Hibernate lazy proxy와 영속성 컨텍스트 뜯어보기 — `.fetch()` 한 줄이 N+1을 없앨 수 있는가
시작은 한 줄짜리 의문#
운영 리포지토리에서 이런 쿼리를 봤습니다.
qf.selectFrom(memberOrgRelation)
.join(memberOrgRelation.organization, organization).fetchJoin()
.join(memberOrgRelation.member, member).fetchJoin()
.fetch(); // ← 결과를 변수에 담지 않는다.fetch() 결과를 변수에 담지도 않고 버립니다. 단순한 실수면 지우면 되는데, Hibernate의 warm-up 패턴일 가능성이 있어서 바로 손댈 수 없었습니다. "이게 정말 영속성 컨텍스트에 뭔가를 남기는가? 남긴다면 얼마나? 그리고 그게 실제로 이후 쿼리를 줄이는가?"
판단하려면 Hibernate 내부 동작을 정확히 짚어야 했습니다. 모호한 부분을 하나씩 뜯어보고 H2 + Spring Boot로 실측한 과정을 정리합니다. 전체 재현 코드는 hibernate-warmup-demo에 있습니다.
엔티티는 단순하게 세웠습니다.
Organization 1 ─── N MemberOrgRelation N ─── 1 Member
Organization.memberOrgRelations는 @OneToMany(mappedBy = "organization"), MemberOrgRelation.member / organization은 각각 @ManyToOne(fetch = FetchType.LAZY). 운영에서 문제 된 구조와 동일합니다.
1. .fetch()가 하는 일 — 영속성 컨텍스트 등록#
공식 문서의 설명:
The persistence context acts as a first-level cache, a staging area where every entity instance managed by a given
Sessionis kept.
쿼리 결과를 변수에 담든 말든 관리 대상 엔티티는 Session의 1차 캐시에 등록됩니다. 같은 트랜잭션 안에서 같은 PK를 다시 조회하면 DB를 치지 않고 그 인스턴스를 재사용합니다.
말로만 보면 막연해서 Session.getStatistics().getEntityCount()로 실제 등록 수를 찍어봤습니다.
Session session = em.unwrap(Session.class);
int before = session.getStatistics().getEntityCount();
em.createQuery(
"select r from MemberOrgRelation r " +
"join fetch r.member join fetch r.organization", MemberOrgRelation.class
).getResultList(); // 결과 변수 할당 없음
int after = session.getStatistics().getEntityCount();
System.out.println(before + " → " + after);출력:
[persistence context] entityCount before = 0, after = 11
org(1) + relation(5) + member(5) = 11. 변수에 담지 않아도 Session이 다 들고 있습니다. warm-up 패턴의 전제는 여기서 성립합니다.
다만 11개가 들어갔다는 사실만으로 이후 쿼리가 줄어드는 건 아닙니다. "들어 있는 엔티티를 다시 어떻게 꺼내 쓰는가"가 더 중요합니다. 그 꺼내는 경로가 lazy proxy 초기화입니다.
2. Lazy proxy는 ByteBuddy가 만든 서브클래스다#
relation.getMember()가 반환하는 객체의 실제 타입을 찍어봤습니다.
MemberOrgRelation r = em.createQuery(...).getSingleResult();
Member m = r.getMember();
System.out.println(m.getClass().getName());
System.out.println(m instanceof HibernateProxy);
System.out.println(Hibernate.isInitialized(m));출력:
[proxy] getClass().getName() = com.example.warmup.entity.Member$HibernateProxy$XFh2Oanv
[proxy] instanceof HibernateProxy = true
[proxy] isInitialized(before) = false
중요한 관찰 세 가지.
$HibernateProxy$접미사가 붙은 서브클래스입니다. Hibernate 6는 이걸 ByteBuddy로 런타임에 만들어냅니다. (옛날엔 CGLIB였습니다.)Member엔티티 클래스를 상속한 서브클래스에__getEntityInstance,__setEntityInstance같은 내부 훅이 들어갑니다.- 그 결과로
instanceof HibernateProxy가 true. JPAPersistenceUnitUtil.isLoaded(), HibernateHibernate.isInitialized()로 언제든 상태를 질의할 수 있습니다. - 이 시점에 member의 필드는 아무것도 채워져 있지 않습니다.
isInitialized = false.
필드를 한 번 건드리면:
m.getFullName(); // 초기화 트리거
System.out.println(Hibernate.isInitialized(m)); // true출력:
[proxy] isInitialized(after) = true
이 "건드리면" 이 중요합니다. getFullName() 호출이 proxy 내부의 initialize()를 호출하고, 거기서 SELECT * FROM member WHERE id = ?가 실제로 날아갑니다. 즉 필드에 접근한 그 순간에 쿼리가 나가는 것이지, relation.getMember()를 호출한 순간이 아닙니다.
N+1이 터지는 지점이 정확히 여기입니다. 100번 getMember().getFullName()을 호출하면 100번 초기화되고, 100번 쿼리가 나갑니다.
3. 컬렉션 초기화 — owner를 selectFrom 해야 하는 이유#
이제 org.memberOrgRelations 같은 OneToMany 컬렉션을 봅시다. Hibernate는 컬렉션도 lazy proxy로 감싸서 PersistentSet / PersistentBag 형태로 들고 있습니다.
Organization org = em.find(Organization.class, orgId);
System.out.println(Hibernate.isInitialized(org.getMemberOrgRelations())); // false
org.findMembers(); // 컬렉션 접근
System.out.println(Hibernate.isInitialized(org.getMemberOrgRelations())); // true출력:
[collection] before access: false
[collection] after access: true
접근 순간에 init SELECT가 나갑니다.
select mor1_0.organization_id, mor1_0.id, mor1_0.member_id
from member_org_relation mor1_0
where mor1_0.organization_id = ?한 가지 구분이 필요합니다. OneToMany 컬렉션은 owner(= Organization)를 selectFrom하고 그 컬렉션을 fetchJoin 해야만 로드됩니다. 아래 두 쿼리는 겉모습이 비슷해 보여도 영속성 컨텍스트에 남는 상태가 다릅니다.
// (A) org.memberOrgRelations 컬렉션이 초기화됨
qf.selectFrom(organization)
.leftJoin(organization.memberOrgRelations, memberOrgRelation).fetchJoin()
.fetch();
// (B) 같은 엔티티들을 읽지만 org.memberOrgRelations는 여전히 lazy
qf.selectFrom(memberOrgRelation)
.join(memberOrgRelation.organization, organization).fetchJoin()
.fetch();(A)는 "org를 기준으로 자식 컬렉션을 끌어오는" 쿼리라서 Hibernate가 결과 그룹핑을 통해 org.memberOrgRelations를 초기화 상태로 만듭니다. (B)는 relation들을 각각 로드하고 각 relation에서 org 쪽을 채우는 쿼리라서, org 쪽의 mappedBy 컬렉션은 건드리지 않습니다.
도입부에서 본 .fetch()가 (B)였습니다. "relation 전체를 영속성 컨텍스트에 올려두면 이후 org.memberOrgRelations 접근이 빨라지지 않을까" 하는 기대를 부르기 쉬운 모양이지만, 실제로는 그 컬렉션은 여전히 lazy입니다.
다만 완전히 의미 없는 건 아닙니다. (B)로 member 엔티티들이 1차 캐시에 들어가 있으면, 나중에 org.memberOrgRelations가 init SELECT로 relation을 가져온 뒤 relation.getMember()로 proxy 초기화를 시도할 때 1차 캐시 히트로 전환됩니다. 컬렉션 init 쿼리는 그대로 나가지만, 그 다음 이어지는 N+1이 사라집니다.
실측 기준으로 보면:
- warm-up 없이
org.findMembers().forEach(Member::getFullName)→ 102회 (org find 1 + collection init 1 + member 100) - warm-up 있고 같은 호출 → 1회 (collection init만)
쿼리 수가 이렇게 갈리는 이유를 proxy 상태로 다시 풀어보면, warm-up이 있는 경우에는 join fetch로 로드된 member가 proxy가 아닌 실체 엔티티로 반환되기 때문입니다.
[warm-up] isLoaded(member) = true
[warm-up] instanceof HibernateProxy = false
join fetch가 걸린 member는 $HibernateProxy$ 서브클래스조차 만들어지지 않고 곧장 초기화된 인스턴스로 영속성 컨텍스트에 들어갑니다. 초기화할 일 자체가 없으니 이후 호출이 몇 번이든 쿼리가 안 나갑니다.
4. Batch fetching — pending proxy를 IN 쿼리로 묶는 알고리즘#
warm-up 없이 N+1을 피하는 또 다른 길이 batch fetching입니다. 공식 문서:
Batch fetching allows Hibernate to fetch multiple lazy entity/collection references in a single SQL statement, which can reduce the number of round trips.
원리는 이렇습니다.
- 필드 접근으로 proxy 초기화가 트리거됨
- Hibernate가 같은 엔티티 타입에서 아직 초기화되지 않은 다른 proxy들을 영속성 컨텍스트에서 찾음
- 그것들을 합쳐서
WHERE id IN (..)한 방에 로드
모아두는 단위가 default_batch_fetch_size 또는 엔티티의 @BatchSize입니다. 100으로 세팅한 상태에서 member 100명을 돌린 결과:
select m1_0.id, m1_0.full_name
from member m1_0
where m1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)member 접근 100번이 IN 쿼리 1번으로 묶입니다.
경계를 넘겼을 때의 동작을 확인하기 위해 member 수를 250으로 늘려 같은 코드를 돌렸습니다.
[member=250, batch=100]
> select o1_0.id, o1_0.name, o1_0.parent_id from organization o1_0 where o1_0.id=?
> select mor1_0.organization_id, mor1_0.id, mor1_0.member_id
from member_org_relation mor1_0 where mor1_0.organization_id=?
> select m1_0.id, m1_0.full_name from member m1_0 where m1_0.id in (? × 100)
> select m1_0.id, m1_0.full_name from member m1_0 where m1_0.id in (? × 100)
> select m1_0.id, m1_0.full_name from member m1_0 where m1_0.id in (? × 50)
정확히 ⌈250 / 100⌉ = 3번으로 쪼개집니다. 즉 batch fetching은 batch_size 단위로 pending proxy를 절단해서 IN 쿼리를 발행합니다. N+1이 선형에서 ⌈N / batch_size⌉ 로 줄어듭니다.
SQL 텍스트로 검증한 테스트 코드:
long memberQueries = CapturingStatementInspector.countMatching("from member ");
int expected = (int) Math.ceil((double) MEMBER_COUNT / BATCH_SIZE);
assertThat(memberQueries).isEqualTo(expected);이게 warm-up이 꼭 필요하지 않을 수도 있다는 근거가 됩니다. proxy를 수동으로 "미리 실체로 만드는" 작업(warm-up) 대신, Hibernate가 proxy 초기화 시점에 알아서 묶어 주는 길이 있는 셈입니다.
5. 그래서 .fetch() 한 줄이 의미 있는가#
지금까지 본 규칙을 도입부의 그 코드에 대입해보면:
- 변수에 담지 않은
.fetch()도 영속성 컨텍스트에 엔티티를 등록한다. 이 부분은 유효. - 그런데 그 코드는
selectFrom(memberOrgRelation)형태라 org의 OneToMany 컬렉션 자체는 초기화하지 않는다. 즉 이후org.findMembers()를 호출하면 컬렉션 init SELECT가 여전히 나간다. - 다만 init SELECT 결과로 뽑힌 relation들의
.getMember()proxy는 영속성 컨텍스트 히트로 초기화된다. 여기까지만 보면 "N+1 제거"라는 warm-up 의도는 성립. - 하지만 저 warm-up은 테넌트 전체의 relation과 member를 매 요청마다 긁는다. 한 요청에 필요한 건 특정 org 하나의 member뿐인데도. 데이터가 조금만 많아져도 warm-up 자체가 주요 비용.
- 그리고 해당 프로젝트의
application.yml엔 이미 이 줄이 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100즉 warm-up을 지워도 batch fetching이 ⌈N / 100⌉로 쿼리를 묶어줍니다. warm-up이 지키던 불변식(= "이후 proxy 초기화 시 쿼리 없음")보다, warm-up이 매번 치르는 비용(= "테넌트 전체 풀스캔")이 더 큽니다. 지우는 쪽이 이득입니다.
실제로 문제의 메서드에서 .fetch() 한 줄을 지우고 개발 환경에서 curl로 측정해보니 이런 결과가 나왔습니다.
| 테넌트 특성 | 변경 전 | 변경 후 |
|---|---|---|
| relation 7,181개 | 1,841ms | 68ms |
| org 486, 특정 노드 child 205개 | 781ms | 82ms |
| org 169 | 409ms | 71ms |
응답 JSON은 diff로 비교해서 동일함을 확인했습니다. batch fetching이 설정된 환경에서 실측이 이론을 따라갑니다.
6. 세 축을 함께 본다#
.fetch() 한 줄의 해석은 다음 세 축에서 동시에 결정됩니다.
엔티티 관계 선언. fetch, cascade, orphanRemoval, 그리고 @OneToMany(mappedBy = ...)가 가리키는 owning side. owner를 selectFrom 해야 OneToMany 컬렉션이 초기화된다는 규칙은 선언만 봐도 파악됩니다.
전역 페치 설정. default_batch_fetch_size, 엔티티 레벨 @BatchSize, 2차 캐시. 이 중 하나라도 활성화되어 있으면 같은 코드의 의미가 달라집니다. 특히 default_batch_fetch_size는 warm-up이 제공하던 효과를 상당 부분 대체합니다.
호출 경로. 반환된 엔티티가 이후 어떤 필드·컬렉션에 접근하는지. join fetch로 로드한 연관은 실체 엔티티로 반환되므로 proxy 초기화가 발생하지 않습니다. warm-up으로 올린 엔티티는 proxy로 반환되더라도 초기화 시 1차 캐시에 적중합니다. 호출 경로가 해당 관계에 접근하지 않는다면 warm-up은 비용만 남깁니다.
도입부의 .fetch()가 "삭제해도 무방한 코드"로 확정된 근거는 세 축 모두에서 일치합니다. 어느 하나라도 빠진 상태에서 판단하면 결론이 다른 축으로 기웁니다. 코드를 수정하기 전에 세 축을 모두 확인하는 순서가 필요합니다.