[TS] JPA/Hibernate Pagination with fetch join

Row기반 페이징 방식과, ResultSet 기반 페이징 방식의 차이
정찬's avatar
Jun 26, 2025
[TS] JPA/Hibernate Pagination with fetch join
 

❌ 문제 상황

 
SpringBatch 실습 중 JpaPagingItemReader 를 사용하여 아이템들을 페이지네이션하여 청크단위로 끊어서 처리해보는 실습을 해보고 있던 중이었다.
Order 테이블을 읽어, 사용자별 월간 주문 집계를 집계하기 위한 ItemReader를 작성하였고, 아래와 같이 N+1 문제를 고려해 한방 쿼리로 조회를 하려고 시도하였다.
 
@Bean @StepScope public JpaPagingItemReader<Orders> ordersJpaPagingItemReader() { return new JpaPagingItemReaderBuilder<Orders>() .name("ordersJpaPagingItemReader") .entityManagerFactory(emf) .queryString(""" SELECT o FROM Orders o LEFT JOIN FETCH o.user LEFT JOIN FETCH o.orderItems WHERE o.orderAt BETWEEN :startDate AND :endDate """) .parameterValues(Map.of( "startDate", LocalDateTime.of(2025, 5, 1, 0, 0), "endDate", LocalDateTime.of(2025,5,31,23,59,59) )) .pageSize(CHUNK_SIZE) .saveState(false) .build(); }
 
하지만 아래와 같은 경고가 발생했다.
 
notion image
 
firstResult/maxResults specified with collection fetch; applying in memory
 
 

💡 어떤 이슈가 있을까?

 
배치 결과를 보았을 때, 페이지네이션이 문제 없이 작동하는 것을 확인했다.
작동에 문제가 없지만, 아래 로그를 보면 성능상의 이슈가 발생할 것이라 짐작할 수 있다.
 

Query 로그를 보면..

Hibernate: select o1_0.id,o1_0.order_at,oi1_0.order_id,oi1_0.id,oi1_0.price,oi1_0.quantity,u1_0.id,u1_0.grade,u1_0.name from orders o1_0 left join user u1_0 on u1_0.id=o1_0.user_id left join order_item oi1_0 on o1_0.id=oi1_0.order_id where o1_0.order_at between ? and ?
 
페이지네이션에 필요한 limit, offset 조건이 사용되지 않고 모든 데이터들을 조회하고 있다.
경고 문구에서 짐작할 수 있듯 모든 데이터들을 일괄 조회하고, 메모리에 저장하여 사용한다는 성능적인 이슈가 발생한다.
메모리에 모든 데이터를 적재하고, 다시 페이징을 처리하니 기능적으론 이슈가 없지만, 성능적으로 큰 이슈가 발생하게 되는 것이다. (대량의 데이터를 처리하는 배치 환경에서는 더 큰 이슈가 된다)
 

❗문제의 원인

 
그렇다면 JPA Pagination과 Fetch Join을 함께 사용했을 때, 이러한 이슈가 발생하는 원인은 무엇일까?
 

쿼리를 생각해 보자

 
우리가 예상했던 쿼리는 위 쿼리 로그에 limit, offset이 추가된(페이지네이션) 로그이다.
하지만 One 에서 Many 관계에 있는 컬렉션들을 함께 조인할 때, One의 결과가 Collection의 갯수만큼 반환된다. 만약 orderId = 1 인 order가 5개의 orderItem들을 갖고 있을 때, orderId=1인 5개의 order가 반환되는 것이다.
 
정리하자면, order 기준으로, 컬렉션의 수 만큼 db row가 중복되게 되고, 이에 따라 페이징이 왜곡되게 된다.
그래서 Hibernate는 위에서 설명한 것처럼, 페이징 없이 일괄 조회한 뒤, 메모리 내에서 추가적인 페이징을 처리하게 되고, 이는 성능적인 이슈로 이어지게 되는 것이다.
 

✅ 해결 방법

 
in-memory pagination 없이 Collection을 함께 조회하는 방법은 크게 2가지가 있다.
 

1. Collection을 별도로 조회

 
One-To-Many 관계에서 페이징의 대상이 되는 One의 엔티티만 먼저 조회하고, Many는 추가 쿼리로 불러오는 방법이다.
 

이 방법도 N+1 이슈가 있는데?

 
맞다. 정확히 말하자면, 이 방법은 N+1 문제를 줄이는 방법이다. N+1을 줄이는 방법으론 BatchSize를 설정하면 된다.
 
BatchSize란?
@BatchSize는 지연 로딩(Lazy Fetch) 전략에서 연관된 엔티티들을 일정한 크기(batch size) 로 한 번에 로딩(in 쿼리로) 하도록 하여 N+1 문제를 완화하는 기법이다.
 
BatchSize를 지정하는 방법
 
  1. 선언적 방법
 
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY) @BatchSize(size = 100) private List<OrderItem> orderItems;
 
Order에서 OneToMany 관계인 orderItems 컬렉션들을 100개씩 가져오는 예시이다.
 
  1. hibernate 환경 설정
 
# in application.properties spring.jpa.properties.hibernate.default_batch_fetch_size=100
 
default_batch_size는 전역 batch size 설정이다.
1,2 번 모두 설정했다면 Spring의 기본 원칙에 따라 구체화된 1번부터 적용된다.
 

2. Subquery (ID 기준으로 페이징)

 
ID만 페이징한 후, fetch join을 사용해 상세 조회하는 방법이다.
 
예를들어 문제가 발생했던 환경에서 orderid를 기준으로 페이징을 하고, 이후에 order와 관련된 컬렉션들을 fetch join하는 방법으로 해결할 수 있다.
 

더 궁금한점

 
전에 커서 기반 리더 (JpaCursorItemReader)를 사용해 똑같은 조회 쿼리를 날려봤는데 문제없었다.
 
@Bean public JpaCursorItemReader<Orders> orderJpaCursorItemReader() { return new JpaCursorItemReaderBuilder<Orders>() .name("orderJpaCursorItemReader") .entityManagerFactory(emf) .queryString(""" SELECT o FROM Orders o LEFT JOIN FETCH o.user LEFT JOIN FETCH o.orderItems WHERE o.orderAt BETWEEN :startDate AND :endDate """) .parameterValues(Map.of( "startDate", LocalDateTime.of(2025, 5, 1, 0, 0), "endDate", LocalDateTime.of(2025,5,31,23,59,59) )) .build(); }
 
위 코드로 조회했을 때, fetch join과 페이징(청크 단위 끊어 읽기)이 문제 없이 작동했었다.
 
병렬, 멀티 스레드 환경의 배치로 확장해보려고, 스레드 안전한 JpaPagingItemReader 로 변경하기만 했는데 이런 문제가 발생한 것이다.
왜 이런 차이가 났는지 궁금해서 알아보았다.
 

JpaCursorItemReader vs JpaPagingItemReader

 
  • 페이징 방식
    • JpaCursorItemReader: JDBC Cursor (ResultSet 기반)
      JpaPagingItemReader: JPA setFirstResult, setMaxResults
  • 페이징 주체
    • JpaCursorItemReader: DB가 직접 커서로 순회
      JpaPagingItemReader: JPA/Hibernate가 쿼리 레벨에서 페이지 계산
       
아까 JpaPagingItemReader에서 fetch join 시 row수가 달라져 페이징에 문제가 발생한다고 했었다.
이는 JpaPagingItemReader가 row 수 기준 페이징 방식(firstResult, maxResult 기반)이라 발생하는 문제이다.
 
반대로 JpaCursorItemReader 는 ResultSet기반의 방식으로 쿼리를 실행한 후, DB Cursor 가 순차적으로 데이터를 읽는 방식이다.
잘 생각해보면.. 한번에 읽어들이는 방법이 아니기 때문에, 쿼리를 보내는 시점에 어디까지 읽을지 특정하지 않아도 된다. 그렇기에 limit, offset 쿼리가 필요없고, fetch join에 적합한 것 같다.
 

정리하자면..

 
JpaPagingItemReaders 는 row 수 기준 페이징 방식(firstResult, maxResult 기반)이다. 한번에 페이지 단위의 모든 데이터를 메모리에 올린다. 그렇기에 쿼리를 보내는 시점에 어떤 row까지 읽어야 하는지 특정을 지어야 하기 때문에 Fetch join이 불가능하다. 왜냐하면 Fetch join을 하면 row의 수가 달라지기 때문에!
Share article

lushlife99