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은 “다음 구간”을 인덱스로 이어서 읽는 구조입니다.

코드 예제: 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)을 그대로 외부 입력으로 노출할 때 화이트리스트를 두는 게 좋습니다
Pageable의sort파라미터를 그대로 받으면, 인덱스가 없는 컬럼 정렬로 인해 느려질 수 있습니다.- 컨트롤러에서 허용할 정렬 키를 제한하거나, 아예 정렬을 고정하고(예:
id desc,createdAt desc) 필요한 경우에만 옵션을 열어두는 방식이 운영에서 안정적입니다. - keyset pagination은 “정렬 키가 유일해야” 페이지 중복/누락이 줄어듭니다. 보통
(createdAt, id)처럼 타이브레이커를 함께 쓰는 패턴을 고려해 보세요.
핵심 요약
Page는 편하지만 count 쿼리 비용이 숨어 있어, 느려질 여지가 큽니다.- “총 페이지 수”가 필요 없으면
Slice가 더 안전한 선택입니다. - offset이 커지는 목록은 keyset pagination을 검토해 보세요.
다음 글: [#19 JPA Auditing(@CreatedDate 등)로 자동 기록]