Java & Spring/Spring

페이지네이션의 원리? 필요성?

승주우에요 2026. 4. 10. 15:32

들어가며

Spring으로 목록 API를 만들다 보면 Pageable, Page, Slice를 자연스럽게 쓰게 된다. 근데 막상 "이게 내부적으로 어떻게 동작해?"라고 물어보면 설명을 잘 못했었다. 이 글에서는 페이징이 왜 필요한지부터 OFFSET 방식의 한계, 커서 기반 페이징의 원리까지 차근차근 정리해보자!


왜 페이징이 필요할까?

SELECT * FROM users;

데이터가 100만 건이면 이 쿼리 한 방에 전부 가져온다. 메모리가 터지고 응답도 한없이 느려진다. 잘라서 가져오는 것이 페이징이다.


OFFSET 기반 페이징

원리

DB에서 페이징을 구현하는 가장 기본적인 방법은 LIMIT과 OFFSET을 쓰는 것이다.

SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 0;   -- 1페이지
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10;  -- 2페이지
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;  -- 3페이지
  • LIMIT = 몇 개 가져올지
  • OFFSET = 몇 번째 행부터 가져올지

공식으로 보면 OFFSET = (페이지번호 - 1) * 페이지크기다.

Spring에서 사용

// 1페이지, 한 페이지에 10개, id 기준 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("id"));
Page<User> page = userRepository.findAll(pageable);

JPA가 Pageable을 받아서 자동으로 LIMIT/OFFSET 쿼리로 변환해준다.

OFFSET의 치명적인 문제

SELECT * FROM users LIMIT 10 OFFSET 900000;

DB는 앞에 90만 건을 실제로 다 읽고 버린 다음 10개를 가져온다. 건너뛰는 게 아니라 읽고 버리는 거다. 뒤 페이지로 갈수록 점점 느려지는 이유가 바로 이것이다.


Pageable, Slice, Page 원리

Pageable

"어떻게 조회할지"에 대한 조건을 담는 객체다. 실제 데이터가 아니라 조회 조건만 들고 있다.

Pageable pageable = PageRequest.of(1, 10, Sort.by("id"));

pageable.getPageNumber() // 1
pageable.getPageSize()   // 10
pageable.getOffset()     // 10 (자동 계산)
pageable.getSort()       // id ASC

이걸 Repository에 넘기면 JPA가 SQL로 변환한다.

-> 10번째 행부터 최대 10개를 id기준으로 가져온다!

SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10

Slice

"조회 결과 + 다음 페이지 존재 여부" 를 담는 객체다. 전체 데이터가 몇 개인지는 모른다.

Slice<User> slice = userRepository.findAll(pageable);

slice.getContent()   // 실제 데이터 리스트
slice.hasNext()      // 다음 페이지 있는지
slice.getNumber()    // 현재 페이지 번호

내부적으로 size + 1개를 가져와서 다음 페이지 여부를 판단한다.

SELECT * FROM users LIMIT 11  -- size(10) + 1개 조회
-- 11개 나오면 → hasNext() = true
-- 10개 이하면 → hasNext() = false

COUNT 쿼리가 없으니 Page보다 가볍다.

Page

Slice에 전체 개수 COUNT 쿼리가 추가된 버전이다.

Page<User> page = userRepository.findAll(pageable);

page.getTotalElements() // 전체 데이터 개수
page.getTotalPages()    // 전체 페이지 수
page.hasNext()          // 다음 페이지 여부
page.getContent()       // 실제 데이터

내부적으로 쿼리가 2번 나간다.

SELECT * FROM users LIMIT 10 OFFSET 10  -- 데이터 조회
SELECT COUNT(*) FROM users               -- 전체 개수

COUNT 쿼리가 추가되니 Slice보다 느리다. 게시판처럼 전체 페이지 수가 필요할 때 쓴다.

셋의 관계

Pageable → 조회 조건을 담는 객체 (인풋)
Slice    → 결과 + hasNext (아웃풋)
Page     → 결과 + hasNext + 전체 개수 (아웃풋, Slice 상속)

Pageable Slice Page

역할 조회 조건 결과 + hasNext 결과 + 전체 개수
COUNT 쿼리 X X O
사용처 공통 무한스크롤 게시판 페이지네이션

커서 기반 페이징

원리

OFFSET 방식의 성능 문제를 해결하기 위해 나온 방식이다. 마지막으로 읽은 데이터의 위치를 커서로 넘겨서 거기서부터 읽는다.

-- OFFSET 방식: 앞 데이터를 다 읽고 버림
SELECT * FROM users LIMIT 10 OFFSET 900000;

-- 커서 방식: 인덱스 타고 바로 그 위치로 점프
SELECT * FROM users WHERE id > 900000 LIMIT 10;

id에 인덱스가 걸려있으면 WHERE id > ? 조건으로 바로 그 위치로 점프할 수 있다. 앞 데이터를 읽고 버리는 낭비가 없다.

전체 흐름

1. 클라이언트 → GET /users?size=10          (cursor 없음, 첫 요청)
   DB 쿼리   → WHERE id > 0 LIMIT 10
   응답      → data: [1~10번], nextCursor: 10

2. 클라이언트 → GET /users?cursor=10&size=10
   DB 쿼리   → WHERE id > 10 LIMIT 10
   응답      → data: [11~20번], nextCursor: 20

3. 클라이언트 → GET /users?cursor=20&size=10
   DB 쿼리   → WHERE id > 20 LIMIT 10
   응답      → data: [21~30번], nextCursor: 30

클라이언트가 응답으로 받은 nextCursor를 다음 요청에 그대로 넘겨주는 구조다.

Spring 구현

// Repository
@Query("SELECT u FROM User u WHERE u.id > :cursor ORDER BY u.id ASC")
List<User> findByCursor(@Param("cursor") Long cursor, Pageable pageable);
// Service
public CursorResponse getUsers(Long cursor, int size) {
    Long cursorId = (cursor == null) ? 0L : cursor;
    Pageable pageable = PageRequest.of(0, size); // OFFSET은 항상 0
    
    List<User> users = userRepository.findByCursor(cursorId, pageable);
    Long nextCursor = users.isEmpty() ? null : users.get(users.size() - 1).getId();
    
    return new CursorResponse(users, nextCursor);
}

여기서 Pageable의 역할이 중요하다. 커서 방식에서 Pageable은 LIMIT만 설정하기 위해 사용한다. OFFSET은 항상 0이고, 시작 위치는 cursor가 결정한다.

cursor   → "어디서부터 읽어" (시작 위치)
Pageable → "몇 개 읽어"     (LIMIT)

cursor 기준으로 무엇을 쓸까

커서는 순서가 보장되는 값이면 무엇이든 쓸 수 있다.

id (Long)          → 가장 심플, 순서 보장
createdAt          → 시간 기준 정렬할 때
createdAt + id     → 같은 시간에 생성된 데이터 있을 때 안전하게

UUID를 id로 쓰는 경우엔 주의가 필요하다.

UUID v4 → 완전 랜덤, 순서 없음 → WHERE id > ? 의미없음
UUID v7 → 타임스탬프 기반, 순서 보장 → 그대로 사용 가능

UUID v4를 쓴다면 createdAt + id 복합 커서를 쓰는 게 안전하다.

@Query("""
    SELECT u FROM User u
    WHERE u.createdAt > :createdAt
    OR (u.createdAt = :createdAt AND u.id > :id)
    ORDER BY u.createdAt ASC, u.id ASC
""")
List<User> findByCursor(
    @Param("createdAt") LocalDateTime createdAt,
    @Param("id") String id,
    Pageable pageable
);

OFFSET vs 커서 비교

OFFSET 방식 커서 방식

뒤 페이지 성능 느림 (앞 데이터 다 읽음) 빠름 (인덱스로 바로 점프)
특정 페이지 이동 가능 불가
전체 개수 파악 가능 불가
구현 복잡도 낮음 높음
사용처 게시판 페이지네이션 무한스크롤

마무리

페이징의 핵심을 정리하면 이렇다.

  • OFFSET은 앞 데이터를 다 읽고 버리기 때문에 뒤로 갈수록 느려진다.
  • 커서 방식은 인덱스를 타고 바로 그 위치로 점프하기 때문에 성능이 일정하다.
  • Pageable은 조회 조건(LIMIT, OFFSET, 정렬)을 담는 객체고, 커서 방식에서는 LIMIT만 활용한다.
  • Slice는 다음 페이지 여부만, Page는 전체 개수까지 알 수 있다.

어떤 방식을 쓸지는 요구사항에 따라 다르다. 전체 페이지 수가 필요한 게시판이면 OFFSET + Page, 무한스크롤이면 커서 + List가 맞다. 만약, 페이지가 필요하고 데이터가 많아 성능 최적화가 필요하다면 커버링 인덱스 방식도 있다고 하니 참고하면 좋을 것 같다 !

'Java & Spring > Spring' 카테고리의 다른 글

OSIV · 낙관적 락 · 비관적 락  (0) 2026.05.09
Spring Batch 개념, 사용  (0) 2026.04.29
[MapStruct] 내부 구현 방식 - 설계 문제 해결  (0) 2026.04.09
Spring 프록시 원리  (0) 2026.04.03
MapStruct  (0) 2026.03.22