Spring Boot의 Pageable을 사용한 페이지 나누기와 정렬

1. 개요

orders 테이블에서 데이터 100개를 모두 반환하는 API는 잘 작동할 수 있습니다. 하지만 데이터가 100만 개로 증가하면 그 API는 애플리케이션을 마비시킵니다. Spring Data는 Pageable이라는 객체로 이 문제를 해결합니다. Pageable은 “어느 페이지를 가져올 것인지, 몇 개의 요소인지, 어떤 기준으로 정렬할 것인지”를 나타내는 객체입니다.

이 글을 읽은 후에 다음을 알게 됩니다:

  • 페이지 나누기 기능이 있는 repository 메서드를 선언하는 방법;
  • Page와 Slice 중 어느 것을 선택할지;
  • HTTP 요청에서 직접 page, size, sort 파라미터를 받는 방법;
  • 성능과 보안에 관련된 몇 가지 함정을 피하는 방법.

2. 준비

Spring Data JPA, Spring Web, 그리고 데이터베이스가 필요합니다. 이 글에서는 간단하게 H2를 사용합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

예제로 사용할 Entity는 주문입니다:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerName;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private LocalDateTime createdAt;

    protected Order() {
    }

    public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
        this.customerName = customerName;
        this.total = total;
        this.status = status;
        this.createdAt = createdAt;
    }

    // getters
}

public enum OrderStatus { NEW, PAID, SHIPPED }

테이블 이름을 orders로 지정하는 이유는 ORDER가 SQL 키워드이기 때문입니다. 기본값을 사용하면 많은 데이터베이스에서 구문 오류를 보고합니다.

3. Repository 계층에서의 페이지 나누기

3.1. Pageable과 PageRequest

JpaRepository는 이미 PagingAndSortingRepository를 상속받았으므로, findAll(Pageable) 메서드를 즉시 사용할 수 있습니다. Pageable을 생성하려면 PageRequest.of를 사용합니다:

Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);

System.out.println("페이지: " + page.getNumber());
System.out.println("이 페이지의 요소 수: " + page.getNumberOfElements());
System.out.println("전체 요소 수: " + page.getTotalElements());
System.out.println("전체 페이지 수: " + page.getTotalPages());
System.out.println("다음 페이지가 있나? " + page.hasNext());

각 파라미터의 의미:

  • 0은 페이지 인덱스입니다. Spring Data는 페이지를 0부터 시작합니다. 1부터 시작하지 않습니다.
  • 10은 각 페이지의 요소 개수입니다.
  • Sort.by("createdAt").descending()은 최신 주문을 맨 앞에 배치하도록 정렬합니다.

테이블에 25개의 주문이 있을 때 출력 결과는:

페이지: 0
이 페이지의 요소 수: 10
전체 요소 수: 25
전체 페이지 수: 3
다음 페이지가 있나? true

spring.jpa.show-sql=true를 켜면 Hibernate가 두 개의 SQL 쿼리를 실행하는 것을 볼 수 있습니다:

select o.id, o.created_at, o.customer_name, o.status, o.total
  from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o

첫 번째 쿼리는 해당 페이지의 데이터를 가져옵니다. 두 번째 쿼리는 totalElements와 totalPages를 계산하기 위해 전체 행 개수를 셉니다. offset ... fetch first 문법은 H2의 문법입니다. MySQL이나 PostgreSQL에서는 limit ? offset ?를 보게 됩니다.

3.2. 페이지 나누기가 있는 Derived query

메서드에 Pageable 파라미터를 추가하기만 하면 됩니다. Spring Data가 나머지를 처리합니다:

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);

    Slice<Order> findByCustomerName(String customerName, Pageable pageable);

    List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
  • findByStatus는 Page를 반환하고, count 쿼리를 함께 실행합니다.
  • findByCustomerName은 Slice를 반환합니다. 섹션 4에서 차이를 설명합니다.
  • findTop5ByStatus는 페이지 나누기 없이 정렬만 필요합니다. 이 경우 Pageable 대신 Sort를 전달합니다.

3.3. 여러 정렬 기준 결합

Sort를 사용하면 여러 열을 결합할 수 있습니다:

Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);

Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);

위 코드는 결제된 주문의 두 번째 페이지(6번째부터 10번째 요소)를 가져옵니다. 금액이 큰 주문이 먼저 정렬됩니다. 금액이 같다면 더 오래된 주문이 앞에 옵니다.

주의: 항상 고유한 열을 포함하여 정렬하세요. 예를 들어 끝에 id를 추가하세요. 만약 total로만 정렬하고 여러 행이 같은 값을 가지면, 데이터베이스는 호출할 때마다 다른 순서로 반환할 수 있습니다. 그 결과 어떤 기록이 두 페이지에 나타나거나, 둘 다에 나타나지 않을 수 있습니다.

4. Page인가 Slice인가?

Page<T> Slice<T>
count 쿼리 있음 없음
getTotalElements(), getTotalPages() 있음 없음
hasNext() 있음 있음
적합한 경우 페이지 표시가 있는 테이블 (1, 2, 3 … 50) 무한 스크롤, “더 보기” 버튼

Slice는 count 쿼리를 실행하지 않습니다. 대신 size + 1 행을 가져옵니다. 추가 행을 받으면 다음 페이지가 있다는 것을 알 수 있습니다:

Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true

큰 테이블의 경우 count(*)는 메인 쿼리만큼 느릴 수 있습니다. UI에서 전체 페이지 수를 표시할 필요가 없다면 Slice를 사용하세요.

5. HTTP 요청에서 Pageable 받기

5.1. Controller

Spring Boot는 자동으로 PageableHandlerMethodArgumentResolver를 구성합니다. 따라서 controller 파라미터로 Pageable을 선언하면 됩니다:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @GetMapping
    public PagedModel<OrderResponse> list(
            @RequestParam OrderStatus status,
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
            Pageable pageable) {

        Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
                .map(OrderResponse::from);
        return new PagedModel<>(page);
    }
}

public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
    static OrderResponse from(Order o) {
        return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
    }
}

주목할 점:

  • @PageableDefault는 client가 page, size, sort를 보내지 않을 때의 기본값을 설정합니다.
  • page.map(...)은 entity를 DTO로 변환하면서 페이지 나누기 정보를 유지합니다. entity를 API 외부로 반환하면 안 됩니다.
  • PagedModel (패키지 org.springframework.data.web, Spring Data 3.3 / Boot 3.3부터 사용 가능)은 Page를 안정적인 JSON 구조로 감쌉니다.

Client는 다음과 같이 API를 호출합니다:

curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
  "content": [
    { "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
    { "id": 4,  "customerName": "An",   "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
  ],
  "page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}

sort 파라미터는 속성,방향 형식입니다. 여러 열로 정렬하려면 여러 번 반복할 수 있습니다.

5.2. Page를 바로 반환하지 않는 이유

Page<OrderResponse>를 반환할 수 있습니다. 하지만 이 경우 JSON은 내부 클래스인 PageImpl을 serialize한 결과이므로, 버전 간에 구조가 변할 수 있습니다. Spring Data 3.3부터는 이렇게 할 때 로그에 경고가 표시됩니다. 두 가지 방법으로 처리할 수 있습니다. 위처럼 PagedModel을 반환하거나, 애플리케이션 전체에 자동 변환 모드를 활성화합니다:

@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}

5.3. 전역 설정

spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort

max-page-size가 가장 중요한 속성입니다. client가 size=1000000을 보내면 Spring은 자동으로 100으로 제한합니다. 기본 제한은 2000으로 대부분의 API에는 너무 큽니다.

one-indexed-parameters=true는 client가 첫 페이지에 page=1을 보낼 수 있게 합니다. 내부적으로는 Pageable이 여전히 0부터 시작하는 인덱스를 사용합니다.

6. @Query를 사용한 페이지 나누기

직접 작성한 JPQL에서도 Spring Data는 자동으로 order by와 limit/offset을 추가합니다. 또한 쿼리에서 count 쿼리를 자동으로 유추하려고 합니다. join fetch가 있으면 이 유추가 잘못되므로, countQuery를 따로 선언해야 합니다:

@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
       countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);

이 예제는 Order가 Customer entity와 @ManyToOne 관계를 가진다고 가정합니다. @ManyToOne의 경우 join fetch는 페이지 나누기와 정상적으로 작동합니다.

주의: collection (@OneToMany)이 있는 join fetch는 페이지 나누기와 함께 사용하지 마세요. Hibernate는 SQL 레벨에서 limit를 적용할 수 없습니다. 전체 데이터를 메모리에 로드한 후 페이지를 자르며, 경고 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory를 기록합니다. 올바른 방법은 먼저 ID로 페이지를 나눈 다음, 해당 ID 목록에 따라 collection을 별도로 조회하는 것입니다.

7. 테스트

@DataJpaTest는 임베드된 데이터베이스를 시작하고 JPA 계층만 로드하므로, repository를 테스트하기에 매우 적합합니다:

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository orderRepository;

    @BeforeEach
    void setUp() {
        LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
        for (int i = 1; i <= 25; i++) {
            orderRepository.save(new Order("고객 " + i, BigDecimal.valueOf(i * 10_000L),
                    OrderStatus.PAID, base.plusHours(i)));
        }
    }

    @Test
    void shouldReturnSecondPageSortedByTotalDesc() {
        Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());

        Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);

        assertThat(page.getTotalElements()).isEqualTo(25);
        assertThat(page.getTotalPages()).isEqualTo(3);
        assertThat(page.getContent()).hasSize(10);
        assertThat(page.getContent().getFirst().getTotal())
                .isEqualByComparingTo("150000");
        assertThat(page.hasNext()).isTrue();
    }

    @Test
    void lastPageShouldContainRemainingOrders() {
        Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));

        assertThat(page.getContent()).hasSize(5);
        assertThat(page.isLast()).isTrue();
    }
}

첫 번째 테스트에서 페이지 1(두 번째 페이지)은 11번째부터 20번째 요소를 포함합니다. 페이지의 첫 번째 요소는 11번째로 큰 주문이므로 25 - 10 = 15, 즉 150,000입니다. 두 번째 테스트는 마지막 페이지에 5개의 주문만 있는지 확인합니다.

8. 자주 발생하는 오류

존재하지 않는 속성으로 정렬. ?sort=passwordHash 또는 ?sort=abc 요청은 PropertyReferenceException을 발생시킵니다. 처리하지 않으면 client는 오류 500을 받습니다. 더 심각하게, client가 민감한 열로 정렬하여 데이터를 탐색할 수 있습니다. 화이트리스트로 필터링해야 합니다:

private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");

static Pageable sanitize(Pageable pageable) {
    Sort safe = Sort.by(pageable.getSort().stream()
            .filter(order -> SORTABLE.contains(order.getProperty()))
            .toList());
    return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
            safe.isSorted() ? safe : Sort.by("createdAt").descending());
}

Offset이 너무 깊음. page=50000&size=20은 데이터베이스가 100만 행을 읽고 건너뛰도록 합니다. 쿼리 시간은 offset에 비례하여 증가합니다. 큰 데이터나 무한 스크롤의 경우 keyset pagination을 사용하세요. 즉, “마지막으로 본 id보다 작은 행들을 가져오기”입니다. Spring Data는 ScrollPosition과 Window를 통해 이를 지원합니다.

페이지 시작을 1로 잘못 이해. PageRequest.of(1, 10)은 두 번째 페이지입니다. frontend가 1부터 시작한다면, 코드 곳곳에서 1을 빼지 말고 one-indexed-parameters를 활성화하세요.

호출 사이에 데이터 변경. offset 기반 페이지 나누기는 새로운 기록이 삽입될 때 불안정합니다. 사용자는 페이지를 넘길 때 같은 주문을 다시 볼 수 있습니다. Keyset pagination은 이 문제를 피합니다.

Page를 사용하면 안 될 때: 테이블이 매우 크고 UI가 전체 페이지 수를 필요로 하지 않을 때. 예를 들어 피드나 로그의 경우입니다. count 쿼리는 불필요한 비용이므로, Slice 또는 Window를 사용하세요.

9. 결론

Pageable은 페이지, 크기, 정렬 방식 세 가지를 하나의 객체로 통합합니다. Spring Data는 이를 derived query, @Query, controller 파라미터 등 모든 계층에서 limit/offset과 order by로 변환합니다. 전체 페이지 수가 필요하면 Page를 사용하고, 데이터의 존재 여부만 알면 되면 Slice를 사용하세요. JSON을 반환할 때는 PagedModel을 사용하여 응답 구조가 안정적으로 유지되도록 하세요.

세 가지를 잊지 마세요: max-page-size를 제한하고, 화이트리스트에 있는 속성으로만 정렬을 허용하며, 항상 고유한 열로 정렬하세요. offset이 느려지기 시작하면, keyset pagination으로 전환할 시간입니다.

spring-bootspring-data-jpapageablerest-apijpa