JPA 커서 페이지네이션 심화

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+)부터 ScrollPositionWindow 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를 사용합니다. 응답에 nextCursorhasNext를 포함하여 클라이언트가 다음 요청을 구성할 수 있도록 합니다.

@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+의 WindowScrollPosition API를 활용하면 최소한의 코드로 구현할 수 있고, 복잡한 조건에는 QueryDSL로 직접 구현하면 됩니다. 무한 스크롤 UI가 대세인 현재, 커서 페이지네이션은 선택이 아닌 필수입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux