--- 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 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit" decisionStatus: PROPOSED --- # Collection Fetch Join과 Pagination을 같이 사용하지 않는다 컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 응답은 한 페이지지만 비용은 데이터셋 전체에 비례한다. ## 근거 - **Collection Fetch Join Pagination의 In-memory Paging** 이 동작을 실행계획과 로드 수로 확인한 기록이다. - **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증** 컬렉션 fetch join이 행을 곱하는 것을 확인한 기록이다. - **Fetch Join · Batch · Projection 선택 기준** 대신 무엇을 쓸지 정한 기준이다. ## 결정문 컬렉션을 fetch join하는 쿼리에 firstResult나 maxResults를 적용하지 않는다. 페이징이 필요한 목록 조회에서는 엔티티만 페이징해 DB LIMIT이 정상 발행되게 하고, 지연 연관은 배치나 별도 쿼리로 채운다. ## 판단 이유 컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다. Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 자른다. 발행된 SQL에 Limit 노드가 없는 것이 이 동작의 증거다. 측정에서 반환 목록은 페이지 크기로 고정됐지만 로드한 부모 엔티티는 데이터셋 전체였다. 초과 적재 배수는 데이터가 커질수록 늘었다. 작은 데이터셋에서는 두 값이 같아 문제가 드러나지 않는다. 엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 상위 몇 행만 취하는 정렬로 바뀐다. 전체 정렬과 상위 몇 행 정렬의 차이가 계획 수준에서 나타난다. ## 영향 - 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식 조회를 위한 쿼리가 따로 필요하다. - 엔티티만 페이징하면 지연 연관의 N+1이 돌아온다. 배치나 프로젝션을 함께 적용해야 한다. - 이 실수를 조기에 발견하려면 컬렉션 fetch join에 페이징이 걸릴 때 실패시키는 설정을 켤 수 있다. 근본 해결은 아니지만 안전장치가 된다. - 회귀 가드는 경고 코드 번호만 비교하지 않는다. 버전에 따라 코드가 달라질 수 있어 문구도 함께 확인한다. - 작은 데이터셋으로만 검증하면 이 문제를 놓친다. 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 봐야 한다.