Spring Data JPA - 페이지네이션 (Page, Pageable)

StrayCat·2026년 2월 25일

DB 데이터가 수천, 수만 건이 넘어가면 전체를 한 번에 조회하는 것은 성능 문제로 이어집니다.
Spring Data JPA는 이를 위해 PageablePage라는 강력한 페이지네이션 도구를 기본 제공합니다.


핵심 개념 이해

Pageable — "어떻게 잘라줄 것인가?"

Pageable은 인터페이스로, 다음 세 가지 정보를 담습니다.

  • page : 현재 페이지 번호 (0부터 시작)
  • size : 한 페이지에 담을 데이터 수
  • sort : 정렬 기준 (선택)

직접 구현체를 생성할 때는 PageRequest.of()를 사용합니다.

// 0번째 페이지, 10개씩
Pageable pageable = PageRequest.of(0, 10);

// 0번째 페이지, 10개씩, 생성일 내림차순 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());

Page — "잘라진 결과물"

Page<T>는 조회 결과와 함께 페이지네이션 메타 정보를 함께 담고 있는 객체입니다.

메서드설명
getContent()현재 페이지의 데이터 목록
getTotalElements()전체 데이터 개수
getTotalPages()전체 페이지 수
getNumber()현재 페이지 번호
getSize()페이지당 데이터 수
isFirst() / isLast()첫/마지막 페이지 여부
hasNext() / hasPrevious()다음/이전 페이지 존재 여부

전체 구현 흐름

1. Entity

@Entity
public class Post {

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

    private String title;
    private String content;

    @CreatedDate
    private LocalDateTime createdAt;
}

2. Repository

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);
}

3. Service

@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);
    }
}

4. Controller

@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));
    }
}

응답 예시 (JSON)

{
  "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 vs Slice — 언제 무엇을 쓸까?

Page 말고도 Slice<T>라는 타입도 존재합니다. 차이를 알아두면 선택에 도움이 됩니다.

구분Page<T>Slice<T>
전체 데이터 수 제공O (getTotalElements())X
COUNT 쿼리 발생O (자동 발생)X
적합한 UI번호형 페이지네이션무한 스크롤, "더보기" 버튼

Page는 내부적으로 COUNT(*) 쿼리를 추가로 실행합니다.
데이터가 수백만 건이라면 이 COUNT 쿼리 자체가 느려질 수 있으므로,
무한 스크롤처럼 총 개수가 필요 없는 경우에는 Slice를 고려하는 것이 좋습니다.


DTO로 변환하는 것이 권장되는 이유

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()은 페이지네이션 메타 정보를 유지한 채로 내부 데이터만 변환해줍니다.


@PageableDefault — 컨트롤러를 더 깔끔하게

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를 활용하면 컨트롤러 코드를 간결하게 유지할 수 있음
profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글