내일배움캠프

[내일배움캠프 사전캠프] 페이지네이션과 N+1 문제

munsik22 2026. 4. 2. 16:04
 

스파르타클럽 | AI시대, 미래를 돌파하는 힘

누구나 잠재력을 깨워 나아가도록. IT 커리어의 모든 성장 과정을 스파르타클럽에서

academia.spartaclub.kr

🧩 페이징 및 정렬

Key Parameters

  • 클라이언트 → 서버
페이징 정렬
  • page: 조회할 페이지 번호
  • size: 한 페이지에 보여줄 아이템 개수
  • sortBy: 정렬 기준
  • isAsc: true (오름차순) / false (내림차순)
  • 서버 → 클라이언트
    • number: 조회된 페이지 번호
    • content: 조회된 아이템 정보 (배열)
    • size: 한 페이지에 보여줄 아이템 개수
    • numberOfElements: 실제 조회된 아이템 개수
    • totalElements: 전체 아이템 개수
    • totalPages: 전체 페이지 수
    • first / last: 첫번째/마지막 페이지 여부 (boolean)
totalPages = totalElement / size 결과를 소수점 올림
1 / 10 = 0.1 => 총 1 페이지
9 / 10 = 0.9 => 총 1페이지
10 / 10 = 1 => 총 1페이지
11 / 10 => 1.1 => 총 2페이지

Pageable

Spring Data JPA에서 페이징 및 정렬 기능을 제공하기 때문에 페이징 및 정렬을 쉽게 구현할 수 있다.

Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);

Page<Product> products = productRepository.findAllByUser(user, pageable);
  • Pageable: 페이징 및 정렬 처리를 위해 제공되는 인터페이스
  • PageRequest: Pageable 인터페이스의 구현체
  • 파라미터로 현재 페이지, 데이터 노출 개수, 정렬 방법(오름차순/내림차순)을 전달함
  • 생성된 Pageable 구현 객체를 Spring Data JPA의 Query Method 파라미터에 함께 전달하면 페이징 및 정렬 처리가 완료된 데이터를 Page 타입으로 반환함
  • Page 타입에는 클라이언트에 전달해야 할 데이터인 totalPages, totalElements 등이 포함됨

🧰 연관관계 수정

어제 구현한 JPA 연관관계와 관련해서 중대한 이슈를 발견해서, 페이지네이션 구현에 앞서 먼저 수정부터 시작해야 한다😨

UNIQUE 제약조건

상품이 null인 주문에 대해 다른 상품으로 변경해주기 위해 update()를 구현했다. 물론 실제 서비스였다면 이미 결제된 주문을 수정하는 일은 있을 수 없으므로 그냥 개발용으로 보면 되겠다.

    /* 주문 수정 */
    @PatchMapping ("/{id}")
    public Order update(@PathVariable Long id, @RequestBody OrderRequest orderRequest) {
        Order order = orderRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        Product product = productRepository.findById(orderRequest.getProductId())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        order.setProduct(product);
        return orderRepository.save(order);
    }

그런데 여기서 404도 아니고 뜬금 없이 500 에러가 발생했다.

[spring-order] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.DataIntegrityViolationException: could not execute statement [Unique index or primary key violation: "PUBLIC.UQ_ORDERS_PRODUCT_ID INDEX PUBLIC.UQ_ORDERS_PRODUCT_ID_INDEX_8 ON PUBLIC.ORDERS(PRODUCT_ID NULLS FIRST) VALUES ( /* 2 */ CAST(1 AS BIGINT) )"; SQL statement: update orders set product_id=? where id=? [23505-240]] [update orders set product_id=? where id=?]; SQL [update orders set product_id=? where id=?]; constraint [PUBLIC.UQ_ORDERS_PRODUCT_ID INDEX PUBLIC.UQ_ORDERS_PRODUCT_ID_INDEX_8]] with root cause

위 로그에서 핵심은 이 부분이다: constraint [PUBLIC.UQ_ORDERS_PRODUCT_ID ...] 즉, 현재 테이블 구조는 orders.product_id 값이 주문마다 중복되면 안 되도록 설계가 되어 있다. 그런데 이미 다른 주문이 쓰고 있는 product_id로 바꾸려고 해서 23505(중복키 위반) 에러가 발생한 것이다.

 

분명 코드에서 unique = true와 같은 설정을 하지 않았기 때문에 대체 어디서 UNIQUE 제약조건이 발생했는지 찾아봤는데, 어제 상품-주문 관계를 @OneToOne으로 설정했던 것이 문제였다는 것을 알게 되었다.

@OneToOne
@JoinColumn(name = "product_id")
private Product product;

명시적으로 UNIQUE를 설정하지 않았음에도, @OneToOne으로 설정하면 JPA가 자동으로 상품 하나가 주문 하나에만 연결된다고 판단한다고 한다.

  • 테이블 생성 SQL: 어제 코드를 다시 보니 테이블 생성 SQL문에서도 product_id에 UNIQUE 제약 조건이 걸려 있었다. 어제 대체 뭔 생각이었던 걸까...
DROP TABLE orders;
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT,
    CONSTRAINT fk_orders_product
        FOREIGN KEY (product_id) REFERENCES products(id)
);
  • Order 엔티티 수정: @OneToOne에서 @ManyToOne으로 수정했다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;

💻 페이지네이션 구현

 

feat: 주문 목록 조회 구현 · qkrwns1478/spring-order@76787d4

@@ -12,4 +12,8 @@ public OrderResponse(Long id, String name) {

github.com

  • DB 초기화 SQL문: 데이터 삭제 및 ID 1로 초기화 (TRUNCATE TABLE 테이블이름 RESTART IDENTITY; 로 하면 된다는 말도 있지만 잘 적용되지 않았다.)
DELETE FROM PRODUCTS;
ALTER TABLE PRODUCTS ALTER COLUMN ID RESTART WITH 1;
DELETE FROM ORDERS;
ALTER TABLE ORDERS ALTER COLUMN ID RESTART WITH 1;
  • TestDataRunner: 최초 실행 후 DB에 데이터가 생성되었다면 코드 전체를 주석 처리하면 된다.
package com.example.springorder.util;

import com.example.springorder.Order.Order;
import com.example.springorder.Order.OrderRepository;
import com.example.springorder.Product.Product;
import com.example.springorder.Product.ProductRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

@Component
public class TestDataRunner implements ApplicationRunner {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public TestDataRunner(OrderRepository orderRepository, ProductRepository productRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        String[] products = {"신발", "과자", "키보드", "휴지", "휴대폰", "앨범",
                             "헤드폰", "이어폰", "노트북", "무선 이어폰", "모니터"};
        for (String product : products) {
            createProduct(product);
        }
        for (int i = 0; i < products.length; i++) {
            createOrder(i+1);
        }
    }

    private void createProduct(String name) {
        Product product = new Product();
        product.setName(name);
        int price = getRandomNumber(100, 10000);
        int quantity = getRandomNumber(100, 10000);
        product.setPrice(price);
        product.setQuantity(quantity);
        productRepository.save(product);
    }

    private void createOrder(long productId) {
        Order order = new Order();
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        order.setProduct(product);
        orderRepository.save(order);
    }

    public int getRandomNumber(int min, int max) {
        return (int) Math.floor(Math.random() * (max - min + 1)) + min;
    }
}
  • OrderPageRequest: 페이지네이션 요청 DTO를 새로 만들었다. 만약 상품 조회에도 페이지네이션을 적용한다면 별도의 패키지에 리팩토링하는 것을 고려할 수도 있겠다.
package com.example.springorder.Order;

import lombok.Getter;
import lombok.Setter;

@Setter @Getter
public class OrderPageRequest {
    private int page;
    private int size;
    private String sortBy;
    private boolean isAsc;
}
  • OrderController: 주문 전체 조회 수정
    /* 주문 전체 조회 */
    @GetMapping
    public Page<Order> getOrders(@RequestBody OrderPageRequest orderPageRequest) {
        int page = orderPageRequest.getPage();
        int size =  orderPageRequest.getSize();
        String sortBy = orderPageRequest.getSortBy();
        boolean isAsc = orderPageRequest.isAsc();

        Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
        Sort sort = Sort.by(direction, sortBy);
        Pageable pageable = PageRequest.of(page, size, sort);

        return orderRepository.findAll(pageable);
    }

응답이 오기는 왔지만 의도했던 형식은 아니다.

  • OrderResponse: from 메서드 추가
public static OrderResponse from(Order order) {
    return new OrderResponse(order.getId(), order.getProduct().getName());
}
  • OrderController: getOrders() 수정
    /* 주문 전체 조회 */
    @GetMapping
    public List<OrderResponse> getOrders(@RequestBody OrderPageRequest orderPageRequest) {
        int pageNo = orderPageRequest.getPage();
        int size =  orderPageRequest.getSize();
        String sortBy = orderPageRequest.getSortBy();
        boolean isAsc = orderPageRequest.isAsc();

        Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
        Sort sort = Sort.by(direction, sortBy);
        Pageable pageable = PageRequest.of(pageNo, size, sort);

        Page<OrderResponse> page = orderRepository.findAll(pageable).map(OrderResponse::from);
        return page.getContent();
    }

의도대로 상품 이름만 제대로 출력되는 것을 볼 수 있다.

🧩 N+1 문제

N+1 문제란 JPA에서 연관 관계가 설정된 엔티티를 조회할 때, 1번의 쿼리로 N개의 데이터를 가져온 후 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 성능 저하 문제를 말한다.

개요

N+1 문제는 처음에 데이터를 1번 조회한 후 연관된 데이터를 가져오려고 추가 쿼리가 N번 더 실행되는 현상이다. 예를 들어 Order가 여러 개 있고, 각 OrderProduct를 참조한다고 하자.

List<Order> orders = orderRepository.findAll();

이 코드로 주문 목록을 가져 오면 처음에 주문을 조회하는 쿼리가 1번 나간다.

for (Order order : orders) {
    System.out.println(order.getProduct().getName());
}

그런데 이후에 반복문에서 이렇게 연관 객체에 접근하면 주문이 10개일 때 product를 하나씩 따로 조회해서 상품 조회 쿼리 10번이 추가로 실행될 수 있다.

 

즉, 주문 조회 1번 + 상품 조회 N번 이렇게 되어 총 N+1번의 쿼리가 나가므로 N+1 문제라고 한다. 이는 JPA가 연관 객체를 바로 한 번에 가져오지 않고 필요한 시점에서 각각 따로 조회하기 때문이다. 특히 지연 로딩(LAZY)일 때 자주 보이지만, 즉시 로딩(EAGER)이라고 해서 자동으로 해결되는 것은 아니다.

해결 방법

  • fetch join 사용: 주문과 상품을 한 번의 쿼리로 가져올 수 있다.
@Query("select o from Order o join fetch o.product")
List<Order> findAllWithProduct();
  • EntityGraph 사용: 연관 객체를 함께 조회하도록 지정할 수 있다.
@EntityGraph(attributePaths = "product")
List<Order> findAll();
  • 배치 크기 설정: 연관 객체를 여러 개씩 묶어서 조회하게 함으로써 쿼리 수를 줄일 수 있다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100

N+1 문제 확인하기

방금 구현한 주문 전체 조회 GET /orders에서 N+1 문제가 발생한다.

  • Orderproduct@ManyToOne(fetch = FetchType.LAZY)라서 주문을 읽을 때 상품을 같이 들고 오지 않는다.
    1. getOrders()orderRepository.findAll(pageable)로 주문 목록을 가져온다.
    2. 바로 다음에 .map(OrderResponse::from)으로 주문마다 DTO 변환을 한다.
    3. 그런데 OrderResponse.from() 안에서 order.getProduct().getName()을 호출한다.
    4. 이 때 각 주문의 product에 접근하면서 상품 조회 쿼리가 주문 수 만큼 추가로 나갈 수 있다.
  • 주문 목록 조회 1번 + 각 주문의 상품 조회 N번 발생
@Setter @Getter
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    public Order() {}

    @Builder
    public Order(Product product) {
        this.product = product;
    }
}
    @GetMapping
    public List<OrderResponse> getOrders(@RequestBody PageRequest pageRequest) {
        int pageNo = pageRequest.getPage();
        int size =  pageRequest.getSize();
        String sortBy = pageRequest.getSortBy();
        boolean isAsc = pageRequest.isAsc();

        Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
        Sort sort = Sort.by(direction, sortBy);
        Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNo, size, sort);

        Page<OrderResponse> page = orderRepository.findAll(pageable).map(OrderResponse::from);
        return page.getContent();
    }
package com.example.springorder.Order;

import lombok.Getter;
import lombok.Setter;

@Setter @Getter
public class OrderResponse {
    private Long id;
    private String productName;

    public OrderResponse(Long id, String name) {
        this.id = id;
        this.productName = name;
    }

    public static OrderResponse from(Order order) {
        return new OrderResponse(order.getId(), order.getProduct().getName());
    }
}

 

진짜 N+1 문제가 발생하는지 로그를 찍어서 확인해 보았다.

  • application.properties: SQL 로그를 보기 위한 설정 추가
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
  • GET /orders 실행
    더보기
    Hibernate: 
        select
            o1_0.id,
            o1_0.product_id 
        from
            orders o1_0 
        order by
            o1_0.id desc 
        offset
            ? rows 
        fetch
            first ? rows only
    Hibernate: 
        select
            count(o1_0.id) 
        from
            orders o1_0
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?
    Hibernate: 
        select
            p1_0.id,
            p1_0.name,
            p1_0.price,
            p1_0.quantity 
        from
            products p1_0 
        where
            p1_0.id=?

위 로그를 보면 다음과 같은 흐름으로 나타난다.

  • 주문 목록 조회 1번: 아래 쿼리로 주문 10개를 먼저 가져왔다. (offset=0, limit=10)
select
    o1_0.id,
    o1_0.product_id
from
    orders o1_0
order by
    o1_0.id desc
offset ? rows fetch first ? rows only
  • count 조회 1번: N+1 문제와는 별개로 페이징 때문에 붙는 정상적인 count 쿼리이므로 여기서는 무시해도 된다.
select count(o1_0.id) from orders o1_0
  • 상품 조회 10번: 이 형태가 id=11, 10, ..., 3, 2로 계속 반복된다. 주문 10개를 가져온 뒤 각 주문의 product를 하나씩 따로 조회했다.
select ... from products p1_0 where p1_0.id=?

N+1 문제 해결하기

실제로 N+1 문제가 발생한 것을 확인했으니 N+1 문제를 해결해보자.

  • OrderRepository: @EntityGraph 사용findAll(pageable) 호출 시 product도 같이 조회하려고 시도한다.
package com.example.springorder.Order;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order,Long> {
    @EntityGraph(attributePaths = "product")
    Page<Order> findAll(Pageable pageable);
}
  • GET /orders 실행
Hibernate: 
    select
        o1_0.id,
        p1_0.id,
        p1_0.name,
        p1_0.price,
        p1_0.quantity 
    from
        orders o1_0 
    left join
        products p1_0 
            on p1_0.id=o1_0.product_id 
    order by
        o1_0.id desc 
    offset
        ? rows 
    fetch
        first ? rows only
Hibernate: 
    select
        count(o1_0.id) 
    from
        orders o1_0

 

SQL 로그가 이전보다 확연히 줄어든 것을 볼 수 있다. 주문 10개를 읽어도 더 이상 select ... from products where id=?가 10번 반복되지 않으니 N+1 문제가 해결되었음을 확인할 수 있다.

💭 느낀 점

오늘 작업이 생각보다 오래 걸렸는데, 페이지네이션 때문이 아니라 어제 작업했던 연관관계 매핑이 개판이었어서 그것을 수정하느라 그랬다😓 만약 이게 실제 실무 코드였고 퇴근 후에 이런 문제가 터졌더라면 정말 끔찍한 시간을 보냈을 것이다. 테스트 코드 작성 및 피어 코드 리뷰의 소중함을 몸소 느낀 하루였다.

📋 내일 할 일

  • 상품 재고 차감 구현
  • 원자성, 영속성 관련 강의 수강