Cursor-based Pagination

ํ™ฉ์—ฐ์ค€ยท2025๋…„ 12์›” 7์ผ

1. Cursor ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์ด๋ž€?

ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ๋งˆ์ง€๋ง‰ ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋‹ค์Œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋ฐฉ์‹

  • ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ(page=1, 2, 3...)๋ฅผ ์‚ฌ์šฉํ•˜๋Š” Offset ํŽ˜์ด์ง€๋„ค์ด์…˜๊ณผ ๋‹ฌ๋ฆฌ
  • ํŠน์ • ์ง€์ (cursor)์„ ๊ธฐ์ค€์œผ๋กœ ๋ฐ์ดํ„ฐ์˜ โ€œ๋‹ค์Œโ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐฉ์‹.

2. Offset(Page ๋ฒˆํ˜ธ) ๊ธฐ๋ฐ˜

Offset ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง• (page=1,2,3)

์˜ˆ: LIMIT 10 OFFSET 30 (4๋ฒˆ์งธ ํŽ˜์ด์ง€)

๋ฌธ์ œ์ :

  • ๋ฐ์ดํ„ฐ๊ฐ€ ์ค‘๊ฐ„์— ์‚ฝ์ž…/์‚ญ์ œ๋˜๋ฉด ํŽ˜์ด์ง€๊ฐ€ ํ”๋“ค๋ฆผ
  • OFFSET์ด ์ปค์งˆ์ˆ˜๋ก ๋А๋ฆผ (OFFSET 1000000 ๊ฐ™์€ ๊ฒฝ์šฐ ๋งค์šฐ ๋น„ํšจ์œจ์ )

Cursor ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง•

์˜ˆ:cursor=100 โ†’ ID 100 ์ดํ›„์˜ 10๊ฐœ๋ฅผ ๊ฐ€์ ธ์™€๋ผ

ํŠน์ง•:

  • ๋ณ€๊ฒฝ์— ๊ฐ•ํ•จ (์ค‘๊ฐ„ ๋ฐ์ดํ„ฐ๊ฐ€ ์ถ”๊ฐ€๋ผ๋„ ์•ˆ์ •์ )
  • ๋น ๋ฆ„ (ํŠน์ • ID ๊ธฐ์ค€์œผ๋กœ Range Scan๋งŒ ํ•˜๋ฉด ๋จ)
  • ํŠธ์œ„ํ„ฐ/์ธ์Šคํƒ€/์œ ํŠœ๋ธŒ ๋ฌดํ•œ์Šคํฌ๋กค์ด ๋‹ค cursor ๋ฐฉ์‹

3. Cursor ๋ฐฉ์‹์˜ ํ•ต์‹ฌ ๊ฐœ๋…

โœ” Cursor = "๋งˆ์ง€๋ง‰ ๋ฐ์ดํ„ฐ์˜ ๊ณ ์œ  ๊ฐ’"

๋ณดํ†ต ์•„๋ž˜ ์ค‘ ํ•˜๋‚˜๋ฅผ cursor๋กœ ์‚ฌ์šฉํ•จ:

  • auto-increment PK (์˜ˆ: id)
  • createdAt (์ •๋ ฌ ๊ธฐ์ค€)
  • ๋ณตํ•ฉํ‚ค (createdAt + id)

โœ” ์š”์ฒญ ์‹œ ๋‘ ๊ฐ’์ด ํ•„์š”ํ•จ

  • size : ๋ช‡ ๊ฐœ ๊ฐ€์ ธ์˜ฌ์ง€
  • cursor : ์ด์–ด์„œ ๊ฐ€์ ธ์˜ฌ ์ง€์  (null์ด๋ฉด ์ฒซ ํŽ˜์ด์ง€)

4. ๋™์ž‘ ํ๋ฆ„

โ–ท ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ฒซ ์š”์ฒญ ๋ณด๋ƒ„

GET /posts?size=10

์„œ๋ฒ„ ์‘๋‹ต:

{
  "data": [...], // 10 items
  "nextCursor": 150
}

โ–ท ๋‹ค์Œ ์š”์ฒญ

GET /posts?size=10&cursor=150

์„œ๋ฒ„๋Š” DB์—์„œ:

WHERE id < 150
ORDER BY id DESC
LIMIT 10;

์ด๋Ÿฐ ์‹์œผ๋กœ ๊ฐ€์ ธ์˜ด.


5. JPA + Querydsl๋กœ cursor pagination ๊ตฌํ˜„ ์˜ˆ์‹œ

Post ์—”ํ‹ฐํ‹ฐ ๊ธฐ์ค€

โ‘  Repository Custom ์ •์˜

public interface PostRepositoryCustom {
    Slice<Post> findByCursor(Long cursor, int size);
}

โ‘ก ๊ตฌํ˜„์ฒด

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {

    private final JPAQueryFactory query;

    @Override
    public Slice<Post> findByCursor(Long cursor, int size) {

        QPost post = QPost.post;

        List<Post> result = query
            .selectFrom(post)
            .where(
                cursor != null ? post.id.lt(cursor) : null
            )
            .orderBy(post.id.desc())
            .limit(size + 1) // ๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
            .fetch();

        boolean hasNext = result.size() > size;

        if (hasNext) {
            result.remove(size);
        }

        return new SliceImpl<>(result, PageRequest.of(0, size), hasNext);
    }
}

cursor ๋ฐฉ์‹์—์„œ limit(size + 1) ๋กœ ๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จ.
๋งŒ์•ฝ, ๋‹ค์Œ ํŽ˜์ด์ง€๊ฐ€ ์žˆ์œผ๋ฉด result.size() > size


6. API ์‘๋‹ต ํ˜•ํƒœ ์˜ˆ์‹œ

{
  "posts": [...10๊ฐœ...],
  "nextCursor": 123,
  "hasNext": true
}

7. Cursor ํŽ˜์ด์ง•์ด ์ข‹์€ ์ด์œ 

โœ” ์•ˆ์ •์ 

์ƒˆ ๊ธ€์ด ์ถ”๊ฐ€๋˜์–ด๋„ ๊ฒฐ๊ณผ ์ˆœ์„œ๊ฐ€ ํ”๋“ค๋ฆฌ์ง€ ์•Š์Œ.

โœ” ๋งค์šฐ ๋น ๋ฆ„

WHERE id < 150
์ •๋ ฌ + Range Scan๋งŒ ํ•˜๋ฉด ๋˜์–ด OFFSET 1000000 ๋ณด๋‹ค ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ๋ฐฐ ๋น ๋ฆ„

โœ” ๋ฌดํ•œ ์Šคํฌ๋กค์— ์ตœ์ ํ™”

NextCursor๋งŒ ๋ณด๋‚ด๋ฉด ๋˜๋‹ˆ๊นŒ.


Cursor ๋ฐฉ์‹์ด ๋ถˆ๋ฆฌํ•œ ๊ฒฝ์šฐ

  • ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ ๊ธฐ๋ฐ˜ UI๊ฐ€ ํ•„์š”ํ•  ๋•Œ (์˜ˆ: 3ํŽ˜์ด์ง€๋กœ ๋ฐ”๋กœ ์ด๋™)
  • ์ •๋ ฌ ์กฐ๊ฑด์ด ์—ฌ๋Ÿฌ ๊ฐœ์ผ ๋•Œ (PK ์™ธ์—๋„, ๋ณตํ•ฉ ์ •๋ ฌ์ด ๋งŽ์œผ๋ฉด ๊ตฌํ˜„ ๋ณต์žกํ•ด์ง)

8. ๋น„๊ต

๋ฐฉ์‹์žฅ์ ๋‹จ์ 
Offset(page)๊ตฌํ˜„ ์‰ฌ์›€, ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ ์ ‘๊ทผ ๊ฐ€๋Šฅ๋А๋ฆผ, ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ์— ์ทจ์•ฝ
Cursor๋น ๋ฅด๊ณ  ์•ˆ์ •์ , SNS์— ์ตœ์ ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ ์ด๋™ ๋ถˆ๊ฐ€, ๋ณตํ•ฉ์ •๋ ฌ ์–ด๋ ค์›€

0๊ฐœ์˜ ๋Œ“๊ธ€