음악 스트리밍 파이프라인 만들기 - 07. N+1, 쿼리 수만 세면 fetch join에 속는다
문제를 일부러 만들다
이전 직장에서 N+1 로 60~90초 걸리던 조회를 개선한 경험이 있는데, MyBatis 환경이었다. JPA 환경에서 같은 문제를 재현하고 개선 전략을 수치로 비교하고 싶었다.
앨범 목록 API(앨범 + 대표 아티스트명 + 트랙 목록)를 만들었다. Album→Artist(ToOne),
Album→Tracks(OneToMany) 모두 LAZY. 리포지토리는 findAll(Pageable) 하나 —
코드 어디에도 반복 조회가 없다. 그런데:
앨범 30개 조회 = 쿼리 61개 이상 (목록 1 + artist 30 + tracks 30)쿼리는 리포지토리가 아니라 DTO 변환 중(지연 로딩 접근)에 나간다. 이게 N+1 이 늦게
발견되는 이유다. MyBatis 는 resultMap 의 collection 쿼리가 XML 에 보이는데, JPA 는
getMainArtist().getName() 한 줄이 조용히 SELECT 를 만든다. 그리고 응답은 완전히
정확하다 — N+1 은 틀린 결과가 아니라 느린 결과라서 기능 테스트로는 절대 안 잡힌다.
측정은 Hibernate Statistics(PrepareStatementCount)로 했다. “느린 것 같다”가 아니라
쿼리 수를 직접 센다.
3가지 개선 전략 측정
앨범 200 / 트랙 2,000 / 페이지 50, 3회 중앙값. 지표를 두 개 봤다 — 쿼리 수와 함께 Album 엔티티 로드 수(EntityLoadCount).
| 전략 | 쿼리 수 | Album 로드 수 | 중앙값 |
|---|---|---|---|
| NAIVE (LAZY 순회) | 102 | 50 | 110ms |
| fetch join | 2 | 200 | 33ms |
@EntityGraph |
2 | 200 | 22ms |
default_batch_fetch_size=100 |
4 | 50 | - |
반전 — fetch join 의 숨은 비용
쿼리 수만 보면 fetch join 완승(102→2)이다. 그런데 로드 수가 이상하다. 페이지는 50개를 요청했는데 전체 200개를 로드했다.
컬렉션 fetch join 에 페이징을 걸면 Hibernate 는 LIMIT 을 SQL 로 못 내린다 —
한 앨범이 트랙 수만큼 여러 행으로 뻥튀기된 결과에서 50행을 자르면 앨범 50개가
아니기 때문이다. 대신 전체를 메모리에 올려놓고 자른다 (로그의 HHH90003004 경고).
앨범이 200개라 33ms 지, 200만 개면 OOM 이다. @EntityGraph 도 SQL 형태가 같아
함정을 그대로 공유한다 — 차이는 선언 방식이지 성능이 아니다.
페이징을 DB 에 남기는 컬렉션 해법은 batch fetch 뿐이었다 (쿼리 4개, 로드 50개).
네 번째 전략은 코드가 아니라 설정이다
default_batch_fetch_size 는 SessionFactory 설정이라 쿼리 단위로 켤 수 없다.
그래서 벤치마크도 별도 테스트 컨텍스트가 필요했는데, 그 클래스에서 실행되는
서비스 코드는 NAIVE 벤치마크와 한 글자도 다르지 않다. 같은 코드가 설정 한 줄로
102쿼리 → 4쿼리가 된다. 동작 원리는 LAZY 프록시 초기화 시 영속성 컨텍스트의 같은 타입
미초기화 프록시를 batch_size 개까지 모아 IN 쿼리 하나로 로딩하는 것.
정리
- ToOne 만 필요하면 fetch join, 컬렉션 + 페이징이면 batch fetch 가 사실상 유일해
- 지표 하나(쿼리 수)만 보면 결론이 뒤집힌다 — EntityLoadCount 를 같이 볼 것
open-in-view: false전제라 DTO 변환은@Transactional(readOnly=true)안에서. OSIV 로 미루면 편하지만 트랜잭션 밖 커넥션 점유를 편의로 포장하는 것이다
벤치마크 코드: AlbumFetchStrategyBenchmarkTest