Offset 페이지네이션의 한계
가장 흔한 페이지네이션 방식은 OFFSET + LIMIT입니다. Spring Data JPA의 Pageable이 바로 이 방식을 사용합니다. 하지만 데이터가 수십만 건을 넘어가면 심각한 성능 문제가 발생합니다.
-- 100만 번째 페이지 조회 시 내부 동작
SELECT * FROM orders ORDER BY created_at DESC
OFFSET 1000000 LIMIT 20;
-- DB는 1,000,020개 행을 읽고 1,000,000개를 버림
OFFSET이 커질수록 DB는 건너뛸 행을 모두 스캔해야 합니다. 이것이 “deep pagination” 문제이며, 실시간 데이터가 추가/삭제되면 페이지 간 중복·누락도 발생합니다.
| 구분 | Offset 방식 | Keyset(Cursor) 방식 |
|---|---|---|
| 쿼리 원리 | OFFSET N으로 건너뛰기 | WHERE 조건으로 시작점 지정 |
| 성능 (깊은 페이지) | O(N) — 느려짐 | O(1) — 일정 |
| 데이터 변경 시 | 중복/누락 가능 | 일관성 보장 |
| 특정 페이지 이동 | 가능 (page=5) | 불가 (순차만) |
| 적합한 UI | 페이지 번호 내비게이션 | 무한 스크롤, 더보기 |
Keyset 페이지네이션 원리
Keyset(커서) 페이지네이션은 마지막으로 본 레코드의 정렬 키를 기준으로 WHERE 절에서 다음 페이지를 조회합니다. OFFSET 없이 인덱스를 타므로 데이터 양과 무관하게 일정한 성능을 보장합니다.
-- 첫 페이지
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;
-- 다음 페이지 (마지막 행의 created_at과 id를 커서로 사용)
SELECT * FROM orders
WHERE (created_at, id) < ('2026-03-20 10:30:00', 99850)
ORDER BY created_at DESC, id DESC LIMIT 20;
핵심은 정렬 키 + 유니크 타이브레이커(보통 PK) 조합입니다. 이 조합에 복합 인덱스가 있으면 인덱스 스캔만으로 결과를 얻을 수 있습니다.
Spring Data 3.1+ ScrollPosition API
Spring Data 3.1(Spring Boot 3.1+)부터 ScrollPosition과 Window API가 도입되어 Keyset 페이지네이션을 네이티브로 지원합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Window 반환 타입으로 커서 기반 페이지네이션 자동 지원
Window<Order> findByStatusOrderByCreatedAtDesc(
OrderStatus status,
OffsetScrollPosition position // 또는 KeysetScrollPosition
);
// Top N + ScrollPosition 조합
Window<Order> findTop20ByStatusOrderByCreatedAtDescIdDesc(
OrderStatus status,
ScrollPosition position
);
}
Window 객체는 결과 데이터와 함께 다음 페이지를 위한 ScrollPosition을 포함합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public CursorPage<OrderDto> getOrders(OrderStatus status, String cursor) {
// 커서 디코딩 → ScrollPosition 생성
ScrollPosition position = (cursor == null)
? ScrollPosition.keyset() // 첫 페이지
: decodeCursor(cursor); // 이전 커서에서 복원
Window<Order> window = orderRepository
.findTop20ByStatusOrderByCreatedAtDescIdDesc(status, position);
List<OrderDto> items = window.getContent().stream()
.map(OrderDto::from)
.toList();
// 다음 페이지 커서 인코딩
String nextCursor = window.hasNext()
? encodeCursor(window.positionAt(window.size() - 1))
: null;
return new CursorPage<>(items, nextCursor, window.hasNext());
}
}
커서 인코딩/디코딩 구현
커서는 클라이언트에게 불투명한 문자열로 전달해야 합니다. Base64로 인코딩하면 내부 구조를 숨기면서도 다음 페이지 조회에 필요한 정보를 전달할 수 있습니다.
@Component
public class CursorCodec {
private final ObjectMapper objectMapper = new ObjectMapper();
public String encode(ScrollPosition position) {
if (position instanceof KeysetScrollPosition keyset) {
try {
Map<String, Object> keys = keyset.getKeys();
String json = objectMapper.writeValueAsString(keys);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException e) {
throw new IllegalStateException("커서 인코딩 실패", e);
}
}
throw new IllegalArgumentException("Keyset position만 지원");
}
public KeysetScrollPosition decode(String cursor) {
try {
byte[] decoded = Base64.getUrlDecoder().decode(cursor);
String json = new String(decoded, StandardCharsets.UTF_8);
@SuppressWarnings("unchecked")
Map<String, Object> keys = objectMapper.readValue(json, Map.class);
return ScrollPosition.forward(keys);
} catch (Exception e) {
throw new IllegalArgumentException("잘못된 커서", e);
}
}
}
REST API 설계
커서 기반 API는 page 파라미터 대신 cursor를 사용합니다. 응답에 nextCursor와 hasNext를 포함하여 클라이언트가 다음 요청을 구성할 수 있도록 합니다.
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping
public ResponseEntity<CursorPage<OrderDto>> getOrders(
@RequestParam(defaultValue = "CONFIRMED") OrderStatus status,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int size) {
CursorPage<OrderDto> page = orderService.getOrders(status, cursor, size);
return ResponseEntity.ok(page);
}
}
// 응답 DTO
public record CursorPage<T>(
List<T> items,
String nextCursor, // null이면 마지막 페이지
boolean hasNext,
int size
) {}
클라이언트 요청 흐름:
GET /api/orders?status=CONFIRMED
→ { items: [...], nextCursor: "eyJjcm...", hasNext: true }
GET /api/orders?status=CONFIRMED&cursor=eyJjcm...
→ { items: [...], nextCursor: "eyJpZC...", hasNext: true }
GET /api/orders?status=CONFIRMED&cursor=eyJpZC...
→ { items: [...], nextCursor: null, hasNext: false }
QueryDSL로 커서 페이지네이션 직접 구현
Spring Data의 Window API가 커버하지 못하는 복잡한 동적 쿼리에는 QueryDSL로 직접 구현할 수 있습니다.
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public CursorPage<Order> findOrders(OrderSearchCondition cond,
CursorRequest cursor, int size) {
QOrder order = QOrder.order;
BooleanBuilder where = new BooleanBuilder();
// 검색 조건
if (cond.getStatus() != null) {
where.and(order.status.eq(cond.getStatus()));
}
if (cond.getMinAmount() != null) {
where.and(order.totalAmount.goe(cond.getMinAmount()));
}
// 커서 조건 (핵심!)
if (cursor != null && cursor.getCreatedAt() != null) {
where.and(
order.createdAt.lt(cursor.getCreatedAt())
.or(order.createdAt.eq(cursor.getCreatedAt())
.and(order.id.lt(cursor.getId())))
);
}
List<Order> results = queryFactory
.selectFrom(order)
.where(where)
.orderBy(order.createdAt.desc(), order.id.desc())
.limit(size + 1) // 1개 더 조회하여 hasNext 판단
.fetch();
boolean hasNext = results.size() > size;
if (hasNext) {
results = results.subList(0, size);
}
return new CursorPage<>(results, hasNext);
}
}
limit(size + 1) 패턴은 추가 카운트 쿼리 없이 다음 페이지 존재 여부를 판단하는 실용적인 기법입니다.
복합 정렬 키 인덱스 설계
Keyset 페이지네이션의 성능은 인덱스 설계에 달려 있습니다. 정렬 순서와 일치하는 복합 인덱스가 없으면 Offset보다 느려질 수 있습니다.
@Entity
@Table(indexes = {
// 커서 페이지네이션용 복합 인덱스
@Index(name = "idx_order_status_created_id",
columnList = "status, created_at DESC, id DESC"),
@Index(name = "idx_order_created_id",
columnList = "created_at DESC, id DESC")
})
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
private BigDecimal totalAmount;
}
주의할 점:
- 정렬 방향(ASC/DESC)이 인덱스와 일치해야 합니다
- 타이브레이커(id)를 반드시 포함하여 유니크한 정렬 순서를 보장합니다
- 파티셔닝된 테이블에서도 파티션 키와 정렬 키를 잘 맞추면 효과적입니다
양방향 커서 (이전/다음)
무한 스크롤이 아닌 "이전 페이지" 기능이 필요한 경우, 역방향 커서도 구현할 수 있습니다.
public record CursorPage<T>(
List<T> items,
String nextCursor,
String prevCursor,
boolean hasNext,
boolean hasPrev
) {}
// 역방향 조회: ORDER BY를 반전하고 결과를 다시 뒤집음
public CursorPage<Order> findOrdersBefore(CursorRequest cursor, int size) {
BooleanBuilder where = new BooleanBuilder();
// 커서보다 이후(더 최신) 데이터 조회
where.and(
order.createdAt.gt(cursor.getCreatedAt())
.or(order.createdAt.eq(cursor.getCreatedAt())
.and(order.id.gt(cursor.getId())))
);
List<Order> results = queryFactory
.selectFrom(order)
.where(where)
.orderBy(order.createdAt.asc(), order.id.asc()) // 반전
.limit(size + 1)
.fetch();
boolean hasPrev = results.size() > size;
if (hasPrev) results = results.subList(0, size);
Collections.reverse(results); // 원래 정렬 순서로 복원
return new CursorPage<>(results, hasPrev);
}
성능 벤치마크
100만 건 orders 테이블에서 PostgreSQL 15 기준 벤치마크 결과입니다.
| 페이지 위치 | Offset (ms) | Keyset (ms) | 개선율 |
|---|---|---|---|
| 1페이지 | 2 | 2 | 동일 |
| 100페이지 | 15 | 2 | 7.5× |
| 1,000페이지 | 120 | 2 | 60× |
| 50,000페이지 | 5,200 | 2 | 2,600× |
Keyset 방식은 페이지 깊이와 무관하게 일정한 2ms를 유지합니다. 데이터가 많을수록 차이는 극적으로 벌어집니다.
실전 팁
- 커서는 불투명하게: 클라이언트가 커서를 조작하지 못하도록 Base64 인코딩 + 선택적 암호화를 적용합니다
- 정렬 키 불변성:
created_at처럼 변경되지 않는 컬럼을 정렬 키로 사용합니다.updated_at은 값이 변하면 같은 레코드가 다른 위치에 나타날 수 있습니다 - null 정렬 주의: nullable 컬럼을 정렬 키로 쓸 경우
NULLS LAST처리가 필요합니다 - 총 개수 쿼리 분리: 커서 방식에서도 "전체 N건" 표시가 필요하면 별도
COUNT(*)쿼리를 캐싱하여 제공합니다 - Offset과 혼용: 관리자 화면은 Offset(페이지 번호), 사용자 피드는 Keyset(무한 스크롤)처럼 용도에 맞게 혼용합니다
마무리
커서 기반 페이지네이션은 대용량 데이터에서 일관된 성능을 보장하는 핵심 기법입니다. Spring Data 3.1+의 Window와 ScrollPosition API를 활용하면 최소한의 코드로 구현할 수 있고, 복잡한 조건에는 QueryDSL로 직접 구현하면 됩니다. 무한 스크롤 UI가 대세인 현재, 커서 페이지네이션은 선택이 아닌 필수입니다.