문제를 일부러 만들다

이전 직장에서 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