들어가며
JPA를 사용하다 보면 일반적인 JDBC에 비해 객체 매핑에 따른 오버헤드를 걱정하게 됩니다. 그러나 실제 테스트 결과 JpaCursorItemReader가 JdbcCursorItemReader보다 2~5배 빠른 결과를 보였습니다. 이 의외의 결과가 단순히 로깅 문제였다는 것을 나중에 알게 되었지만, 이 과정에서 Hibernate의 객체 매핑 메커니즘을 자세히 살펴볼 기회가 생겼습니다.
이 글에서는 Hibernate가 어떻게 DB 결과를 자바 객체로 효율적으로 매핑하는지, 그 내부 동작 원리를 살펴보겠습니다.
실행 환경
- JDK 17
- Hibernate 6.6
📌 TL;DR (요약)
JPA의 객체 매핑은 느릴 거라 생각했지만, Hibernate는 실제로 빠르게 동작한다.
그 이유는 Hibernate가
✅ 필드 접근 방식을 미리 캐싱하고,
✅ Unsafe.putReference를 통해 메모리에 직접 값 주입을 하기 때문.
🍸 Hibernate 객체 매핑 프로세스
Hibernate의 객체 매핑은 다음 4단계로 이루어집니다:
1. JDBC ResultSet에서 원시 값 추출 (JdbcValuesResultSetImpl)
2. 결과 그래프 구성 (EntityResultImpl 등)
3. 엔티티 초기화 (EntityInitializerImpl)
4. 엔티티 어셈블 (EntityAssembler)
이 글에서는 단일 테이블 매핑에 초점을 맞추기 때문에 2번과 4번은 생략하고, 핵심인 3번 과정을 자세히 살펴보겠습니다.
설명을 위해 간단한 엔티티 예시를 준비했습니다:
@Entity
class Document {
long id;
int number;
}
🏉 EntityInitializerImpl 분석
Hibernate의 org.hibernate.sql.results.graph.entity.internal
패키지 내 EntityInitializerImpl
클래스는 엔티티의 초기화, 생성, 프록시 처리, 캐시 관리 등을 담당합니다.
이 클래스의 initializeEntityInstance
메소드를 살펴보겠습니다:
protected void initializeEntityInstance(EntityInitializerData data) {
// ...
final Object[] resolvedEntityState = extractConcreteTypeStateValues(data);
final Object entityInstanceForNotify = data.entityInstanceForNotify;
data.concreteDescriptor.setPropertyValues(entityInstanceForNotify, resolvedEntityState);
// ...
}
resolvedEntityState
: DB에서 읽어온 값들을 객체 속성 배열로 매핑 (예:[id, number]
)entityInstanceForNotify
: Hibernate가 생성한Document
인스턴스data.concreteDescriptor.setPropertyValues
: 인스턴스에 필드값을 주입하는 부분
🚒 AbstractEntityPersister의 역할
위 코드의 data.concreteDescriptor
는 AbstractEntityPersister
타입입니다. 이 클래스의 setPropertyValues
메소드를 분석해보겠습니다:
private Setter[] setterCache;
@Override
public void setPropertyValues(Object object, Object[] values) {
if (accessOptimizer != null) {
accessOptimizer.setPropertyValues(object, values);
}
else {
final BytecodeEnhancementMetadata enhancementMetadata = entityMetamodel.getBytecodeEnhancementMetadata();
final AttributeMappingsList attributeMappings = getAttributeMappings();
if (enhancementMetadata.isEnhancedForLazyLoading()) {
for (int i = 0; i < attributeMappings.size(); i++) {
final Object value = values[i];
if (value != UNFETCHED_PROPERTY) {
setterCache[i].set(object, value);
}
}
}
else {
for (int i = 0; i < attributeMappings.size(); i++) {
setterCache[i].set(object, values[i]);
}
}
}
}
몇 가지 중요한 로직을 살펴보겠습니다:
accessOptimizer
옵션:
if (accessOptimizer != null) {
accessOptimizer.setPropertyValues(object, values);
}
이 옵션이 활성화되면, 리플렉션 대신 ByteBuddy를 사용한 바이트코드 기반 조작 방식을 사용합니다. 기본적으로 비활성화되어 있습니다.
2. 지연 로딩(LazyLoading) 처리:
if (enhancementMetadata.isEnhancedForLazyLoading()) {
for (int i = 0; i < attributeMappings.size(); i++) {
final Object value = values[i];
if (value != UNFETCHED_PROPERTY) {
setterCache[i].set(object, value);
}
}
}
지연 로딩을 위한 조건문으로, 아직 로딩되지 않은 필드(UNFETCHED_PROPERTY
)는 무시하고 처리합니다.
setterCache의 비밀
핵심은 다음 코드입니다:
private Setter[] setterCache;
setterCache[i].set(object, value);
setterCache
는 리플렉션을 위한 필드 정보를 캐싱한 것입니다. Hibernate는 성능 최적화를 위해 엔티티 클래스의 모든 필드 정보를 Setter[]
배열에 미리 캐싱해둡니다.
🙃 SetterFieldImpl 분석
이제 실제 값 주입을 담당하는 SetterFieldImpl
클래스를 살펴보겠습니다:
public class SetterFieldImpl implements Setter {
private final Class<?> containerClass;
private final String propertyName;
private final Field field;
private final @Nullable Method setterMethod;
public void set(Object target, @Nullable Object value) {
try {
field.set(target, value);
}
catch (Exception e) {
// ...
}
}
}
SetterFieldImpl
은 필드 정보를 저장하고, Java 리플렉션 API의 Field.set()
메소드를 호출하여 필드 값을 설정합니다. 이 메소드는 내부적으로 FieldAccessor
인터페이스의 구현체 중 하나를 선택하여 실행합니다.
🐑 UnsafeObjectFieldAccessorImpl과 직접 메모리 접근
대표적인 FieldAccessor
구현체인 UnsafeObjectFieldAccessorImpl
의 set
메소드를 보면 성능 비밀이 드러납니다:
public void set(Object obj, Object value)
throws IllegalArgumentException, IllegalAccessException
{
ensureObj(obj);
if (isFinal) {
throwFinalFieldIllegalAccessException(value);
}
if (value != null) {
if (!field.getType().isAssignableFrom(value.getClass())) {
throwSetIllegalArgumentException(value);
}
}
unsafe.putReference(obj, fieldOffset, value);
}
여기서 핵심은 마지막 줄의 unsafe.putReference(obj, fieldOffset, value)
입니다. 이 코드는 fieldOffset
을 사용해 해당 필드에 값을 직접 주입합니다.
바로 이 직접 메모리 접근 방식이 Hibernate 객체 매핑의 성능 비결입니다!
getter/setter 메소드를 호출하지 않고 메모리에 직접 접근하기 때문에 성능이 뛰어납니다.
보너스: fieldOffset이란?
자바 객체의 메모리 구조는 다음과 같이 구성됩니다:
| 객체 헤더 (Object Header) | 필드 데이터 (Instance Fields) | 패딩 (Padding) |
예를 들어, 우리의 Document
클래스가 인스턴스화되면 메모리에 다음과 같이 배치됩니다:
[0x00] Mark Word (8 bytes)
[0x08] Klass Pointer (8 bytes)
[0x10] long id (8 bytes)
[0x18] int number (4 bytes)
이때 id
필드의 offset은 0x10
(16), number
필드의 offset은 0x18
(24)입니다. 인스턴스의 메모리 주소에 이 offset을 더하면 해당 필드 값에 직접 접근할 수 있습니다.
보너스2: BeanPropertyRowMapper 와의 비교
청크사이즈 | JPA-Cursor | JDBC-Cursor(default) | JDBC-Cursor(필드주입) |
---|---|---|---|
2000 | 1m49s | 1m53s | 1m43s |
1000 | 3m59s | 4m12s | 3m58s |
500 | 7m51s | 8m00s | 7m47s |
JpaCursorItemReader와 JdbcCursorItemReader의 성능을 비교해 보았습니다. 일반적으로 JPA는 영속성 컨텍스트 오버헤드로 인해 성능이 다소 저하될 것이라 예상했으나, 실제 테스트 결과는 그렇지 않았습니다.
그 원인을 분석해 보니 RowMapper 구현 방식의 차이에 있었습니다. JdbcCursorItemReader에서 기본적으로 사용하는 BeanPropertyRowMapper는 리플렉션을 사용하지만, JPA에서 적용된 최적화 기법들이 부재하여 성능이 상대적으로 떨어졌습니다.
이를 검증하기 위해 SetterFieldImpl과 유사한 방식으로 커스텀 RowMapper를 구현하여 테스트했더니, JDBC-Cursor(필드주입) 항목에서 볼 수 있듯이 성능이 향상되었습니다.
public class FieldAccessRowMapper implements RowMapper<Document> {
private static final Field idField;
private static final Field numberField;
static {
try {
idField = getField("id");
numberField = getField("number");
} catch (Exception e) {
throw new RuntimeException("Field 초기화 실패", e);
}
}
private static Field getField(String name) throws NoSuchFieldException {
Field field = Document.class.getDeclaredField(name);
field.setAccessible(true);
return field;
}
@Override
public Document mapRow(ResultSet rs, int rowNum) throws SQLException {
Document doc = new Document();
idField.set(doc, rs.getLong("id"));
numberField.set(doc, rs.getInt("number"));
return doc;
}
}
결론
Hibernate가 성능을 위해 어떤 최적화를 사용하는지 살펴보았습니다. JDBC ResultSet에서 읽어온 값을 객체에 주입할 때, Hibernate는 리플렉션을 통한 직접 메모리 접근 방식을 사용합니다.
- 필드 정보를 캐싱하여 반복적인 리플렉션 비용을 줄입니다.
unsafe.putReference
를 사용한 직접 메모리 접근으로 getter/setter 호출 오버헤드를 제거합니다.
'스프링' 카테고리의 다른 글
@ExceptionHandler는 어떻게 동작할까? Spring 예외 처리의 내부 동작 원리 파헤치기 (2) | 2025.06.25 |
---|---|
톰캣 NIOConnector는 어떻게 수많은 요청을 처리할까? (feat. Acceptor, Poller) (0) | 2025.06.02 |
JPA의 @Transactional(readOnly = true) 동작 (0) | 2025.05.11 |