Spring Boot

Spring Boot 페이징과 정렬 실전 가이드 (성능 함정: Slice vs Page, count 최적화, keyset pagination)

IT Lab 2026. 3. 17. 10:00

Spring Boot 3.x + Spring Data JPA에서 Pageable을 제대로 쓰는 방법과 Page/Slice 선택 기준, count 쿼리 최적화, keyset pagination(커서 페이징)까지 실전 관점으로 정리합니다.

도입 (문제 상황)

목록 API를 만들 때 Pageable만 붙이면 끝일 것 같지만, 운영에 올리면 갑자기 DB가 느려지거나 count 쿼리가 병목이 되는 경우가 많습니다. 특히 “페이지 수를 보여줘야 해서 Page로 했는데 응답이 느려요” 같은 상황을 자주 겪으실 거예요. 이번 글에서는 페이징/정렬을 “되는 코드”가 아니라 “성능까지 고려한 코드”로 만드는 기준을 잡아봅니다.

핵심 개념: Spring Data JPA 페이징/정렬에서 진짜 중요한 것들

1) Pageable은 편하지만, Page는 공짜가 아닙니다

Pageable을 Repository 메서드에 받으면 Spring Data JPA가 limit/offset + order by를 자동으로 만들어 줍니다. 문제는 반환 타입이 Page<T>일 때입니다.

  • Page<T>totalElements, totalPages가 필요해서 추가로 count 쿼리를 실행합니다.
  • 이 count 쿼리가 단순 테이블이면 괜찮지만, join / group by / distinct가 섞이면 갑자기 비싸질 수 있습니다.

반면 Slice<T>는 “다음 페이지가 있는지”만 판단하기 위해 한 건을 더 조회하는 방식이라 count 쿼리가 없습니다. 즉, “총 페이지 수”가 꼭 필요하지 않다면 Slice가 더 안전한 기본값이 됩니다.

2) Slice vs Page 선택 기준 (실무에서 자주 헷갈리는 부분)

아래 표처럼 “UI/요구사항” 기준으로 결정하시면 실수가 줄어듭니다.

구분 Page Slice
totalElements/totalPages 제공함 (count 쿼리 필요) 제공 안 함
성능 count가 비싸면 느려짐 대체로 유리
적합한 UI “1/123 페이지” 같은 페이지 네비게이션 “더보기”, 무한 스크롤
쿼리 특징 content 쿼리 + count 쿼리 content 쿼리(plus 1 row)

3) count 쿼리 최적화: “content 쿼리”와 분리해서 생각하기

Page를 써야 한다면, 핵심은 count 쿼리를 단순하게 만드는 것입니다.

  • content 조회는 필요한 join/fetch를 하더라도
  • count는 가능하면 join 없이, 혹은 최소한의 조건만으로 세는 게 좋습니다.

Spring Data JPA에서는 @Query(value=..., countQuery=...)로 content와 count를 분리할 수 있습니다. 이게 실무에서 가장 효과가 큰 최적화 포인트 중 하나입니다.

4) offset pagination의 함정과 keyset pagination(커서 페이징) 맛보기

page=5000처럼 offset이 커지면 DB는 “앞의 4999페이지를 건너뛰기” 위해 내부적으로 많은 작업을 하게 됩니다. 정렬 컬럼에 인덱스가 있어도 offset이 커질수록 비용이 증가하는 패턴이 흔합니다.

이때 대안이 keyset pagination(커서 기반)입니다.

  • offset 대신 “마지막으로 본 id/시간” 같은 커서를 넘깁니다.
  • where id < :cursor order by id desc limit :size 형태로 동작합니다.
  • 큰 페이지로 갈수록 느려지는 문제가 줄어듭니다.

다만 “임의 페이지로 점프”는 어렵고, 커서 설계(정렬 키의 유일성 확보)가 필요합니다. 그래서 보통 무한 스크롤/피드형에 먼저 적용해 보시길 권합니다.

flowchart LR
  A["Client 요청"] --> B["Spring Data JPA"]
  B --> C["Offset pagination: limit/offset + order by"]
  B --> D["Keyset pagination: where key < cursor + limit"]
  C --> E["DB: offset 커질수록 부담 증가"]
  D --> F["DB: 인덱스 타고 다음 구간 조회"]

offset은 뒤로 갈수록 비싸지고, keyset은 “다음 구간”을 인덱스로 이어서 읽는 구조입니다.

Offset pagination과 keyset pagination의 성능 차이를 보여주는 개념도

코드 예제: Pageable, Slice vs Page, count 최적화, keyset pagination

아래 코드는 Spring Boot 3.x + Java 17 + Spring Data JPA 기준으로 바로 실행 가능한 예시입니다. H2로 동작하며, /posts/page, /posts/slice, /posts/keyset 세 가지 엔드포인트로 비교해 볼 수 있습니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
}

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
  h2:
    console:
      enabled: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

도메인: Post

package com.example.demo;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(indexes = {
        @Index(name = "idx_post_created_id", columnList = "createdAt, id")
})
public class Post {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private Instant createdAt;

    protected Post() {}

    public Post(String title, Instant createdAt) {
        this.title = title;
        this.createdAt = createdAt;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public Instant getCreatedAt() { return createdAt; }
}

Repository: Page / Slice / countQuery 분리 / keyset

package com.example.demo;

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository<Post, Long> {

    // 1) Page: count 쿼리가 함께 나감
    Page<Post> findByTitleContainingIgnoreCase(String keyword, Pageable pageable);

    // 2) Slice: count 쿼리 없이 다음 페이지 여부만 판단
    Slice<Post> findSliceByTitleContainingIgnoreCase(String keyword, Pageable pageable);

    // 3) Page + countQuery 최적화 예시
    // content 쿼리는 정렬/조건을 유지하되, count는 가볍게 분리할 수 있음
    @Query(
        value = """
            select p
            from Post p
            where lower(p.title) like lower(concat('%', :keyword, '%'))
            """,
        countQuery = """
            select count(p.id)
            from Post p
            where lower(p.title) like lower(concat('%', :keyword, '%'))
            """
    )
    Page<Post> searchPageOptimizedCount(@Param("keyword") String keyword, Pageable pageable);

    // 4) Keyset pagination: "id desc" 기준 커서 페이징 맛보기
    @Query("""
        select p
        from Post p
        where (:cursorId is null or p.id < :cursorId)
        order by p.id desc
    """)
    Slice<Post> findNextByIdDesc(@Param("cursorId") Long cursorId, Pageable pageable);
}

Controller

package com.example.demo;

import org.springframework.data.domain.*;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/posts")
public class PostController {

    private final PostRepository postRepository;

    public PostController(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    // Page 예시: totalElements/totalPages 필요할 때
    @GetMapping("/page")
    public Map<String, Object> page(
            @RequestParam(defaultValue = "") String q,
            @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable
    ) {
        Page<Post> page = postRepository.searchPageOptimizedCount(q, pageable);

        return Map.of(
                "contentSize", page.getContent().size(),
                "totalElements", page.getTotalElements(),
                "totalPages", page.getTotalPages(),
                "hasNext", page.hasNext(),
                "contentIds", page.getContent().stream().map(Post::getId).toList()
        );
    }

    // Slice 예시: 무한 스크롤/더보기
    @GetMapping("/slice")
    public Map<String, Object> slice(
            @RequestParam(defaultValue = "") String q,
            @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable
    ) {
        Slice<Post> slice = postRepository.findSliceByTitleContainingIgnoreCase(q, pageable);

        return Map.of(
                "contentSize", slice.getContent().size(),
                "hasNext", slice.hasNext(),
                "contentIds", slice.getContent().stream().map(Post::getId).toList()
        );
    }

    // Keyset pagination: cursorId를 넘겨서 다음 묶음을 가져옴
    @GetMapping("/keyset")
    public Map<String, Object> keyset(
            @RequestParam(required = false) Long cursorId,
            @RequestParam(defaultValue = "20") int size
    ) {
        // keyset에서는 정렬이 고정되는 경우가 많아 Pageable에 sort를 외부 입력으로 받지 않는 편이 안전합니다.
        Pageable pageable = PageRequest.of(0, size);

        Slice<Post> slice = postRepository.findNextByIdDesc(cursorId, pageable);
        Long nextCursor = slice.getContent().isEmpty()
                ? null
                : slice.getContent().get(slice.getContent().size() - 1).getId();

        return Map.of(
                "cursorId", cursorId,
                "nextCursorId", nextCursor,
                "hasNext", slice.hasNext(),
                "contentIds", slice.getContent().stream().map(Post::getId).toList()
        );
    }
}

데이터 초기화 (실행 시 더미 데이터 생성)

package com.example.demo;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Instant;
import java.util.stream.IntStream;

@Configuration
public class DataInit {

    @Bean
    CommandLineRunner init(PostRepository postRepository) {
        return args -> {
            if (postRepository.count() > 0) return;

            Instant now = Instant.now();
            IntStream.rangeClosed(1, 500).forEach(i -> {
                postRepository.save(new Post("post " + i, now.minusSeconds(i)));
            });
        };
    }
}

애플리케이션 엔트리포인트

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

호출 예시

  • Page: GET /posts/page?q=post&page=0&size=20&sort=id,desc
  • Slice: GET /posts/slice?q=post&page=0&size=20&sort=id,desc
  • Keyset:
    • 첫 페이지: GET /posts/keyset?size=20
    • 다음 페이지: GET /posts/keyset?cursorId=481&size=20 (응답의 nextCursorId를 이어서 사용)

실무 팁

💡 실무에서는: “Page를 기본값”으로 두지 말고, UI 요구사항부터 확인해 보세요

  • 페이지 네비게이션(총 페이지 수 노출)이 꼭 필요할 때만 Page를 쓰는 편이 안전합니다.
  • 무한 스크롤/더보기라면 Slice로 시작하면 count 병목을 원천적으로 피할 수 있습니다.
  • 특히 검색 조건이 복잡해질수록 count는 예상보다 비싸집니다(조인/중복 제거가 끼면 더 심해요).

💡 실무에서는: 정렬(sort)을 그대로 외부 입력으로 노출할 때 화이트리스트를 두는 게 좋습니다

  • Pageablesort 파라미터를 그대로 받으면, 인덱스가 없는 컬럼 정렬로 인해 느려질 수 있습니다.
  • 컨트롤러에서 허용할 정렬 키를 제한하거나, 아예 정렬을 고정하고(예: id desc, createdAt desc) 필요한 경우에만 옵션을 열어두는 방식이 운영에서 안정적입니다.
  • keyset pagination은 “정렬 키가 유일해야” 페이지 중복/누락이 줄어듭니다. 보통 (createdAt, id)처럼 타이브레이커를 함께 쓰는 패턴을 고려해 보세요.

핵심 요약

  • Page는 편하지만 count 쿼리 비용이 숨어 있어, 느려질 여지가 큽니다.
  • “총 페이지 수”가 필요 없으면 Slice가 더 안전한 선택입니다.
  • offset이 커지는 목록은 keyset pagination을 검토해 보세요.

다음 글: [#19 JPA Auditing(@CreatedDate 등)로 자동 기록]