dev notes

Hibernate lazy proxy와 영속성 컨텍스트 뜯어보기 — `.fetch()` 한 줄이 N+1을 없앨 수 있는가

2026-04-1515 min read
공유

시작은 한 줄짜리 의문#

운영 리포지토리에서 이런 쿼리를 봤습니다.

java
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 Session is kept.

쿼리 결과를 변수에 담든 말든 관리 대상 엔티티는 Session의 1차 캐시에 등록됩니다. 같은 트랜잭션 안에서 같은 PK를 다시 조회하면 DB를 치지 않고 그 인스턴스를 재사용합니다.

말로만 보면 막연해서 Session.getStatistics().getEntityCount()로 실제 등록 수를 찍어봤습니다.

java
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()가 반환하는 객체의 실제 타입을 찍어봤습니다.

java
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. JPA PersistenceUnitUtil.isLoaded(), Hibernate Hibernate.isInitialized()로 언제든 상태를 질의할 수 있습니다.
  • 이 시점에 member의 필드는 아무것도 채워져 있지 않습니다. isInitialized = false.

필드를 한 번 건드리면:

java
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 형태로 들고 있습니다.

java
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가 나갑니다.

sql
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 해야만 로드됩니다. 아래 두 쿼리는 겉모습이 비슷해 보여도 영속성 컨텍스트에 남는 상태가 다릅니다.

java
// (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.

원리는 이렇습니다.

  1. 필드 접근으로 proxy 초기화가 트리거됨
  2. Hibernate가 같은 엔티티 타입에서 아직 초기화되지 않은 다른 proxy들을 영속성 컨텍스트에서 찾음
  3. 그것들을 합쳐서 WHERE id IN (..) 한 방에 로드

모아두는 단위가 default_batch_fetch_size 또는 엔티티의 @BatchSize입니다. 100으로 세팅한 상태에서 member 100명을 돌린 결과:

sql
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 텍스트로 검증한 테스트 코드:

java
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엔 이미 이 줄이 있다.
yaml
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,841ms68ms
org 486, 특정 노드 child 205개781ms82ms
org 169409ms71ms

응답 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()가 "삭제해도 무방한 코드"로 확정된 근거는 세 축 모두에서 일치합니다. 어느 하나라도 빠진 상태에서 판단하면 결론이 다른 축으로 기웁니다. 코드를 수정하기 전에 세 축을 모두 확인하는 순서가 필요합니다.

Connected Notes