
JPA fetch join Pageable 조합은 목록 API에서 자주 등장합니다. N+1을 줄이고 싶어서 fetch join을 넣었는데, Pageable까지 붙이면 결과가 이상해지거나 Hibernate 경고를 만나기도 합니다.
핵심은 컬렉션 fetch join은 SQL row 수를 늘리고, Pageable은 row 기준으로 잘라야 한다는 점입니다. 객체 그래프를 한 번에 가져오려는 의도와 DB pagination의 기준이 충돌할 수 있습니다.
JPA fetch join과 Pageable 핵심 판단

JPA fetch join Pageable 문제는 언제 생길까
fetch join은 연관 엔티티를 함께 가져와 N+1 문제를 줄이는 데 유용합니다. 하지만 Order와 OrderItem처럼 one-to-many 컬렉션을 fetch join하면 한 주문이 여러 row로 펼쳐집니다.
@Query("""
select o
from Order o
join fetch o.items
where o.status = :status
""")
Page<Order> findOrders(@Param("status") OrderStatus status, Pageable pageable);이 쿼리는 객체로 보면 주문 목록을 가져오는 쿼리처럼 보입니다. 하지만 SQL 결과는 주문이 아니라 주문과 상품 row 조합입니다. 이 상태에서 limit, offset을 먼저 적용하면 사용자가 기대한 주문 단위 페이지가 아닐 수 있습니다.
to-one과 collection fetch join을 나눠 봐야 한다
모든 fetch join이 같은 위험을 갖는 것은 아닙니다. many-to-one, one-to-one처럼 결과 row 수를 크게 늘리지 않는 to-one fetch join은 목록 조회에서 비교적 다루기 쉽습니다.
반대로 one-to-many 컬렉션 fetch join은 중복 row가 생깁니다. JPA는 최종적으로 같은 식별자의 엔티티를 하나로 합칠 수 있지만, DB가 page를 자르는 시점과 애플리케이션이 객체를 합치는 시점이 다릅니다.
countQuery는 왜 따로 필요할까
Spring Data JPA의 query methods 문서는 @Query로 직접 쿼리를 선언할 수 있음을 설명합니다. Page를 반환할 때는 데이터 조회 쿼리뿐 아니라 전체 개수를 세는 count 쿼리도 중요합니다.
fetch join이 들어간 쿼리를 count에 그대로 쓰면 문법 오류나 불필요한 join이 생길 수 있습니다. 그래서 복잡한 @Query에서는 countQuery를 명시해 데이터 조회와 개수 조회를 분리하는 편이 안전합니다.
이 글은 기존 JPA 영속성 컨텍스트 글 다음에 읽으면 좋습니다. fetch join 문제도 결국 SQL 결과를 객체 그래프로 다시 해석하는 과정에서 더 잘 보입니다.
@Query(
value = """
select o
from Order o
join fetch o.member
where o.status = :status
""",
countQuery = """
select count(o)
from Order o
where o.status = :status
"""
)
Page<Order> findOrders(@Param("status") OrderStatus status, Pageable pageable);실무 대안 1: 목록은 DTO로 조회한다
목록 화면이 필요한 데이터가 제한적이라면 엔티티 그래프 전체를 가져오지 않아도 됩니다. 화면에 필요한 필드만 DTO로 조회하면 page 기준이 명확해지고, 불필요한 연관 객체 로딩도 줄어듭니다.
@Query("""
select new com.example.OrderSummary(o.id, m.name, o.createdAt)
from Order o
join o.member m
where o.status = :status
order by o.createdAt desc
""")
Page<OrderSummary> findOrderSummaries(OrderStatus status, Pageable pageable);실무 대안 2: ID page를 먼저 자른 뒤 상세를 가져온다
컬렉션까지 필요하다면 두 단계로 나누는 방법도 있습니다. 먼저 주문 ID만 page로 안정적으로 가져오고, 그 ID 목록에 대해 fetch join이나 batch loading으로 상세를 가져옵니다.
- 조건에 맞는 root entity ID를 Pageable로 조회한다.
- 조회된 ID 목록으로 연관 데이터를 한 번 더 가져온다.
- 정렬 순서가 필요하면 ID 순서를 보존해 조립한다.
정리
JPA fetch join과 Pageable은 둘 다 좋은 도구입니다. 다만 collection fetch join으로 row가 늘어나는 순간, page가 무엇을 기준으로 잘리는지 반드시 확인해야 합니다. 목록 API는 DTO 조회, countQuery 분리, 2단계 조회를 먼저 검토하는 편이 안전합니다.