Reflection은 Hot Path의 병목이 될 수 있을까
getDeclaredField()와 field.get()이 CPU 레벨에서 왜 비싼지, pointer chasing·cache miss·branch misprediction 관점에서 분석하고 단계별 개선 방향을 제시합니다.
업무 중 필드가 수십 개인 테이블 구조에서 특정 필드명으로 접근하는 로직을 reflection 방식으로 구현했는데, 실제 운영 중에 부하가 집중되는 상황에서 발생한 오류 디버깅을 하는 중, reflection이 병목 요인이 될 수 있을까 하는 궁금증이 생겼다.
문제 코드
문제가 된 코드는 대략 이런 형태였다.
private Object getFieldValue(Source source, String fieldName) {
try {
Field field = Source.class.getDeclaredField(fieldName);
return field.get(source);
} catch (NoSuchFieldException e) {
log.warn("Unknown field: {} on Source", fieldName);
} catch (Exception e) {
log.warn("Failed to get field value: {} from Source", fieldName);
}
return null;
}
이 코드가 문제가 될 수 있는 전제 조건은 다음과 같다.
- 매 요청마다 호출됨
- for문 등의 구조로 인해 요청 1회에도 내부적으로는 반복 호출됨
- 특정 이벤트 타이밍에 트래픽이 집중됨
겉으로는 필드 하나 읽는 작은 유틸처럼 보인다. 하지만 이 코드가 fallback 진입 시마다 반복 호출되는 경로에 위치하면 이야기가 달라진다.
요청
→ fallback 진입
→ getFieldValue() 반복 호출
→ getDeclaredField() 매번 실행
핵심은 reflection 1회의 절대 비용이 아니라, 느린 경로에서의 누적이었다. 그래서 먼저 이 코드 안에서 실제로 어떤 비용이 반복되는지부터 분해해봤다.
만약 reflection이 병목 요인으로 작용한 게 맞다면 왜 느린지, 어떤 상황에서 병목이 되는지를 low-level 수준에서 파보려 한다.
Reflection의 비용 구조
Source.class.getDeclaredField(fieldName) 는 단순한 map lookup이 아니다. 내부적으로 다음 작업이 수반된다.
- 클래스 메타데이터 탐색
- declared field 배열 순회 + 이름 비교(String.equals)
- 해당 클래스에 선언된 필드가 없으면
NoSuchFieldException발생 - 접근 권한 체크 + Field 객체 반환
field.get(obj) 도 마찬가지다. 단순한 값 읽기가 아니라 indirection, access check, primitive boxing 가능성까지 포함된다.
왜 CPU 입장에서 비쌀까
reflection이 느린 근본 이유는 "추가 연산"이 아니라 CPU가 선호하는 메모리 접근 패턴을 깨기 때문이다.
처음에는 getDeclaredField() 호출 횟수만 줄이면 충분하지 않을까 생각했다. 그런데 flame graph나 로그만 보고는 비용의 성격이 잘 보이지 않았다. 그래서 JVM이 결국 어떤 메모리 접근을 만들게 되는지 기준으로 다시 나눠봤다.
전체 흐름을 계층별로 보면 이렇다.
Java 코드
→ JVM (Reflection / MethodAccessor / Metadata)
→ 메모리 접근 패턴 (pointer chasing)
→ CPU cache miss / branch misprediction
→ pipeline stall
일반 getter 호출은 이렇게 동작한다
obj.getName()
JIT 최적화 이후 CPU 레벨에서는 거의 이렇게 된다.
mov rax, [obj + offset]
정확한 어셈블리는 JDK와 CPU, JIT 상태에 따라 달라지지만, 핵심은 동일하다. offset이 컴파일 타임에 고정되고, JIT이 getter를 inline하기 쉬워진다. 호출 경로가 짧고 예측 가능해지는 것이다.
reflection은 이렇게 동작한다
field.get(obj)
Class 객체 접근
→ Field lookup (metadata 탐색)
→ access check
→ MethodAccessor / Unsafe 경로 선택
→ 실제 값 읽기
이 차이가 CPU 레벨에서 네 가지 비용으로 이어진다.
① pointer chasing
obj → class pointer → field array → Field object → name (String) → char[]
각 단계가 heap의 다른 위치를 가리키고 있어서 CPU prefetcher가 예측하지 못한다. 연속 메모리가 아니므로 cache line 활용도도 낮다. 결과적으로 pipeline stall이 발생한다.
② cache miss
reflection의 metadata(Class, Field, String)는 heap 여기저기 흩어져 있다. 반복 호출 시 캐시 친화도가 낮아지고, 상황에 따라 L3 또는 RAM access까지 이어질 수 있다.
③ branch misprediction
reflection 내부에는 데이터 기반 조건 분기가 많다.
if (public?) if (accessible?) if (override?) if (primitive?)
CPU는 branch를 예측하지만 reflection은 패턴이 일정하지 않아 misprediction이 발생한다. 정확한 비용은 CPU와 실행 상황에 따라 달라지므로 여기서는 숫자보다 방향이 중요하다. 예측하기 어려운 분기가 반복되면 평균보다 tail latency에서 더 거칠게 드러날 수 있다.
④ JIT 최적화 불가
일반 코드는 inline, constant folding, escape analysis 등이 적용된다. reflection은 런타임에 문자열 기반으로 어떤 필드에 접근할지 결정하기 때문에 JIT이 최적화하지 못한다. 느린 코드가 그대로 실행된다.
언제 병목이 되는가
1회 호출 비용 자체는 크지 않을 수 있다. 문제는 조합이다.
영향이 미미한 경우
- 초기화 시 1~2회
- 관리자성 배치 작업
- 저QPS 경로
- 요청당 1회 이하
병목이 될 수 있는 경우
- 요청당 여러 번 반복
- 루프 안에서 반복
- fallback path처럼 원래 느린 경로에 얹히는 경우
- 고동시성
- p99/tail latency에 민감한 API
위험한 조합
slow path + 반복 호출 + reflection
이미 느린 경로에서 reflection이 반복되면 평균 latency보다 p95/p99 같은 tail latency에서 더 크게 드러난다.
얼마나 느린가
"왜 느린가"는 설명했다. 그렇다면 실제로 얼마나 느릴까.
운영 코드에서 문제가 된 패턴은 단순히 reflection을 썼다는 것이 아니라, 필드 하나를 읽을 때마다 getDeclaredField()를 다시 호출한다는 점이었다. 게다가 이 로직이 shadow validation 흐름에서 row와 fieldName을 중첩 순회하며 반복됐다.
그래서 벤치마크도 두 가지를 나눠서 봤다.
- 단일 필드 1회 접근
- 실제 호출 형태에 가까운
row 1000개 × field 6개반복 접근
비교한 방식은 다음과 같다.
directGetter: getter 직접 호출accessorMap:Map<String, Function<SourceDto, Object>>기반 접근cachedReflection:Field를 미리 찾아두고 재사용rawReflection: 매번getDeclaredField()호출 후setAccessible(true)rawReflectionLikeProd: 운영 코드와 같은 방식. 매번getDeclaredField()만 호출하고setAccessible(true)는 호출하지 않음methodHandle:MethodHandle캐싱

단일 필드 1회 접근에서는 차이가 작아 보일 수 있다.
| 방식 | Avg (ns/op) |
|---|---|
| directGetter | 0.671 |
| accessorMap | 2.001 |
| cachedReflection | 4.488 |
| methodHandle | 6.255 |
| rawReflection | 12.930 |
| rawReflectionLikeProd | 12.267 |
단일 호출 기준으로도 운영 패턴(rawReflectionLikeProd)은 getter 직접 호출보다 약 18배 느렸다. 하지만 이 수치만 보면 실제 문제의 크기가 잘 드러나지 않는다. 운영에서는 이 호출이 한 번 끝나는 게 아니라, row와 fieldName 조합만큼 반복되기 때문이다.
반복 케이스에서 차이가 훨씬 분명해졌다.
| 방식 | Avg (ns/op) |
|---|---|
| directGetterLoop | 1,678.353 |
| accessorMapLoop | 34,405.313 |
| cachedReflectionLoop | 44,080.523 |
| methodHandleLoop | 46,636.494 |
| rawReflectionLoopLikeProd | 106,948.290 |
| rawReflectionLoop | 120,802.265 |
row 1000개 × field 6개 기준으로 보면 운영 패턴은 direct getter 대비 약 64배 느렸다. Field를 캐싱한 방식과 비교해도 약 2.4배, accessor map과 비교해도 약 3.1배 차이가 났다.
여기서 확인하고 싶었던 건 "reflection이 무조건 느리다"가 아니었다. 병목의 핵심은 반복 루프 안에서 getDeclaredField()를 계속 호출하는 구조였다. rawReflectionLikeProd와 cachedReflectionLoop의 차이를 보면, 같은 reflection을 쓰더라도 Field lookup을 반복하느냐 캐싱하느냐에 따라 비용이 꽤 달라진다.
accessorMapLoop와 cachedReflectionLoop의 차이도 의미가 있다. 둘 다 fieldName 기반 동적 접근이라는 점은 같지만, accessor map은 결국 미리 정해둔 getter 호출로 이어진다. JIT 입장에서는 Function 호출이라는 간접성이 남더라도, 내부에서 실행되는 getter 자체는 inline될 여지가 있다. 반면 cached reflection은 lookup 비용을 제거해도 Field.get()이라는 reflection 경로를 계속 통과한다. 그래서 캐싱만으로도 좋아지지만, hot path에서는 reflection 자체를 없애는 쪽이 더 유리했다.
또 하나 눈에 띈 점은 setAccessible(true)를 호출하지 않은 운영 패턴이 특별히 안전하거나 빠른 경로가 아니었다는 점이다. public 필드라 접근은 가능하지만, field.get() 과정의 access check와 reflection 호출 비용은 여전히 남는다. 결국 이 케이스에서는 setAccessible(true) 유무보다 매번 field metadata를 다시 찾는 비용이 더 중요한 문제로 보인다.
sample mode의 tail 쪽도 비슷한 방향을 보여준다. rawReflectionLoopLikeProd의 p99는 약 146,688 ns였고, directGetterLoop의 p99는 약 1,542 ns였다. 평균뿐 아니라 tail에서도 반복 reflection 경로가 훨씬 불리하게 드러난 셈이다.
Microbenchmark 해석 주의
Microbenchmark 수치는 다음 요인에 민감하다.
- JVM 워밍업: warmup iterations가 부족하면 JIT 컴파일 전 상태를 측정하게 된다
- Dead code elimination: 결과를 소비하지 않으면 JIT이 코드 자체를 제거할 수 있다.
Blackhole.consume()필수 - 환경 의존성: JDK 버전, GC, CPU 아키텍처에 따라 수십 % 이상 달라진다
실제 병목 여부는 async-profiler나 JFR로 실 트래픽 하에서 확인하는 것이 맞다. Microbenchmark는 후보를 검증하는 도구이지 병목을 확진하는 도구가 아니다.
이 결과를 보고 선택지는 비교적 분명해졌다. 우선은 getDeclaredField() 반복 호출을 제거하고, 가능하면 fieldName 기반 분기를 getter map으로 밀어내는 방향이 맞다.
개선 방향
getDeclaredField() 비용은 캐싱으로 제거할 수 있다.
private static final ConcurrentHashMap<String, Field> fieldCache = new ConcurrentHashMap<>();
private Object getFieldValue(Source source, String fieldName) {
try {
Field field = fieldCache.computeIfAbsent(fieldName, name -> {
try {
Field f = Source.class.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException | SecurityException e) {
throw new IllegalArgumentException("Field not found: " + name, e);
}
});
return field.get(source);
} catch (Exception e) {
log.warn("Failed to get field value: {}", fieldName);
return null;
}
}
| 항목 | Before | After |
|---|---|---|
| 메타데이터 탐색 | 매번 수행 | 최초 1회 |
| pointer chasing | 많음 | 감소 |
| cache miss | 많음 | 감소 |
단, field.get(obj) 자체의 비용(indirection, JIT inline 불가, boxing 가능성)은 여전히 남는다. 캐싱은 "메타데이터 탐색" 비용만 제거해준다. 또한 setAccessible(true)는 Java 모듈 시스템이나 보안 정책에 따라 제한될 수 있으므로, 라이브러리/플랫폼 코드에서는 사용 가능 여부를 별도로 확인해야 한다.
fieldName의 종류가 제한적이라면 reflection 자체를 없애는 게 가장 효과적이다.
Map<String, Function<Source, Object>> accessors = new HashMap<>();
accessors.put("id", Source::getId);
accessors.put("name", Source::getName);
Function<Source, Object> accessor = accessors.get(fieldName);
if (accessor == null) {
log.warn("Unknown field: {}", fieldName);
return null;
}
return accessor.apply(source);
CPU 관점에서 Map lookup(hash, O(1)) 이후 direct method call이 실행되고, JIT이 getter를 inline할 수 있어 거의 zero-cost에 가까워진다.
| 방식 | 메타데이터 탐색 | field.get() 비용 | JIT 최적화 |
|---|---|---|---|
| 매번 getDeclaredField | 매번 | 있음 | 불가 |
| Field 캐싱 | 1회 | 있음 | 불가 |
| getter map | 없음 | 없음 | 가능 |
fieldName이 런타임에 동적으로 주어지지만 종류가 많아 accessor map을 쓰기 어려운 경우, MethodHandle이 선택지가 될 수 있다.
private static final ConcurrentHashMap<String, MethodHandle> mhCache = new ConcurrentHashMap<>();
private Object getFieldValue(Source source, String fieldName) throws Throwable {
MethodHandle mh = mhCache.computeIfAbsent(fieldName, name -> {
try {
Field field = Source.class.getDeclaredField(name);
field.setAccessible(true);
return MethodHandles.lookup().unreflectGetter(field);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
return mh.invoke(source);
}
핵심은 lookup은 1회, invoke는 매번이라는 구조다.
invokeExact는 타입이 정확히 일치할 때 JIT이 일반 메서드 호출처럼 최적화할 수 있다. 단, fieldName이 완전히 동적이면 어차피 invoke 경로를 타게 되고, 이 경우 cached Field와 큰 차이가 없을 수 있다.
| 항목 | cached Field | MethodHandle |
|---|---|---|
| 메타데이터 탐색 | 1회 | 1회 |
| field.get() / invoke 비용 | 있음 | 있음 (invoke) / 감소 (invokeExact) |
| JIT 최적화 | 어렵 | invokeExact 시 가능 |
대안 선택 기준
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| fieldName이 컴파일 타임에 고정, 종류 제한적 | accessor map | reflection 완전 제거, JIT inline 가능 |
| fieldName이 런타임 동적, 종류 많음 | cached Field 또는 MethodHandle 캐싱 | getDeclaredField 비용만 제거 |
| 라이브러리/범용 코드 | MethodHandle + 캐싱 | 타입 안정성 + 성능 균형 |
| hot path, 성능이 매우 중요 | accessor map 또는 code gen | reflection 자체를 없애는 것이 유일한 해법 |
fieldName이 외부 설정이나 DB 컬럼명에서 오는 경우 accessor map만으로 해결이 안 될 때가 있다. 그 경우 Field 캐싱이 최소한의 개선이고, 정말 hot path라면 APT(Annotation Processing)나 Byte Buddy 같은 code generation을 고려할 수 있다. 단, 그 정도 개선이 필요한지는 프로파일러로 먼저 확인하는 게 순서다.
벤치마크 재현 코드
위 실험을 직접 재현하거나 자신의 환경에서 다시 측정하려면 아래 JMH 코드를 기준으로 시작할 수 있다. 실제 글에 첨부한 결과는 운영 패턴에 맞춰 row 1000개 × field 6개 반복 케이스까지 포함해 측정했다.
@State(Scope.Benchmark)
@BenchmarkMode({Mode.AverageTime, Mode.SampleTime})
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(2)
public class ReflectionBenchmark {
public static class Source {
private String name;
public Source(String name) { this.name = name; }
public String getName() { return name; }
}
private Source source;
private Field cachedField;
private MethodHandle methodHandle;
private Map<String, Function<Source, Object>> accessors;
@Setup(Level.Trial)
public void setup() throws Exception {
source = new Source("test-name");
cachedField = Source.class.getDeclaredField("name");
cachedField.setAccessible(true);
MethodHandles.Lookup lookup = MethodHandles.lookup();
methodHandle = lookup.findVirtual(
Source.class, "getName", MethodType.methodType(String.class)
);
accessors = new HashMap<String, Function<Source, Object>>();
accessors.put("name", new Function<Source, Object>() {
@Override
public Object apply(Source source) {
return source.getName();
}
});
}
@Benchmark
public void rawReflection(Blackhole bh) throws Exception {
Field field = Source.class.getDeclaredField("name");
field.setAccessible(true);
bh.consume(field.get(source));
}
@Benchmark
public void cachedReflection(Blackhole bh) throws Exception {
bh.consume(cachedField.get(source));
}
@Benchmark
public void methodHandle(Blackhole bh) throws Throwable {
bh.consume((String) methodHandle.invokeExact(source));
}
@Benchmark
public void directGetter(Blackhole bh) {
bh.consume(source.getName());
}
@Benchmark
public void accessorMap(Blackhole bh) {
bh.consume(accessors.get("name").apply(source));
}
}
의존성(pom.xml):
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>provided</scope>
</dependency>
mvn clean package -q
java -jar target/benchmarks.jar ReflectionBenchmark
정리
reflection이 느린 이유
CPU cache에 친화적인 연속 접근을 못 하고, 랜덤 메모리 + 분기 + 간접 참조를 반복하기 때문
이번에 확인한 결론은 단순히 "reflection은 느리다"가 아니었다. reflection도 초기화나 저빈도 경로에서는 충분히 쓸 수 있다. 문제는 운영 코드처럼 fallback path + 반복 호출 + 고동시성이 겹친 곳에 getDeclaredField() 반복 호출이 들어갔다는 점이었다.
결국 운영에서 먼저 선택할 만한 순서는 이렇다.
- Field 캐싱 (메타데이터 탐색 반복 제거, 가장 낮은 비용)
- fieldName 종류가 제한적이면 accessor map으로 reflection 제거
- 동적 접근을 유지해야 한다면 MethodHandle 또는 code generation 검토
- hot path에 남은 reflection 호출은 JFR / async-profiler로 다시 확인