이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 무한 스크롤 API는 왜 cursor와 잘 맞는가?
- 중복과 누락을 줄이려면 cursor token에 무엇을 담아야 하는가?
- 프론트엔드와 API 계약에서 어떤 점을 합의해야 하는가?
개요
무한 스크롤은 사용자가 다음 목록을 이어서 보는 UX다. 전체 페이지 수나 임의 페이지 이동보다 “다음 20개”가 중요하다. 그래서 offset보다 cursor가 잘 맞는다.
무한 스크롤 API는 DB 쿼리뿐 아니라 cursor token, 정렬 안정성, 새 데이터 삽입 시 동작, 삭제된 데이터 처리까지 계약해야 한다.
원리
무한 스크롤 API의 원리는 “다음 페이지 번호”가 아니라 “마지막으로 본 정렬 위치 이후의 다음 구간”을 안정적으로 반환하는 것이다. 그래서 cursor token은 단순 문자열이 아니라 정렬 기준, tie-breaker, 필터 조건, token 버전이 묶인 API 계약이다.
백엔드 개발자는 DB 쿼리만 맞추면 끝이라고 보면 안 된다. 새 데이터 삽입, 삭제/비공개 처리, cursor 위변조, 정렬 기준 변경, 프론트 캐시 병합 방식까지 합의해야 사용자가 중복과 누락을 덜 겪는다.
기본 SQL
최신순 게시글 목록은 다음처럼 설계할 수 있다.
SELECT id, title, created_at
FROM posts
WHERE board_id = :boardId
AND status = 'PUBLISHED'
AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :size + 1;size + 1개를 조회해 다음 페이지 존재 여부를 판단한다. 응답에는 size개만 반환하고, 마지막 항목으로 next cursor를 만든다.
row value comparison을 풀어 쓰면 다음과 같다.
AND (
created_at < :lastCreatedAt
OR (created_at = :lastCreatedAt AND id < :lastId)
)정렬이 DESC, DESC라면 부등호도 그 방향에 맞아야 한다. 이 방향이 틀리면 다음 페이지가 비거나 이전 항목이 반복된다.
Cursor Token
Cursor에는 정렬 키가 들어간다.
{
"createdAt": "2026-06-30T10:15:00",
"id": 12345
}이를 base64로 인코딩할 수 있다. 보안이나 조작 방지가 필요하면 서명한다. cursor는 클라이언트가 해석하는 값이 아니라 서버가 발급한 opaque token으로 다루는 편이 좋다.
필터 조건이 cursor 발급 시점과 다음 요청에서 달라지면 결과가 깨질 수 있다. boardId, status, 정렬 방식, token version을 cursor에 포함하거나 서버가 별도로 검증해야 한다.
API 응답
응답은 다음처럼 구성한다.
{
"items": [
{ "id": 12345, "title": "DB index", "createdAt": "2026-06-30T10:15:00" }
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTMwVDEwOjE1OjAwIiwiaWQiOjEyMzQ1fQ",
"hasNext": true
}프론트엔드는 다음 요청에 cursor를 그대로 보낸다.
GET /api/posts?boardId=10&cursor=...&size=20서버는 cursor뿐 아니라 size, sort, filter도 검증한다.
int size = Math.min(request.size(), 50);
Cursor cursor = cursorCodec.decodeAndVerify(request.cursor());
if (!cursor.boardId().equals(request.boardId())) {
throw new InvalidCursorException();
}
if (!cursor.sort().equals("created_at_desc_id_desc")) {
throw new InvalidCursorException();
}무한 스크롤은 호출 빈도가 높기 때문에 작은 입력 검증 누락도 비용이 커진다. cursor가 유효해도 현재 요청의 필터와 맞지 않으면 다른 목록의 중간 위치부터 조회하는 버그가 된다.
변경 데이터 처리
무한 스크롤 중 새 게시글이 추가될 수 있다. cursor 방식은 사용자가 본 마지막 위치 이후를 가져오기 때문에 offset보다 흔들림이 적다.
삭제된 게시글은 다음 페이지에서 그냥 빠질 수 있다. 문제는 사용자가 이미 본 항목이 사라지는 것인데, 이는 목록 UX와 도메인 정책으로 처리한다.
무한 스크롤은 보통 전체 스냅샷 일관성을 제공하지 않는다. “새 글 보기” 버튼으로 상단 새 데이터를 따로 불러오게 할지, 현재 스크롤에 자동 삽입할지, 삭제된 항목은 빈칸 없이 제거할지 프론트와 합의한다.
인덱스
쿼리에 맞는 인덱스를 둔다.
CREATE INDEX idx_posts_board_status_created_id
ON posts (board_id, status, created_at DESC, id DESC);상태 필터와 최신순 정렬을 함께 지원한다. 데이터 분포상 status가 거의 항상 PUBLISHED라면 인덱스 순서는 실행 계획으로 검증한다.
실전 팁
- cursor 정렬 키는 응답 정렬과 정확히 일치해야 한다.
- 같은 정렬 값이 가능한 컬럼은 tie-breaker를 둔다.
- cursor token 형식은 버전 변경 가능성을 고려한다.
- 프론트와 “새 글이 위에 추가될 때 기존 스크롤은 어떻게 보일지”를 합의한다.
- 전체 count가 필요하면 별도 가벼운 통계나 지연 계산을 검토한다.
- cursor에는 필터와 정렬 기준 변경을 감지할 수 있는 버전 정보를 둔다.
- next cursor는 반환한 마지막 item 기준으로 만들고, 클라이언트가 임의 생성하지 못하게 한다.
- 새 데이터 삽입/삭제 시 프론트 병합 정책을 API 문서에 남긴다.
- size 상한, 허용 sort, cursor-filter 일치 여부를 서버에서 검증한다.
- cursor decode 실패와 filter mismatch는 400 계열 오류로 빠르게 실패시키고 DB 쿼리까지 보내지 않는다.
위험 신호!
- cursor는 있는데 SQL은 offset을 쓴다.
created_at만 cursor로 사용한다.- 정렬 컬럼이 사용 중 수정된다.
- cursor token을 클라이언트가 직접 조작할 수 있다.
- hasNext를 알기 위해 매번 count를 수행한다.
- cursor 발급 후 필터를 바꿨는데 서버가 검증하지 않아 다른 목록의 중간부터 조회된다.
- next cursor를 마지막 item이 아니라 요청 cursor 기준으로 다시 만들어 같은 페이지를 반복한다.
- 무한 스크롤이 스냅샷 일관성을 제공한다고 제품이 오해한다.
- 잘못된 cursor나 과도한 size 요청도 일단 DB 쿼리로 보내본다.
확인 질문
- 무한 스크롤이 cursor와 잘 맞는 이유는 무엇인가?
- 사용자는 임의 페이지보다 마지막으로 본 항목 이후의 다음 목록을 원하므로 cursor가 자연스럽고 뒤 페이지 비용이 작기 때문이다.
size + 1조회를 하는 이유는 무엇인가?- 실제 반환 개수보다 하나 더 조회해 다음 페이지 존재 여부를 count 없이 판단하기 위해서다.
- Cursor token에 tie-breaker가 필요한 이유는 무엇인가?
- 같은 정렬 값의 행 사이 순서를 안정적으로 만들어 중복과 누락을 줄이기 위해서다.
- cursor에 필터나 정렬 버전 정보가 필요한 이유는 무엇인가?
- cursor가 특정 목록 조건과 정렬 기준에서만 의미 있으므로, 조건이 바뀐 요청에 재사용되면 중복, 누락, 엉뚱한 위치 조회가 발생할 수 있기 때문이다.
- 무한 스크롤 요청에서 DB 쿼리 전에 검증해야 할 것은 무엇인가?
- cursor 서명과 버전, 필터 일치 여부, 허용 sort, size 상한, cursor 부등호 방향을 확인해야 한다.