들어가며
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 |