핵심 한 줄
9편에서 fetch join으로 N+1을 잡았다. 그런데 그 fetch join에 페이징을 얹는 순간 함정이 열린다 — 조인은 행을 뻥튀기해서
LIMIT을 걸 수 없고, Hibernate는 전체를 메모리에 올린 뒤 잘라낸다(HHH90003004). “5개 주세요”가 “5만 개 로딩”이 되어 OOM. 컬렉션이 둘이면 아예 거부당한다(MultipleBagFetchException). 두 함정의 뿌리는 하나 — 조인은 뻥튀기한다. 해법도 하나 — 조인 대신 배치.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. N+1을 잡은 그 fetch join이, 이번엔 함정이 된다
9편에서 join fetch로 N+1을 없앴다. 주문과 라인을 한 쿼리에 조인해 가져오니 추가 조회가 사라졌다. 깔끔했다. 그때 데이터는 “주문 3개”였다.
실무는 주문이 5만 개다. 그래서 목록은 페이징한다 — “한 페이지 10개씩.” 9편의 fetch join 쿼리에 LIMIT 10만 얹으면 될 것 같다. 그런데 여기서 조용한 재앙이 시작된다.
2. 합치면 자를 수가 없다
fetch join은 행을 뻥튀기한다. 주문 하나에 라인이 2개면 조인 결과는 2행이다.
주문1-라인A / 주문1-라인B / 주문2-라인A / 주문2-라인B / ...
여기에 LIMIT 10을 SQL로 걸면? 10행이 잘리고, 그건 주문 5개다. “10개 주세요” 했는데 5개가 온다. 페이징이 깨진다.
Hibernate도 이걸 안다. 그래서 LIMIT을 SQL에 붙이지 않는다. 대신 조인 결과 전체를 메모리로 가져온 뒤, 메모리에서 10개를 잘라낸다. 그리고 로그에 경고 하나를 흘린다.
WARN org.hibernate.orm.query :
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
실측해 보면 반환 개수는 정확하다(5개 요청 → 5개). 문제는 그 5개를 위해 테이블 전체를 힙에 올렸다는 것이다. SQL을 찍어 보면 limit이 없다.
select ... from orders o join order_line ol on ... -- LIMIT 없음
주문이 5만 건이면, LIMIT 5인 줄 알았던 쿼리가 5만×라인을 통째로 메모리에 적재한다 → OOM. 에러도 아니고 WARN 로그 하나라, 개발 땐 멀쩡하다가 운영에서 조용히 죽는다. 가장 악명 높은 JPA 함정 중 하나다.
3. “@EntityGraph 쓰면 되잖아?” — 안 된다
fetch join 대신 @EntityGraph를 떠올리기 쉽다. 하지만 @EntityGraph도 결국 컬렉션을 조인하는 방식이다. 조인하는 이상 행은 똑같이 뻥튀기되고, 페이징을 걸면 **똑같이 HHH90003004**가 뜬다. 페이징의 해법은 조인의 종류를 바꾸는 게 아니다. 조인을 안 하는 것이다.
4. 정석 — 합치지 말고 나눈다
한 쿼리로 다 하려니 문제였다. 그래서 일을 둘로 쪼갠다.
① 뿌리(주문)만 페이징한다. 조인이 없으니 행이 안 뻥튀기되고, LIMIT이 SQL에 진짜로 먹힌다.
select o.* from orders o limit 5; -- 5행 = 주문 5개. 깔끔.② 컬렉션은 배치로 나중에 가져온다. 그 5개 주문의 라인을, 부모 키를 모아 IN으로 한 방에.
select ol.* from order_line ol where ol.order_id in (?, ?, ?, ?, ?);이 두 번째 쿼리를 자동으로 만들어 주는 설정이 **default_batch_fetch_size**다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # LAZY 컬렉션을 100개씩 IN으로 묶어 로딩측정하면 쿼리는 셋이다 — 카운트(1) + 뿌리 페이징+LIMIT(1) + 라인 배치 IN(1). HHH90003004 경고는 사라진다. N+1(주문마다 1쿼리)도 아니고, 인메모리 전체 적재도 아니다. 조인은 “합쳐서 한 번”, 배치는 “나눠서 IN 두 번.” 규모에선 나누는 쪽이 이긴다.
5. 컬렉션이 둘이면 — 조인은 아예 거부당한다
주문에 라인뿐 아니라 태그(List)도 있다고 하자. 둘 다 fetch join하면?
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags
MultipleBagFetchException. 이유는 4번과 같은 뿌리다 — List 둘을 조인하면 주문 × 라인 × 태그 카테시안 곱이 되어, Hibernate가 각 컬렉션의 원래 크기를 복원할 수 없다. 그래서 쿼리를 만드는 순간 거부한다.
해법도 같다. 조인을 포기하고 배치로 가면, 라인과 태그가 각각 IN 한 방으로 온다.
select ... from order_line where order_id in (?, ?, ?, ?, ?); -- 라인 배치
select ... from order_tag where order_id in (?, ?, ?, ?, ?); -- 태그 배치측정하면 주문(1) + 라인 IN(1) + 태그 IN(1) = 3쿼리. 예외도, 카테시안 곱도, N+1도 없다. 조인이 못 하던 “컬렉션 둘”을, 조인을 안 함으로써 해낸다.
6. 조인은 뻥튀기, 배치는 분리
두 함정은 얼굴이 달라 보이지만 뿌리가 하나다 — 컬렉션 fetch join은 행을 뻥튀기한다. 그래서 ① 페이징을 못 하고(HHH90003004), ② 둘 이상 못 한다(MultipleBagFetchException). 그리고 두 함정의 해법도 하나다 — 조인 대신 배치 페치.
9편에선 “N+1을 없애려면 fetch join”이라고 배웠다. 이번 편에서 단서가 붙었다 — 작을 땐 fetch join, 규모(페이징·다중 컬렉션)엔 배치. 같은 도구가 규모 앞에서 함정이 되고, 다른 도구가 그 자리를 넘겨받는다.
덤으로 하나 더 드러났다. 이 실험 중
findAll결과가 예상과 어긋났는데, 범인은 커밋해 버리는 다른 테스트가 남긴 데이터였다(+ORDER BY없는 조회는 순서가 비결정적). 테스트 격리라는, 아직 안 판 벽이 손을 흔들고 지나갔다.
카타는 계속된다. 날짜 경계, 할인 배분의 잔돈, 상태머신의 전이 가드, 그리고 테스트 격리 — 각각이 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.
이 시리즈는 계속 이어진다.