커서 기반 페이징(Cursor-based Pagination)

2025. 5. 25. 22:32·스프링/개념

페이징(Pagination)은 데이터가 많을 때 클라이언트에게 일정 단위로 나눠서 제공하기 위한 기법입니다.

흔히 사용하는 offset 방식은 간단하지만, 성능이나 일관성 면에서 여러 한계가 있습니다.

이 문제를 해결하기 위한 대안으로 커서 기반 페이징(Cursor-based Pagination)이 있습니다.

 

커서 기반 페이징은 조금 더 복잡한 구조를 요구하지만, 데이터 일관성과 성능 면에서 매우 뛰어난 방식입니다.
특히 무한 스크롤 UI나 실시간 목록 등에 매우 적합합니다.
복잡한 쿼리 조건과 커서 정렬 조건을 잘 맞추는 것이 핵심이며, QueryDSL이나 JPA를 활용하면 깔끔하게 구현할 수 있습니다.
필요하다면 미션 상태, 유저, 정렬 기준 등을 다양하게 커스터마이징하여 더 복잡한 조건도 적용할 수 있습니다.

 

1. Offset 기반 페이징의 한계

offset 기반 페이징은 다음과 같은 방식입니다.

SELECT * FROM post ORDERBY created_at DESC LIMIT 10 OFFSET 30;

이 방식은 간단하고 직관적이지만 다음과 같은 문제점이 있습니다.

  • 데이터가 많아질수록 성능 저하 (OFFSET이 커질수록 느려짐)
  • 중간에 데이터가 삽입/삭제되면 순서가 밀려 중복/누락 발생
  • 정확한 커서 위치를 보장하기 어렵다

 

2. 커서 기반 페이징이란?

커서 기반 페이징은 특정 컬럼(정렬 기준) 값을 기준으로 "지금까지 본 마지막 데이터 이후" 를 조회하는 방식입니다.
기본 구조는 다음과 같습니다.

SELECT * FROM post WHERE created_at < '2023-01-01 12:00:00' ORDER BY created_at DESC LIMIT 10;
  • created_at이 마지막으로 본 데이터보다 작으면 → 이후 데이터
  • 정렬 기준이 명확하고, 삽입/삭제에도 영향을 받지 않음
  • 성능이 좋고 중복/누락이 없음

 

3. 커서(Cursor)란?

커서는 "마지막으로 본 데이터의 위치 정보"를 문자열로 압축한 것입니다.
저는 상태, 점수, 미션 ID를 기준으로 했습니다.

예를 들어, 다음과 같이 나타낼 수 있습니다.

DOING:120:345
  • 상태(MissionStatus): DOING
  • 점수(points): 120
  • 미션 ID(id): 345

이 커서를 기준으로 정렬 기준에 따라 그 뒤 데이터를 불러오게 됩니다.

 

4. 커서 문자열 생성

"상태:점수:ID" 형태로, 다음 2가지 방식 중 하나로 생성할 수 있습니다.

(1) Java 코드에서 생성

String cursor = String.format("%s:%d:%d", status, points, id);

(2) QueryDSL 쿼리에서 직접 생성

private Expression<String> generateCursor(EnumExpression<MissionStatus> status, NumberExpression<Integer> points, NumberExpression<Long> id) {
    return Expressions.stringTemplate(
          "CONCAT({0}, ':', {1}, ':', {2})",
          status.stringValue(), points.stringValue(), id.stringValue()
    );

→ 이 방식은 DB 쿼리에서 커서 문자열을 바로 만들어 결과 DTO에 넣는 방식입니다.

 

5. 커서 파싱

커서 파싱은 어떻게 이뤄지는가?

클라이언트가 서버로 커서 기반 페이징 요청을 보낼 때,
이전 응답에서 받은 커서 문자열을 쿼리 파라미터로 함께 전송합니다.

예를 들어 다음과 같은 요청이 있다고 가정해보겠습니다.

GET /missions?cursor=DOING:120:345

이때 서버는 cursor=DOING:120:345 라는 문자열을 받아서,
다시 내부에서 사용할 수 있는 객체(MissionCursor)로 변환해야 합니다.
이 과정을 커서 파싱(cursor parsing) 이라고 합니다.

 

커서 파싱은 언제 수행되나?

실제로는 쿼리 실행보다 먼저, 즉 컨트롤러 → 서비스 계층으로 요청이 전달될 때
가장 먼저 수행됩니다.

public List<MissionDto> getUserMissions(String username, String rawCursor, int limit) {
    MissionCursor cursor = MissionCursor.parse(rawCursor); // 여기서 파싱
    return userMissionRepository.findUserMissions(username, cursor, limit, ...);
}

 

커서 파싱 코드 예시

public record MissionCursor(MissionStatus status, int points, Long id) {

    public static MissionCursor parse(String rawCursor) {
        if (rawCursor == null || rawCursor.isEmpty()) return null;

        String[] parts = rawCursor.split(":");
        if (parts.length != 3) {
            throw new IllegalArgumentException("Invalid cursor format");
        }

        return new MissionCursor(
            MissionStatus.valueOf(parts[0]),
            Integer.parseInt(parts[1]),
            Long.parseLong(parts[2])
        );
    }
}

 

이 parse() 메서드는 다음과 같은 작업을 합니다.

parts[0] 미션 상태 (예: DOING, DONE 등)
parts[1] 미션 점수 (points)
parts[2] 미션 ID (고유 식별자)

이렇게 파싱된 MissionCursor 객체는 이후 QueryDSL 조건절에 활용되어
WHERE 조건을 정확히 생성할 수 있게 해줍니다.

 

6. 정렬 방향과 커서 비교 조건

커서 페이징에서 가장 중요한 부분은 정렬 방향과 커서 비교 조건을 일치시키는 것입니다.

예를 들어 다음과 같은 정렬이 있다고 가정해보겠습니다.

정렬 기준

.orderBy(mission.points.desc(), mission.id.desc())

이 경우, 커서 조건은 다음과 같아야 합니다.

WHERE mission.points < :cursorPoints OR (mission.points = :cursorPoints AND mission.id < :cursorId)

즉, 더 작은 점수 또는 동점일 경우 더 작은 id가 다음 페이지로 간주됩니다.

반대로 정렬이 ASC일 경우에는 비교 연산자도 반대로 설정해야 합니다.

7. QueryDSL로 구현하는 방법

1. 커서 조건 설정

if (cursor != null) {
    builder.and(
        mission.points.lt(cursor.points())
            .or(
                mission.points.eq(cursor.points())
                    .and(mission.id.lt(cursor.id()))
            )
    );
}

2. 정렬 기준

.orderBy(mission.points.desc(), mission.id.desc())

3. 커서 문자열 생성 (쿼리 내에서)

Expressions.stringTemplate( "CONCAT({0}, ':', {1}, ':', {2})", status.stringValue(), points.stringValue(), id.stringValue() )
 

8. 클라이언트 ↔ 서버 흐름 요약

  1. 클라이언트가 처음 요청: GET /missions?cursor=
  2. 서버는 미션 10개 + 커서 문자열 응답
  3. 클라이언트는 응답의 커서로 다음 요청: GET /missions?cursor=DOING:120:345
  4. 서버는 커서를 파싱하고 그 이후 데이터 반환

 

아직 기능만 작성한 상태라 프로젝트에서 작동하는 걸 보고싶네용...

'스프링 > 개념' 카테고리의 다른 글

[빈 시리즈-2] 스프링은 언제 의존관계를 주입하는가  (0) 2026.01.12
[빈 시리즈-1] 스프링 빈은 어떻게 등록되는가  (1) 2026.01.09
싱글톤 패턴의 한계와 싱글톤 컨테이너  (1) 2026.01.08
'스프링/개념' 카테고리의 다른 글
  • [빈 시리즈-2] 스프링은 언제 의존관계를 주입하는가
  • [빈 시리즈-1] 스프링 빈은 어떻게 등록되는가
  • 싱글톤 패턴의 한계와 싱글톤 컨테이너
hissic
hissic
더 나은 내일을 향해~!! 아자아자화이팅
  • hissic
    터벅터벅 나의 코딩일지
    hissic
  • 전체
    오늘
    어제
    • 분류 전체보기 (10) N
      • 스프링 (6) N
        • 개념 (4) N
      • 트러블슈팅 (3)
      • 도커 (0)
      • 회고 (0)
      • 프로젝트 (1) N
        • 숨틈 (0)
        • 원스어폰타임 (1) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
hissic
커서 기반 페이징(Cursor-based Pagination)
상단으로

티스토리툴바