@Basic(fetch = LAZY)가 왜 안 되지?
Hibernate Bytecode Enhancement 적용기
문제를 발견한 계기
진단 플로우를 리뷰하다가 이상한 걸 발견했다. 상태 변경 로직 — 이벤트 리스너, 전화번호 인증 — 에서 findById를 호출할 때마다 SQL에 report_data가 섞여 나오는 것이었다.
reportData는 진단 결과 JSON 전체를 담는 TEXT 컬럼이다. 실제 진단 데이터가 쌓이면 수백 KB가 될 수 있는 컬럼이 불필요한 모든 조회에 딸려오고 있었다.
어떤 구조였나
Diagnosis 엔티티에는 진단 결과 JSON 전체를 담는 reportData TEXT 컬럼이 있다.
@Entity
class Diagnosis(
val id: String = Ulid.generate(),
val studentName: String,
var status: DiagnosisStatus = DiagnosisStatus.PENDING,
@Column(columnDefinition = "TEXT")
var reportData: String? = null, // 진단 결과 JSON — 수백 KB일 수 있음
)
이 컬럼이 실제로 필요한 곳은 딱 두 곳이다.
GetDiagnosisDetailUseCase— 상세 조회 APIGetDiagnosisResultUseCase— 결과 조회 API
그런데 상태 변경 용도의 모든 findById에서도 reportData가 함께 SELECT되고 있었다. 진단 건수가 늘어날수록 이 불필요한 I/O가 누적될 것은 자명했다.
// 이벤트 리스너 — status만 바꾸면 되는데 reportData까지 가져옴
fun handleDiagnosisRequested(event: DiagnosisRequestedEvent) {
val diagnosis = diagnosisAdaptor.findById(event.diagnosisId)
// diagnosis.callbackSecret, studentPhone, parentPhone만 씀
// reportData? 안 씀. 근데 SELECT에는 들어있음
}
첫 번째 시도: @Basic(fetch = LAZY)
JPA 스펙에는 단순 필드에도 lazy loading을 지정하는 방법이 있다.
@Basic(fetch = FetchType.LAZY)
@Column(columnDefinition = "TEXT")
var reportData: String? = null
이 코드는 동작하지 않는다. 어노테이션을 붙여도 Hibernate는 여전히 reportData를 매번 SELECT한다.
왜 동작하지 않는가
@OneToMany 같은 연관관계의 lazy loading은 프록시 객체로 동작한다. 연관 엔티티 자리에 프록시를 끼워넣고, 실제 접근 시 초기화한다.
하지만 String? 같은 단순 필드는 프록시를 끼워넣을 수가 없다. null이냐 아니냐밖에 표현할 수 없기 때문이다.
Hibernate가 @Basic(fetch = LAZY)를 실제로 동작하게 하려면, 엔티티 클래스 자체가 필드 접근을 가로챌 수 있어야 한다. 이를 위해 Bytecode Enhancement가 필요하다.
Bytecode Enhancement란
컴파일된 .class 파일을 후처리하여 Hibernate 인터페이스를 구현하게 만드는 작업이다. Enhancement된 엔티티는 PersistentAttributeInterceptable을 구현하며, 각 필드에 대한 getter/setter에 인터셉터 호출이 삽입된다.
// Enhancement 전 (컴파일 결과)
class Diagnosis {
var reportData: String? = null
}
// Enhancement 후 (바이트코드 수정)
class Diagnosis : PersistentAttributeInterceptable {
private var $$_hibernate_attributeInterceptor: PersistentAttributeInterceptor?
var reportData: String?
get() {
// 인터셉터가 있으면 → lazy load 트리거
return $$_hibernate_attributeInterceptor
?.readObject(this, "reportData", field)
?: field
}
}
적용 방법 (Gradle + Kotlin)
1. Hibernate 버전 확인
플러그인 버전은 프로젝트의 Hibernate 버전과 일치해야 한다.
./gradlew :SClass-Domain:dependencies --configuration runtimeClasspath \
| grep hibernate-core
# 출력: org.hibernate.orm:hibernate-core:7.2.4.Final
2. SClass-Domain/build.gradle.kts
plugins {
id("org.hibernate.orm") version "7.2.4.Final"
}
hibernate {
enhancement {
enableLazyInitialization.set(true)
}
}
tasks.bootJar { enabled = false }
tasks.jar { enabled = true }
dependencies {
// 기존 의존성 ...
}
Hibernate 7.x에서 Kotlin DSL의 프로퍼티명은 lazyInitialization이 아니라 enableLazyInitialization이다. 이름을 틀리면 Unresolved reference 컴파일 오류가 발생한다.
3. Diagnosis.kt
@Basic(fetch = FetchType.LAZY) // 이제 실제로 동작함
@Column(columnDefinition = "TEXT")
var reportData: String? = null
검증: 테스트로 확인하기
Enhancement가 실제로 동작하는지 Hibernate.isPropertyInitialized()로 검증했다. 이 API는 해당 필드가 DB에서 로드됐는지 여부를 직접 반환한다.
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QuerydslConfig::class)
class DiagnosisLazyLoadingTest {
@Test
fun `findById 조회 직후 reportData는 초기화되지 않는다`() {
val saved = repository.save(Diagnosis(reportData = "{\"score\":95}", ...))
em.flush()
em.clear() // 1차 캐시 제거 → 재조회 시 DB hit
val loaded = repository.findById(saved.id).get()
// reportData에 접근하지 않았으므로 SELECT 안 쳤음
assertFalse(Hibernate.isPropertyInitialized(loaded, "reportData"))
}
@Test
fun `reportData에 접근하면 초기화된다`() {
val saved = repository.save(Diagnosis(reportData = "{\"score\":95}", ...))
em.flush()
em.clear()
val loaded = repository.findById(saved.id).get()
loaded.reportData // 접근 → lazy SELECT 트리거
assertTrue(Hibernate.isPropertyInitialized(loaded, "reportData"))
}
}
두 테스트 모두 통과 = Enhancement 정상 동작.
Enhancement 적용 여부 확인
빌드 후 javap로 클래스를 디컴파일하면 PersistentAttributeInterceptable 구현 여부로 확인할 수 있다.
javap -p build/classes/kotlin/main/.../Diagnosis.class \
| grep "implements\|hibernate"
# Enhancement 적용된 경우:
public final class Diagnosis extends BaseTimeEntity
implements ManagedEntity, PersistentAttributeInterceptable, ...
# Enhancement 없는 경우:
public final class Diagnosis extends BaseTimeEntity
주의사항
| 상황 | 동작 |
|---|---|
트랜잭션 밖에서 reportData 접근 |
LazyInitializationException 발생 |
| 트랜잭션 안에서 접근 | 추가 SELECT 후 정상 반환 |
complete(reportData) 호출 (쓰기) |
정상 동작 — 쓰기는 lazy 무관 |
| 단위 테스트에서 직접 생성한 객체 | Enhancement 미적용 → 일반 필드로 동작 |
@Transactional 밖에서 lazy 필드에 접근하면 LazyInitializationException이 터진다. 이미 UseCase에 @Transactional이 선언되어 있다면 문제없지만, 트랜잭션 범위를 벗어난 DTO 변환 시 주의해야 한다.
실측 수치
실제 진단 결과 JSON과 유사한 크기(~79KB)의 payload로 100건을 로드하는 벤치마크를 작성했다. 측정 방식은 로드된 엔티티의 String 필드 바이트 합산이다 (JVM heap 직접 측정은 GC 타이밍 노이즈가 심해 부정확).
// DiagnosisLazyBenchmarkTest — 100건 × 79KB reportData
// 상태 변경 경로: status만 읽고 reportData 미접근
heap에 올라온 데이터 : 6KB
reportData 로드 여부 : 없음 (lazy — DB에서 가져오지 않음)
// 결과 조회 경로: reportData 접근 → lazy load 100회
heap에 올라온 데이터 : 7,938KB
reportData 로드 여부 : 있음 (100건 × 79KB)
// 비교
상태 변경 경로 절약량 : 7,932KB (~7.7MB / 100건)
건당 절약량 : 79KB
1,000건 처리 시 절약 : ~77MB
상태 변경 경로(이벤트 리스너, 전화번호 인증 등)에서 100건 처리 시 ~7.7MB가 heap에 올라오지 않는다. 진단 건수가 누적될수록 이 차이는 선형으로 증가한다.
실제 발생하는 SQL 차이
SQL 로그로도 동작을 확인할 수 있다.
-- 상태 변경 시: report_data 컬럼 없음
SELECT d.id, d.callback_secret, d.created_at, d.parent_phone,
d.request_data, d.request_id, d.result_url,
d.status, d.student_name, d.student_phone, d.updated_at
FROM diagnoses d WHERE d.id = ?
-- 결과 조회 시: reportData 접근 순간 추가 쿼리
SELECT d.report_data FROM diagnoses d WHERE d.id = ?