Engineering Note

[JPA] 컬렉션 Fetch Join 페이지네이션 문제와 해결책 본문

Server/JPA ORM

[JPA] 컬렉션 Fetch Join 페이지네이션 문제와 해결책

Software Engineer Kim 2025. 12. 29. 19:32

컬렉션을 Fetch Join하면 생기는 문제

일대다 연관관계를 맺는 컬렉션 엔티티를 Fetch Join하면 페이지네이션이 적용되지 않는 문제가 있다. 정확하게는 JPA는 페이지네이션을 적용하기 위해 DB의 데이터를 메모리로 가지고와서 페이지네이션을 수행하는데, 이렇게 되면 데이터가 중복돼서 페이지네이션이 정상적으로 적용되지 못할 수도 있고, 데이터가 메모리의 용량을 초과할 경우 Out Of Memory 에러가 발생할 수 있다.
 

상황

Order Entity
- Order Entity는 Member, Deliver와는 일대일관계지만 OrderItem과는 일대다
- Item은 OrderItem과 일대다 관계
 
 
주문 내역을 조회를 위해 쿼리로 offset, limit으로 0(첫 번째 페이지)에서 100개의 데이터를 가져오는 코드

public List<Order> findAllWithItem() {
  return em.createQuery(
          "select distinct o from Order o"
              + " join fetch o.member m"
              + " join fetch o.delivery d"
              + " join fetch o.orderItems oi"
              + " join fetch oi.item i",
          Order.class)
      .setFirstResult(0)
      .setMaxResults(100)
      .getResultList();
}

 

실제 수행 쿼리와 경고 로그 메세지

2025-12-29T19:11:36.532+09:00  WARN 69105 --- [nio-8080-exec-1] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
2025-12-29T19:11:36.549+09:00 DEBUG 69105 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        distinct o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artis,
        i1_0.title,
        i1_0.author,
        i1_0.isbn,
        i1_0.director,
        i1_0.etc,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id
 
=> 실제 수행된 쿼리를 보면 offset과 limit가 쿼리에 적용되지 않았다. 그리고 윗줄의 로그를 보면 출력된 로그를 보면 경고 메세지와 함께 쿼리가 적용되지 않은 이유를 알 수 있다. 
컬렉션 페치조인에서는 offset/limit에 해당하는 'firstResult/maxResults'이 적용되지 않고 메모리에서 적용된다는 내용이다.
 
 
해결 방법1
컬렉션 페치조인 페이지네이션 문제를 해결하는 방법은 단계적으로는 일대일 관계만 먼저 fetch join으로 조회하고 일대다 관계의 엔티티는 지연로딩으로 두면서 batch_size를 이용하는 방법이다.

public List<Order> findAllWithMemberDelivery(int offset, int limit) {
  return em.createQuery(
          "select o from Order o" +
              " join fetch o.member m" +
              " join fetch o.delivery d", Order.class
      ).setFirstResult(offset)
      .setMaxResults(limit).getResultList();
}

 
위에서 조회한 Order Entity에 대해 루프를 돌면서 지연로딩으로 OrderItems 등 데이터를 세팅하는 코드. (N+1발생 코드)

List<OrderDto> result = orders.stream()
    .map(o -> new OrderDto(o))
    .collect(Collectors.toList());

 
 
OrderDto 생성자

public OrderDto(Order order) {
  this.orderId = order.getId();
  this.name = order.getMember().getName();
  this.orderDate = order.getOrderDate();
  this.orderStatus = order.getStatus();
  this.address = order.getDelivery().getAddress();
  this.orderItems = order.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem))
      .collect(Collectors.toList());
}

 
application.yml

jpa:
  hibernate:
    ddl-auto: create
  properties:
    hibernate:
      #        show_sql: true
      format_sql: true
    default_batch_fetch_size: 100

 
 
해결방법2
기준이 되는 엔티티만 먼저 페이지네이션을 하고 조회된 PK를 기준으로 fetch join 하면서 where 조건절에 id값을 in 쿼리로 수행하는 방법이 있다.

@Override
public List<Order> findOrdersByEmail(String email, Pageable pageable) {
    // 1단계: ID만 페이징 (DB 페이징)
    List<Long> orderIds = queryFactory
            .select(order.id)
            .from(order)
            .where(order.member.email.eq(email))
            .orderBy(order.regTime.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    if (orderIds.isEmpty()) {
        return List.of();
    }

    // 2단계: Fetch Join (IN 쿼리)
    return queryFactory
            .selectFrom(order)
            .distinct()
            .join(order.orderItems, orderItem).fetchJoin()
            .join(orderItem.item, item).fetchJoin()
            .where(order.id.in(orderIds))
            .orderBy(order.regTime.desc())
            .fetch();
}

 
 
두 방법은 사실 같은 방법이기 때문에 취향에 맞게 사용하면 된다.
 
 
@BatchSize

  • 'N+1'로 실행되는 쿼리는 데이터베이스를 엄청나게 많이 사용하는 어노테이션.
  • size 속성을 지정해 'N'번 수행되는 쿼리를 모아서 한번에 실행할 수 있게 해준다.

 
 
최종 주문 내역 조회 코드

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
    @RequestParam(value = "offset", defaultValue = "0") int offset,
    @RequestParam(value = "limit", defaultValue = "100") int limit) {

  List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
  List<OrderDto> result = orders.stream()
      .map(o -> new OrderDto(o))
      .collect(Collectors.toList());

  return result;
}

 
 
 
 
참고자료 : 인프런 JPA 활용2편(김영한)

Comments