DB 데이터가 수천, 수만 건이 넘어가면 전체를 한 번에 조회하는 것은 성능 문제로 이어집니다.
Spring Data JPA는 이를 위해 Pageable과 Page라는 강력한 페이지네이션 도구를 기본 제공합니다.
Pageable은 인터페이스로, 다음 세 가지 정보를 담습니다.
직접 구현체를 생성할 때는 PageRequest.of()를 사용합니다.
// 0번째 페이지, 10개씩
Pageable pageable = PageRequest.of(0, 10);
// 0번째 페이지, 10개씩, 생성일 내림차순 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<T>는 조회 결과와 함께 페이지네이션 메타 정보를 함께 담고 있는 객체입니다.
| 메서드 | 설명 |
|---|---|
getContent() | 현재 페이지의 데이터 목록 |
getTotalElements() | 전체 데이터 개수 |
getTotalPages() | 전체 페이지 수 |
getNumber() | 현재 페이지 번호 |
getSize() | 페이지당 데이터 수 |
isFirst() / isLast() | 첫/마지막 페이지 여부 |
hasNext() / hasPrevious() | 다음/이전 페이지 존재 여부 |
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@CreatedDate
private LocalDateTime createdAt;
}
Spring Data JPA의 JpaRepository를 상속받으면 findAll(Pageable pageable)이 이미 포함되어 있습니다.
별도 구현 없이 바로 사용 가능합니다.
public interface PostRepository extends JpaRepository<Post, Long> {
// 기본 제공: Page<Post> findAll(Pageable pageable);
// 조건 검색 + 페이지네이션도 자연스럽게 조합 가능
Page<Post> findByTitleContaining(String keyword, Pageable pageable);
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public Page<Post> getPosts(int page, int size) {
// 생성일 내림차순 정렬로 최신 글부터 조회
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return postRepository.findAll(pageable);
}
public Page<Post> searchPosts(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return postRepository.findByTitleContaining(keyword, pageable);
}
}
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
// GET /posts?page=0&size=10
@GetMapping
public ResponseEntity<Page<Post>> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
return ResponseEntity.ok(postService.getPosts(page, size));
}
// GET /posts/search?keyword=spring&page=0&size=10
@GetMapping("/search")
public ResponseEntity<Page<Post>> search(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
return ResponseEntity.ok(postService.searchPosts(keyword, page, size));
}
}
{
"content": [
{ "id": 10, "title": "Spring Boot 페이지네이션", "createdAt": "2025-01-10" }
],
"totalElements": 100,
"totalPages": 10,
"number": 0,
"size": 10,
"first": true,
"last": false
}
Page<T>를 그대로 반환하면 위와 같이 메타 정보가 자동으로 포함된 응답이 만들어집니다.
Page 말고도 Slice<T>라는 타입도 존재합니다. 차이를 알아두면 선택에 도움이 됩니다.
| 구분 | Page<T> | Slice<T> |
|---|---|---|
| 전체 데이터 수 제공 | O (getTotalElements()) | X |
| COUNT 쿼리 발생 | O (자동 발생) | X |
| 적합한 UI | 번호형 페이지네이션 | 무한 스크롤, "더보기" 버튼 |
Page는 내부적으로 COUNT(*) 쿼리를 추가로 실행합니다.
데이터가 수백만 건이라면 이 COUNT 쿼리 자체가 느려질 수 있으므로,
무한 스크롤처럼 총 개수가 필요 없는 경우에는 Slice를 고려하는 것이 좋습니다.
Entity를 그대로 반환하면 불필요한 필드 노출, 순환 참조 등의 문제가 생길 수 있습니다.
Page.map()을 사용하면 간결하게 DTO로 변환할 수 있습니다.
// PostResponseDto
public record PostResponseDto(Long id, String title, LocalDateTime createdAt) {}
// Service에서 변환
public Page<PostResponseDto> getPosts(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return postRepository.findAll(pageable)
.map(post -> new PostResponseDto(post.getId(), post.getTitle(), post.getCreatedAt()));
}
Page.map()은 페이지네이션 메타 정보를 유지한 채로 내부 데이터만 변환해줍니다.
Spring MVC에서는 Pageable을 컨트롤러 파라미터로 직접 받을 수 있습니다.
@PageableDefault로 기본값을 설정하면 코드가 한결 간결해집니다.
// GET /posts?page=0&size=10&sort=createdAt,desc
@GetMapping
public ResponseEntity<Page<PostResponseDto>> getPosts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return ResponseEntity.ok(postService.getPosts(pageable));
}
Spring이 쿼리 파라미터(page, size, sort)를 자동으로 Pageable로 바인딩해줍니다.
@RepositoryRestResource와 Spring HATEOAS
Spring Data REST를 사용하면 Page 응답에 _links 형태의 HATEOAS 링크가 자동으로 포함됩니다.
REST API 설계에 관심이 있다면 함께 살펴볼 만한 주제입니다.
Spring Boot 3.x 이상 (Jakarta EE 기반)
Spring Boot 3.x부터는 javax.persistence 대신 jakarta.persistence 패키지를 사용합니다.
임포트 경로 외 페이지네이션 자체의 사용법은 동일합니다.
레거시 환경 (Spring Boot 2.x)
Pageable, Page의 핵심 API는 버전에 따른 차이가 없으므로 본 포스팅의 내용이 그대로 적용됩니다.
Pageable은 페이지 번호, 크기, 정렬 정보를 담는 요청 객체Page<T>는 조회 결과와 전체 개수 등 메타 정보를 함께 담는 응답 객체Page.map()으로 DTO 변환 시 메타 정보가 유지됨Slice<T> 사용을 검토할 것@PageableDefault를 활용하면 컨트롤러 코드를 간결하게 유지할 수 있음