--- id: 5e4d033c-d6fe-4257-a4dc-1ade44473c72 kind: PROJECT_DECISION slug: no-collection-fetch-join-with-pagination title: Collection Fetch Join과 Pagination을 같이 사용하지 않는다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source: - final/document.md#10-5 - final/document.md#9-6 --- # Collection Fetch Join과 Pagination을 같이 사용하지 않는다 컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 반환 목록만 한 페이지일 뿐 메모리에 올라오는 부모 엔티티는 데이터셋 전체다. ## 근거 - **Collection Fetch Join Pagination의 In-memory Paging** 발행 SQL에 Limit 노드가 없다는 것과 부모 엔티티 로드 수를 이 기록에서 함께 쟀다. - **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증** 부모 100행짜리 조인이 실제로는 1,961행을 실어 나른다는 것을 확인한 기록이다. - **Fetch Join · Batch · Projection 선택 기준** 컬렉션 fetch join을 버린 뒤 배치와 프로젝션 중 무엇을 쓸지 정한 기록이다. ## 결정문 컬렉션을 fetch join하는 쿼리에 firstResult나 maxResults를 적용하지 않는다. 페이징이 필요한 목록 조회에서는 엔티티만 페이징해 DB LIMIT이 정상 발행되게 하고, 지연 연관은 배치나 별도 쿼리로 채운다. ## 판단 이유 컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 페이지 크기 20으로 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 자식이 누락될 수 있다. Hibernate는 이 누락을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 페이지를 자른다. seed(100)에서 실행계획을 떠 보니 이 쿼리에는 Limit 노드가 없었고, 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환했다. N=1,000에서 반환 목록은 페이지 크기인 20에 머물렀지만 메모리에 올라온 부모 엔티티는 1,000개, 반환의 50.0배였다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 10으로 같았고 문제가 보이지 않았다. 엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 정렬 방식이 quicksort에서 top-N heapsort로 바뀌고, 상위 20행만 가져온다. 문제의 쿼리는 FeedQueryAdapter가 아니라 통합 테스트 안의 원시 JPQL로만 실행했고, 늘어난 코드는 그 테스트의 측정 메서드뿐이다. ## 영향 - 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식은 쿼리를 따로 내서 채워야 한다. - 엔티티만 페이징하면 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아오므로, 페이지 부모 키를 모아 한 번에 조회하는 배치나 프로젝션을 함께 적용해야 한다. - hibernate.query.fail_on_pagination_over_collection_fetch를 true로 바꾸면 같은 쿼리가 곧바로 실패한다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 쓸 수 있다. - 이 랩의 Hibernate ORM 7.1.8은 널리 알려진 HHH000104가 아니라 HHH90003004를 남겼다. 그래서 회귀 가드는 경고 코드 번호만 비교하지 않고 collection fetch라는 문구도 함께 확인한다. - 작은 데이터셋으로만 검증하면 이 문제를 놓치므로, 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 본다.