Foundry
API 설계
중급
핵심

페이지네이션: offset vs cursor

10만 번째 페이지에서 느려지는 이유

페이지네이션 (offset vs cursor)

두 방식 비교

항목OffsetCursor (keyset)
쿼리LIMIT 20 OFFSET 100000WHERE id < :cursor LIMIT 20
깊은 페이지건너뛸 행을 읽고 버린다 -> 느려짐인덱스 진입 1회, 일정
임의 페이지 이동가능다음과 이전만
목록 변동 중항목 중복과 누락 발생안정
총 개수제공 쉬움별도 집계 필요

커서 설계

정렬키가 유일하지 않으면 타이브레이커를 함께 넣는다
ORDER BY created_at DESC, id DESC
WHERE (created_at, id) < (:ts, :id)
  • 커서는 내부 구조를 감춘 불투명 문자열로 노출한다(추후 정렬키 변경 여지 확보)
  • 응답에 next_cursor와 다음 페이지 존재 여부를 담는다

실무 포인트

  • 피드와 무한 스크롤은 커서, 페이지 번호가 필요한 관리자 화면은 offset이 현실적이다
  • COUNT(*)가 목록 조회보다 비쌀 수 있다. 근사치나 상한 표기(1000+)로 대체를 검토한다
  • 페이지 크기 상한을 서버가 강제한다. 클라이언트가 보낸 limit을 그대로 신뢰하지 않는다
  • 정렬 기준을 바꿀 수 있는 API는 커서 호환성이 깨지므로 정렬 조건도 커서에 포함한다
면접에서 이렇게 나옵니다

Q.커서 값을 클라이언트에 그대로 노출하면 어떤 문제가 있나요?

커서에 정렬 키 값이 그대로 담기면 내부 구조를 노출하고 나중에 바꿀 수 없게 됩니다.

문제내용
구조 노출created_at=2026-08-14&id=1234 가 보이면 내부 정렬 방식이 드러난다
조작 가능값을 바꿔 보내면 의도하지 않은 범위를 조회할 수 있다
변경 불가정렬 기준을 바꾸면 옛 커서가 깨진다
권한 우회다른 사용자의 커서를 써서 접근 범위를 넘볼 수 있다
정보 유출id 값 자체가 전체 규모나 순번을 알려준다

그래서 불투명한 커서로 만듭니다. 필요한 값을 인코딩해 하나의 문자열로 주고, 클라이언트는 그것을 그대로 되돌려 보냅니다.

처리내용
인코딩정렬 키와 방향, 필터 조건을 담아 base64 로
서명변조를 막으려면 해시를 함께 담는다
버전커서 형식을 바꿀 수 있게 버전을 넣는다
필터 포함첫 요청의 필터를 담아 두면 도중에 조건이 바뀌는 것을 막는다

마지막이 실용적입니다. 커서에 필터를 담으면 클라이언트가 페이지마다 같은 필터를 보내는지 검증할 수 있습니다.

흔한 실수: base64 인코딩을 보안으로 여기는 것. 누구나 디코딩할 수 있어서 노출은 막지 못합니다. 조작을 막으려면 서명이 필요합니다.

Q.커서 기반 페이지네이션에서 정렬키가 중복될 때 어떤 문제가 생기나요?

같은 값이 여러 개면 어디까지 봤는지 특정할 수 없어 항목이 빠지거나 중복됩니다.

created_at 하나로 커서를 만든 경우입니다.

상황결과
같은 시각에 3건이 있고 페이지 경계가 그 사이에 걸린다
WHERE created_at < 마지막값같은 시각의 나머지 2건을 건너뛴다
WHERE created_at <= 마지막값이미 본 1건이 다시 나온다

어느 쪽으로도 정확하지 않습니다. 등호를 넣으면 중복, 빼면 누락입니다.

해결은 고유한 값을 정렬 키에 더하는 것입니다.

항목내용
정렬ORDER BY created_at DESC, id DESC
조건WHERE (created_at, id) < (마지막시각, 마지막id)
인덱스그 컬럼 조합으로 만든다

두 값을 묶어 비교하면 순서가 완전히 결정됩니다. 같은 시각이어도 id 로 갈리므로 경계가 애매해지지 않습니다.

주의할 것이 하나 있습니다. 이 비교를 created_at < ? OR (created_at = ? AND id < ?) 로 풀어 쓰면 인덱스를 제대로 못 타는 경우가 있어, 실행 계획을 확인해야 합니다.

흔한 실수: 정렬 키가 유일하다고 가정하는 것. 타임스탬프는 초 단위 저장이거나 대량 삽입이면 쉽게 겹칩니다. 정렬 키에 고유 컬럼을 항상 함께 두는 편이 안전합니다.

Q.무한 스크롤 중 새 글이 등록되면 offset 방식에서 무슨 일이 벌어지나요?

앞쪽에 항목이 끼어들면서 뒤로 밀린 항목을 다시 보게 됩니다.

순서상황
11페이지로 최신 10건을 본다
2새 글 3건이 등록된다
3OFFSET 10 으로 2페이지를 요청한다
4새 글 때문에 모든 항목이 3칸 밀렸다
51페이지에서 본 8, 9, 10번째 글이 다시 나온다

반대 방향도 생깁니다. 글이 삭제되면 항목이 앞으로 당겨져 못 본 항목이 건너뛰어집니다. 이쪽이 더 나쁩니다. 사용자는 중복은 알아채지만 누락은 모릅니다.

커서 방식은 이 문제가 없습니다. 위치를 번호로 세지 않고 마지막으로 본 항목 자체를 기준으로 하기 때문입니다. 앞에 몇 건이 추가되든 "이 글보다 오래된 것"의 답은 변하지 않습니다.

대응내용
커서 기반으로 전환근본 해결
첫 요청 시각 고정WHERE created_at <= 조회시작시각 을 계속 붙인다
클라이언트 중복 제거id 로 걸러낸다. 누락은 못 막는다

두 번째는 offset 을 유지하면서 쓸 수 있는 우회입니다. 스크롤을 시작한 시점 이전 데이터만 보게 고정하면 새 글이 끼어들지 않습니다.

흔한 실수: 중복만 보고 클라이언트에서 걸러 해결했다고 보는 것. 누락은 그대로 남아 있고, 사용자는 그것을 알 수 없습니다.

Q.전체 개수를 함께 제공해야 한다면 어떻게 설계하겠습니까?

전체 개수 계산은 조건에 맞는 행을 모두 세는 일이라 데이터가 많으면 그 자체가 느립니다. 그래서 정말 필요한지부터 봅니다.

요구대안
정확한 총 개수별도 요청으로 분리해 첫 페이지 응답을 막지 않는다
대략적인 규모추정치를 쓴다. 실행 계획의 예상 행수나 통계
다음 페이지 유무만한 건 더 조회해 존재만 확인한다
상한 표시1000+ 처럼 일정 개수까지만 세고 넘으면 표시만 바꾼다

세 번째가 가장 값싸고 대부분의 화면에 충분합니다. 페이지 크기 20이면 21건을 조회해 21번째가 있으면 다음 페이지가 있다고 판단하고 20건만 돌려줍니다.

네 번째는 검색 엔진들이 쓰는 방식입니다. 1,000건까지만 세고 그 이상은 정확히 세지 않습니다. 사용자도 정확한 숫자를 필요로 하지 않습니다.

성능을 얻는 방법내용
캐시개수를 짧게 캐시한다. 몇 초 지난 값이어도 된다
집계 테이블자주 쓰는 조건의 개수를 미리 관리한다
근사치통계 기반 추정

흔한 실수: 목록 조회와 개수 조회를 한 트랜잭션에서 함께 하는 것. 개수 계산이 느리면 목록도 그만큼 늦게 나갑니다. 개수를 나중에 채우면 사용자는 목록을 먼저 봅니다.

먼저 스스로 답해보고 아래 답변과 견줘보세요. 막히는 부분은 문제로 확인할 수 있어요.

읽었으면 문제로 확인해보세요

API 설계 문제를 풀면 틀린 문제가 자동으로 노트에 쌓입니다. 가입 없이 5문제를 먼저 풀어볼 수도 있어요.