[JPA] 페이지네이션과 N + 1 문제

2025. 12. 13. 12:50·DB 접근 기술/JPA

개요

대용량 트래픽 환경에서 JPA를 사용하다 보면 가장 자주 마주치는 문제 중 하나가 N + 1 문제입니다.

특히 페이지네이션이 포함된 조회 API에서는 Fetch Join 사용에 제약이 생기기 때문에, 단순한 해결책이 오히려 더 큰 문제를 만들기도 합니다. 이번 포스트에서는 페이지네이션 API 에 대해서 어떤 쿼리 최적화 전략을 사용할지에 대해 알아보겠습니다.

 

배울 수 있었던 점

Fetch Join 을 사용하기 어려운 페이지네이션 API 에서 사용할 수 있는 대안인 '쿼리를 나누어 IN 절로 묶기' 를 통해 문제를 해결할 수 있었습니다.

또한 이 방법이 만능은 아니며 IN 절의 제한이 있는 DB에서는 다른 방식으로 해결할 수 있는 방법을 확인할 수 있었습니다.

 

해당 코드에 대한 실습은 다음 레포지토리에서 진행하실 수 있습니다.

https://github.com/juno71w/jpa-optimize-pagination 

 

테이블 

테이블은 다음과 같으며 각 테이블에는 아래와 같은 더미 데이터를 집어넣고 실습을 진행하였습니다.

members: 100만 개

products 1만 개

orders: 50만 개

order_items: 150만 개 (주문당 평균 3개)

 

소규모 데이터에서는 문제가 드러나지 않기 때문에,
실제 운영 환경과 유사한 조건에서 테스트하는 것이 목적이었습니다.

 

 

문제 상황

요구사항

  • 여러 조건에 따라 주문을 조회하는 API
  • 응답에 필요한 정보
    • 주문 정보 (order)
    • 주문자(member)의 email
    • 주문에 포함된 order_item의 총 개수
  • 페이지네이션

API에서는 members 테이블의 email,order_items 의 총 갯수가 필요한 상황이였습니다. 하지만 조회한 order 에서 다른 엔티티에 접근하면 lazy loading 으로 인한 N + 1 문제가 발생합니다. 이 문제를 해결하는 유명한 방법인 Fetch Join을 생각할 수 있었습니다. 하지만 페이지네이션을 사용하는 API는 Fetch Join 을 사용할 수가 없습니다. 

Fetch Join + Pagination 을 사용하면 Fetch Join 은 row 를 기준으로, Pagination 은 엔티티를 기준으로 집계하기 때문에 DB 레벨에서 페이지네이션이 불가능 합니다. 그래서 JPA 는 메모리에서 페이징을 하는데 이는 OOM 의 발생 가능성을 높이기 때문에 사용을 자제합니다.

 

// OrderRepository
// 복합 조건 검색 (여러 인덱스 필요)
@Query("SELECT o FROM Order o " +
       "WHERE o.orderDate >= :startDate " +
       "AND o.status = :status " +
       "AND o.totalAmount >= :minAmount " +
       "ORDER BY o.orderDate DESC")
Page<Order> findOrdersByComplexCondition(
        @Param("startDate") LocalDateTime startDate,
        @Param("status") OrderStatus status,
        @Param("minAmount") int minAmount,
        Pageable pageable
);

OrderRepository 에서는 다음과 같은 쿼리를 통해 Order를 내려주고 있습니다. JPA의 페이지네이션 객체를 같이 넘겨주어 복합 조건을 페이지네이션으로 Order 객체 형태로 반환합니다.

 

 

V1

public Page<OrderResponse> searchOrdersV1(
        LocalDateTime startDate,
        OrderStatus status,
        int minAmount,
        Pageable pageable) {
    return orderRepository.findOrdersByComplexCondition(startDate, status, minAmount, pageable)
            .map(this::toOrderResponse);
}

private OrderResponse toOrderResponse(Order order) {
    return OrderResponse.builder()
            .id(order.getId())
            .orderNumber(order.getOrderNumber())
            .memberEmail(order.getMember().getEmail())
            .status(order.getStatus())
            .orderDate(order.getOrderDate())
            .totalAmount((long) order.getTotalAmount())
            .totalItems(order.getOrderItems().size())
            .build();
}

 

문제점 분석

  • order.getMember()
  • order.getOrderItems()

위 두 접근 지점에서 Lazy Loading이 발생합니다.

 

코드에서는 Member, OrderItem 에 접근하여 API 에 맞는 데이터를 조회합니다. 이는 하나의 API에서 1개의 Order가 관련된 Member 수 + Order Item 의 수 만큼 쿼리가 조회될 수 있는 N + 1 문제에 봉착하게 됩니다. 아래 사진은 V1 버전 API 를 실행했을 때 발생하는 쿼리의 수를 보여드리고자 찍은 캡처입니다. 오른쪽의 스크롤로 보아 많은 양의 쿼리가 발생했다는 것을 확인할 수 있습니다.

 

스크롤의 길이가 보이시나요?

 

 

V2

이 문제를 해결하기 위해서 member, order_item 에 바로 접근하지 않고 쿼리를 나누어 해결하기로 하였습니다. 기존 방식에선 order 엔티티 하나마다 member 한번, order_item 한번을 조회했습니다. 여기서 order를 페이지네이션으로 받은 뒤 order id 를 추출하여 members, order_items 테이블에 IN 절로 넣는 방식을 택했습니다. 

 

Hibernate: 
    select
        oi1_0.id,
        oi1_0.created_at,
        oi1_0.order_id,
        oi1_0.price,
        oi1_0.product_id,
        oi1_0.quantity,
        oi1_0.updated_at 
    from
        order_items oi1_0 
    where
        oi1_0.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

위의 로그와 같이 order_id 를 모두 가져온 뒤 order_items에 한번 members 에 한번 쿼리를 발생시키도록하겠습니다. 아래 코드를 보시죠

// 1차 개선
public Page<OrderResponse> searchOrdersV2(
        LocalDateTime startDate,
        OrderStatus status,
        int minAmount,
        Pageable pageable) {
    Page<Order> orderPage = orderRepository.findOrdersByComplexCondition(startDate, status, minAmount, pageable);

    List<Long> orderIds = orderPage.stream().map(Order::getId).toList();
    List<OrderItem> orderItems = orderItemRepository.findOrderItemsByOrderIds(orderIds);

    List<Long> memberIds = orderRepository.findMemberIdsByOrderIds(orderIds);
    List<Member> members = memberRepository.findMembersByIdIn(memberIds);

    return orderPage.map(order -> {
        int orderItemSize = 0;
        String email = "";
        for (OrderItem orderItem : orderItems) {
            if (Objects.equals(orderItem.getOrder().getId(), order.getId())) orderItemSize++;
        }
        for (Member member : members) {
            if (Objects.equals(member.getId(), order.getMember().getId())) {
                email = member.getEmail();
            }
        }

        return OrderResponse.builder()
                .id(order.getId())
                .orderNumber(order.getOrderNumber())
                .memberEmail(email)
                .status(order.getStatus())
                .orderDate(order.getOrderDate())
                .totalAmount((long) order.getTotalAmount())
                .totalItems(orderItemSize)
                .build();
    });
}

 

 

이제 V1 에 비해서 개선된 점은 다음과 같습니다.

1. N + 1 문제가 해소되었습니다. 이제 쿼리의 수를 예측할 수 있습니다. 이 메서드는 정확히 5개의 쿼리를 발생시킵니다.

2. Fetch Join 을 썼을 때보다 효과적입니다. Fetch Join 은 모든 row를 끌어와 데이터를 합칩니다. 이는 메모리 용량 초과를 일으킬 수 있는데 반해 해당 방식은 필요한 데이터만 불러들입니다.

 

단점은 다음과 같습니다.

1. 코드가 복잡합니다. V1 코드는 코드 한줄로 바로 원하는 내용을 출력할 수 있습니다. 반면 V2 는 데이터를 연결해줘야하는 복잡성이 추가되었습니다.

2. 여러 Repository 에서 데이터를 가져옵니다. OrderService의 의존성이 커지는 단점이 생겨버립니다.

 

여기서 List 대신 Map을 사용하면 어떨까요? Order 의 id를 기준으로 key를 두면 Map 자료구조의 O(1)이라는 빠른 시간 복잡도를 확보할 수 있을 것 같습니다. v3는 이를 적용한 코드입니다.

 

 

V3

v2의 가장 큰 문제는 List 반복 탐색이었습니다.

이를 해결하기 위해 Map을 사용했습니다.

// 2차 개선
public Page<OrderResponse> searchOrdersV3(
        LocalDateTime startDate,
        OrderStatus status,
        int minAmount,
        Pageable pageable) {
    // 1. 주문 목록 조회
    Page<Order> orderPage = orderRepository.findOrdersByComplexCondition(startDate, status, minAmount, pageable);

    // 2. 주문 ID 목록 추출
    List<Long> orderIds = orderPage.stream().map(Order::getId).toList();

    // 3. 관련 데이터 일괄 조회
    List<OrderItem> orderItems = orderItemRepository.findOrderItemsByOrderIds(orderIds);
    List<Long> memberIds = orderRepository.findMemberIdsByOrderIds(orderIds);
    List<Member> members = memberRepository.findMembersByIdIn(memberIds);

    Map<Long, Integer> orderItemCountMap = orderItems.stream().collect(
            Collectors.toMap(
                    orderItem -> orderItem.getOrder().getId(),
                    orderItem -> 1,
                    (oldVal, newVal) -> oldVal + 1
            )
    );

    Map<Long, Member> memberMap = members.stream().collect(
            Collectors.toMap(
                    Member::getId,
                    member -> member
            )
    );

    return orderPage.map(order -> {
        String email = memberMap.get(order.getMember().getId()).getEmail();
        int orderItemSize = orderItemCountMap.getOrDefault(order.getId(), 0);

        return OrderResponse.builder()
                .id(order.getId())
                .orderNumber(order.getOrderNumber())
                .memberEmail(email)
                .status(order.getStatus())
                .orderDate(order.getOrderDate())
                .totalAmount((long) order.getTotalAmount())
                .totalItems(orderItemSize)
                .build();
    });
}

 

V2에 대해서 개선된 점은 Map 을 통해 빠른 접근이 가능 합니다. 또한 엔티티를 찾을 때 발생하던 시간이 단축되었습니다. 하지만 여전히 코드가 복잡하다는 점은 단점입니다.

 

모니터링 해보기

이제 코드를 개선했으니 부하 테스트를 통해 실제로 얼마나 개선이 되었는지 측정해보았습니다.

사용된 도구는 k6, grafana, prometheus 등이며 k6 스크립트는 깃허브에 등록해두었습니다.

 

결과를 보시면 V1, V2, V3 에서 응답시간이 개선이 일어났다는 것을 확인할 수 있었습니다.

 

마무리

이번 포스트에서는 페이지네이션이 섞인 API에서 쿼리를 나누어 IN 절을 활용하는 방식에 대해서 알아보았습니다. 사실 이 기능은 JPA의 옵션 중 하나인 default_batch_size 옵션을 통해서 구현할 수 있습니다. lazy loading 이 발생했을 때 JPA 는 쿼리를 모아뒀다가 옵션 만큼 쿼리가 차면 IN 절에 담아 한번에 쿼리를 발생시킵니다. 하지만 찾는 데이터에 비해 사이즈가 커질 수록 비효율이 늘어나는 문제도 있는데 이 점에 유의해서 사용해야 할 듯합니다. 

'DB 접근 기술 > JPA' 카테고리의 다른 글

[JPA] 집계 테이블로 통계 조회를 최적화하기  (0) 2025.12.21
[JPA JDBC] PK 생성 전략 별로 사용해야할 bulk insert 를 알아보자  (3) 2025.12.19
'DB 접근 기술/JPA' 카테고리의 다른 글
  • [JPA] 집계 테이블로 통계 조회를 최적화하기
  • [JPA JDBC] PK 생성 전략 별로 사용해야할 bulk insert 를 알아보자
_Juno
_Juno
juno 님의 블로그 입니다.
  • _Juno
    안녕, 세상아
    _Juno
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Redis
      • Database
        • MySQL
      • DB 접근 기술
        • JPA
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    힙덤프
    OOM
    JPA
    Heap
    in
    eclipse memory analyzer
    튜토리얼
    집계테이블
    page
    Java
    storage engine
    Stream
    spring
    mysql
    OutOfMemory
    dump
    innodb
    B+Tree
    index
    쿼리
    pagination
    실습
    mat
    Spring Data JPA
    n + 1 문제
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
_Juno
[JPA] 페이지네이션과 N + 1 문제
상단으로

티스토리툴바