Spring Boot 페이지네이션 구현 (offset 기반)

송진우·2025년 12월 18일
post-thumbnail

페이지네이션이란?

페이지네이션(Pagination)은 대량의 데이터를 여러 페이지로 나누어 보여주는 기법입니다.

필요성

  • 성능 향상: 필요한 데이터만 조회하여 서버 부담 감소
  • 사용자 경험: 빠른 응답 속도와 직관적인 네비게이션
  • 네트워크 효율: 전송 데이터량 최소화

예시 : 전체 게시글 47개

→ 페이지당 10개씩
→ 총 5페이지


🆚 페이지네이션 방식 비교

1. Offset 기반 페이지네이션

SELECT * FROM post ORDER BY created_at DESC LIMIT 10 OFFSET 0;-- 2페이지 (10~19번)
SELECT * FROM post ORDER BY created_at DESC LIMIT 10 OFFSET 10;

OFFSET = 페이지 번호 × 페이지 크기

장점

  • 구현이 간단하고 직관적
  • 특정 페이지로 바로 이동 가능
  • 전체 페이지 수 파악 가능
  • 페이지 번호 UI에 적합 (1, 2, 3, 4, 5...)

단점

  • 데이터가 많을수록 성능 저하
  • 페이징 중 데이터 변경 시 중복/누락 가능

2. Cursor 기반 페이지네이션 (No Offset)

-- 마지막 조회 ID 기준
WHERE id < :lastId ORDER BY id DESC LIMIT 10

장점

  • 성능 우수
  • 데이터 중복/누락 없음

단점

  • 특정 페이지 직접 이동 불가
  • 전체 개수 파악 어려움

선택 기준

  • 게시판, 검색 결과 → Offset 기반
  • 무한 스크롤, SNS 피드 → Cursor 기반

Spring Data JPA의 페이징 지원

Spring Data JPA는 페이지네이션을 위한 강력한 기능을 제공합니다.

핵심 인터페이스

1. Pageable

페이징 정보를 담는 인터페이스로 페이지 번호, 크기, 정렬 정보를 포함합니다.

Pageable pageable = PageRequest.of(
    0,      // page: 페이지 번호 (0부터 시작)
    10,     // size: 페이지당 크기
    Sort.by(Sort.Direction.DESC, "createdAt")  // 정렬
);

2. Page

페이징 결과를 담는 인터페이스로 데이터 + 페이징 메타데이터를 포함합니다.

Page page = postRepository.findAll(pageable);

// 제공 메서드
page.getContent();        // 실제 데이터 List
page.getTotalElements();  // 전체 데이터 개수
page.getTotalPages();     // 전체 페이지 수
page.getNumber();         // 현재 페이지 번호
page.getSize();           // 페이지 크기
page.isFirst();           // 첫 페이지 여부
page.isLast();            // 마지막 페이지 여부

3. JpaRepository 기본 지원

public interface PostRepository extends JpaRepository {
    // Page findAll(Pageable pageable)이 이미 제공
}

구현 과정

1단계: PageResponse DTO 생성

클라이언트에게 페이징 정보를 함께 전달하기 위한 응답 DTO입니다.

파일 위치: post/dto/PageResponse.java

package org.example.jinuweb.post.dto;

import lombok.Builder;
import lombok.Getter;
import org.springframework.data.domain.Page;

import java.util.List;

@Getter
@Builder
public class PageResponse {
    private List content;           // 실제 데이터 목록
    private int currentPage;           // 현재 페이지 번호
    private int totalPages;            // 전체 페이지 수
    private long totalElements;        // 전체 데이터 개수
    private int size;                  // 페이지당 크기
    private boolean first;             // 첫 페이지 여부
    private boolean last;              // 마지막 페이지 여부

    // Page 객체를 PageResponse로 변환하는 정적 팩토리 메서드
    public static  PageResponse of(Page page) {
        return PageResponse.builder()
                .content(page.getContent())
                .currentPage(page.getNumber())
                .totalPages(page.getTotalPages())
                .totalElements(page.getTotalElements())
                .size(page.getSize())
                .first(page.isFirst())
                .last(page.isLast())
                .build();
    }
}

핵심 포인트

  • 제네릭 타입 <T> 사용으로 다양한 엔티티에 재사용 가능
  • 정적 팩토리 메서드 of()로 Spring의 Page 객체를 쉽게 변환
  • 클라이언트가 페이지네이션 UI를 구성하는데 필요한 모든 정보 제공

2단계: Repository

파일: post/repository/PostRepository.java

package org.example.jinuweb.post.repository;

import org.example.jinuweb.post.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository {
    // JpaRepository가 Page findAll(Pageable pageable)을 자동 제공
}

동작 원리

  • JpaRepository를 상속받으면 findAll(Pageable pageable) 메서드가 자동으로 제공됩니다
  • Spring Data JPA가 런타임에 LIMIT, OFFSET이 포함된 SQL을 자동 생성합니다

3단계: Service 로직 구현

파일: post/service/PostService.java

package org.example.jinuweb.post.service;

import lombok.RequiredArgsConstructor;
import org.example.jinuweb.post.dto.PageResponse;
import org.example.jinuweb.post.dto.PostResponse;
import org.example.jinuweb.post.entity.Post;
import org.example.jinuweb.post.repository.PostRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    /**
     * 페이징 처리된 게시글 목록 조회
     * @param page 페이지 번호 (0부터 시작)
     * @param size 페이지당 크기
     * @param sort 정렬 조건
     * @return 페이징된 게시글 응답
     */
    public PageResponse findAll(int page, int size, String sort) {
        // 1. Sort 파싱
        String[] sortParams = sort.split(",");
        Sort.Direction direction = Sort.Direction.fromString(sortParams[1]);
        Sort sortBy = Sort.by(direction, sortParams[0]);
        
        // 2. Pageable 객체 생성
        Pageable pageable = PageRequest.of(page, size, sortBy);
        
        // 3. Repository에서 페이징 조회
        Page postPage = postRepository.findAll(pageable);
        
        // 4. Post 엔티티를 PostResponse DTO로 변환
        Page responsePage = postPage.map(PostResponse::from);
        
        // 5. PageResponse로 변환하여 반환
        return PageResponse.of(responsePage);
    }
}

단계별 설명

1. Sort 파싱

String[] sortParams = sort.split(",");
Sort.Direction direction = Sort.Direction.fromString(sortParams[1]);
Sort sortBy = Sort.by(direction, sortParams[0]);
  • 클라이언트로부터 받은 정렬 문자열을 파싱
  • Sort.by()로 JPA가 이해할 수 있는 정렬 객체 생성

2. Pageable 생성

Pageable pageable = PageRequest.of(page, size, sortBy);
  • PageRequestPageable 인터페이스의 구현체
  • 페이지 번호, 크기, 정렬 정보를 담음
  • 내부적으로 OFFSET = page × size 계산

3. 데이터 조회

Page postPage = postRepository.findAll(pageable);
  • JPA가 자동으로 SELECT ... LIMIT 10 OFFSET 0 SQL 생성
  • Page 객체에 데이터 + 메타데이터가 함께 반환됨

4. DTO 변환

Page responsePage = postPage.map(PostResponse::from);
  • Page.map() 메서드로 엔티티를 DTO로 변환
  • 내부적으로 스트림 처리되어 효율적

5. 응답 생성

return PageResponse.of(responsePage);
  • 최종적으로 클라이언트에 반환할 커스텀 DTO로 변환

4단계: Controller

파일: post/controller/PostController.java

package org.example.jinuweb.post.controller;

import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.example.jinuweb.post.dto.PageResponse;
import org.example.jinuweb.post.dto.PostResponse;
import org.example.jinuweb.post.service.PostService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final PostService postService;

    /**
     * 게시글 전체 조회 (페이징)
     * @param page 페이지 번호 (기본값: 0)
     * @param size 페이지 크기 (기본값: 10)
     * @param sort 정렬 조건
     */
    @Operation(summary = "게시글 전체 조회")
    @GetMapping
    public ResponseEntity<PageResponse> findAll(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(defaultValue = "createdAt,desc") String sort
    ) {
        PageResponse response = postService.findAll(page, size, sort);
        return ResponseEntity.ok(response);
    }
}

@RequestParam 설정

  • defaultValue = "0": 파라미터를 전달하지 않으면 첫 페이지 조회
  • defaultValue = "10": 기본 페이지 크기는 10개
  • defaultValue = "createdAt,desc": 기본 정렬은 최신순

동작 흐름

전체 동작 흐름을 순서대로 정리하면:

1. Client: GET /api/posts?page=1&size=10&sort=createdAt,desc

2. Controller: 요청 파라미터를 받아 Service 호출
   → postService.findAll(1, 10, "createdAt,desc")

3. Service: 
   - Sort 파싱: "createdAt,desc" → Sort 객체
   - Pageable 생성: PageRequest.of(1, 10, sort)
   - Repository 호출: postRepository.findAll(pageable)

4. Repository (JPA):
   - SQL 자동 생성: SELECT * FROM post ORDER BY created_at DESC LIMIT 10 OFFSET 10
   - DB 조회 후 Page<Post> 반환

5. Service:
   - Page<Post> → Page<PostResponse> 변환 (map 사용)
   - Page<PostResponse> → PageResponse<PostResponse> 변환

6. Controller: ResponseEntity로 감싸서 클라이언트에 응답

API 테스트

요청 예시

1. 첫 페이지 조회 (기본값 사용)

GET http://localhost:8080/api/posts

2. 2페이지 조회

GET http://localhost:8080/api/posts?page=1&size=10

3. 제목 오름차순 정렬

GET http://localhost:8080/api/posts?page=0&size=10&sort=title,asc

응답 예시

{
  "content": [
    {
      "id": 47,
      "title": "최신 게시글",
      "content": "내용...",
      "writer": "작성자",
      "createdAt": "2024-12-18T10:30:00",
      "updatedAt": "2024-12-18T10:30:00"
    }
    // ... 9개 더
  ],
  "currentPage": 0,
  "totalPages": 5,
  "totalElements": 47,
  "size": 10,
  "first": true,
  "last": false
}

응답 필드 설명

  • content: 실제 게시글 데이터 배열
  • currentPage: 현재 페이지 번호 (0부터 시작)
  • totalPages: 전체 페이지 수 (5페이지)
  • totalElements: 전체 게시글 개수 (47개)
  • size: 페이지당 크기 (10개)
  • first: 첫 페이지 여부 (true)
  • last: 마지막 페이지 여부 (false)

생성되는 SQL 확인

application.yml에 다음 설정을 추가하면 실제 실행되는 SQL을 확인할 수 있습니다:

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

실행 결과:

-- 데이터 조회
SELECT 
    p.id, p.title, p.content, p.writer, p.created_at, p.updated_at
FROM 
    post p
ORDER BY 
    p.created_at DESC
LIMIT 10 OFFSET 10;

-- 전체 개수 조회 (totalElements 계산용)
SELECT 
    COUNT(p.id)
FROM 
    post p;

핵심 포인트

1. Page vs PageResponse

  • Page: Spring Data JPA가 제공하는 인터페이스
  • PageResponse: 클라이언트에게 반환할 DTO
  • 변환 이유: 필요한 정보만 노출, 일관된 응답 형식

2. 페이지 번호는 0부터 시작

page=0 → 1페이지
page=1 → 2페이지
page=2 → 3페이지

3. PageRequest는 불변 객체

Pageable pageable = PageRequest.of(0, 10, sort);
// 생성 후 수정 불가, 새로 생성해야 함

0개의 댓글