연관 엔티티를 한 번의 조인 쿼리로 함께 가져와 즉시 초기화하는 JPQL 문법. N+1의 대표 해법이다.
일반 조인과 결과 메모리가 다르다
-- 일반 조인 — SELECT 대상만 채워진다. member 는 여전히 프록시
select o from Order o join o.member m
-- fetch join — member 까지 실제 인스턴스로 채워진다
select o from Order o join fetch o.member
메모리로 보면 이렇게 갈린다.
| 일반 조인 : | Order{ member → 프록시(비어 있음) } | → 나중에 SQL 또 나감 |
|---|---|---|
| fetch join: | Order{ member → Member 인스턴스 } | → 추가 SQL 없음 |
컬렉션 fetch join의 함정 ① — 페이징과 같이 쓰면 안 된다
select t from Team t join fetch t.members
팀 1개에 멤버 3명이면 조인 결과는 3행이 된다. 여기에 limit 2를 걸면 팀이 아니라
행이 잘려 데이터가 깨진다.
그래서 하이버네이트는 DB에서 페이징하지 못하고 전체를 메모리로 읽어 와서 자른다.
경고 로그(firstResult/maxResults specified with collection fetch; applying in memory)를
남기며, 데이터가 커지면 그대로 OOM이다.
→ 루트만 페이징하고 컬렉션은 @BatchSize로 푼다.
- 1) select t from Team t (페이징) — → 팀 10개
- select m from Member m where team_id in (…) → IN 절 한 방
- 총 2번. N+1이 1+1이 된다
spring.jpa.properties.hibernate.default_batch_fetch_size: 100 으로 전역 설정하는 것이
실무에서 가장 흔한 처방이다.
함정 ② — 컬렉션을 둘 이상 fetch join하면 곱집합
멤버 3명 × 프로젝트 4개 = 12행으로 부풀고 중복 제거도 어려워진다. 하나만 fetch join하고 나머지는 batch로 푼다.
중복 제거
컬렉션 조인은 루트가 행 수만큼 중복된다. JPQL distinct는 SQL DISTINCT를 붙이는 동시에
애플리케이션에서 같은 엔티티 참조를 합쳐 준다. 하이버네이트 6부터는 기본 동작이라
distinct를 쓰지 않아도 된다.