JPA Performance Kotlin

@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일 수 있음
)

이 컬럼이 실제로 필요한 곳은 딱 두 곳이다.

그런데 상태 변경 용도의 모든 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이냐 아니냐밖에 표현할 수 없기 때문이다.

@OneToMany lazy Diagnosis items = Proxy Proxy 접근 시 초기화 @Basic(fetch=LAZY) — enhancement 없음 Diagnosis reportData = "..." ← 이미 로드됨 items 접근 시 SELECT items WHERE ... 어노테이션 무시됨 findById 때 이미 SELECT됨

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 {
    // 기존 의존성 ...
}
💡 Kotlin DSL 주의사항

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 = ?

PR: seeun0210/s-class-backend#163