커서 페이지네이션은 데이터 변경 시 목록을 안정적으로 유지합니다. 삽입과 삭제로 인해 오프셋 페이징이 어떻게 깨지는지, 그리고 깔끔한 커서를 어떻게 구현하는지 알아보세요.

page 3 같은 오프셋을 요청하면, 사용자가 스크롤하는 도중에 페이지 경계가 이동합니다. 결과는 불안정하고 신뢰할 수 없는 피드입니다.\n\n목표는 단순합니다: 사용자가 앞으로 스크롤하기 시작하면 목록은 스냅샷처럼 행동해야 합니다. 새 항목은 존재할 수 있지만 이미 페이징한 항목의 순서를 뒤섞어서는 안 됩니다. 사용자는 부드럽고 예측 가능한 순서를 받아야 합니다.\n\n완벽한 페이지네이션 방법은 없습니다. 실제 시스템에는 동시 쓰기, 수정, 여러 정렬 옵션이 존재합니다. 하지만 커서 페이지네이션은, 오프셋이 이동하는 행 수에 의존하는 대신 정렬된 위치에서 이어가기 때문에 보통 더 안전합니다.\n\n## 오프셋 페이지네이션 1분 요약\n\n오프셋 페이징은 "건너뛰기 N, 가져오기 M" 방식입니다. API에 몇 건을 건너뛸지(offset)와 몇 건을 반환할지(limit)를 알려줍니다. 예를 들어 limit=20이면 한 페이지당 20건을 받습니다.\n\n개념적으로:\n\n- GET /items?limit=20\u0026offset=0 (첫 페이지)\n- GET /items?limit=20\u0026offset=20 (두 번째 페이지)\n- GET /items?limit=20\u0026offset=40 (세 번째 페이지)\n\n응답에는 보통 항목들과 다음 페이지를 요청하는 데 필요한 정보가 포함됩니다.\n\n```json\n{"items": [ {"id": 101, "title": "..."}, {"id": 100, "title": "..."} ], "limit": 20, "offset": 20, "total": 523 } json GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ== json { "items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ], "next_cursor": "...", "has_more": true } ```\n\n서버 측 논리는 간단합니다: 안정적인 순서로 정렬하고, 커서를 사용해 필터링한 뒤 을 적용하세요.\n\n예를 들어 로 최신순 정렬한다면, 커서를 로 디코드해서 쌍이 커서보다 엄격히 작은 행들을 같은 정렬로 가져오고 만큼 취하세요.\n\n### 3) 커서 인코딩: 불투명(opaque)이 낫다\n\n커서는 base64로 인코딩된 JSON 블롭처럼 간단하게 만들거나, 서명/암호화된 토큰처럼 더 안전하게 만들 수 있습니다. 불투명 토큰이 더 안전한 이유는 내부 구조를 나중에 바꿀 수 있기 때문입니다.\n\n또한 기본값을 합리적으로 설정하세요: 모바일 기본값(보통 20~30), 웹 기본값(보통 50), 그리고 버그난 클라이언트가 수천 건을 요청하지 못하도록 서버에서 하드맥스를 두세요.\n\n## 삽입, 삭제, 편집 항목 처리\n\n안정적인 피드는 한 가지 약속에 관한 것입니다: 사용자가 앞으로 스크롤하기 시작하면, 다른 사람이 레코드를 생성·삭제·수정했다고 해서 사용자가 보려던 항목들이 튀지 않아야 합니다.\n\n커서 페이지네이션에서는 삽입이 가장 쉽습니다. 새 레코드는 새로고침에서 보이게 하고, 이미 로드한 페이지 중간에 섞이지 않게 하세요. 로 정렬하면 새 항목은 자연스럽게 첫 페이지 앞에 놓이고 기존 커서는 더 오래된 항목으로 계속 이어집니다.\n\n삭제는 목록을 뒤섞지 않아야 합니다. 항목이 삭제되면 단지 그 항목이 반환되지 않을 뿐입니다. 페이지 크기를 일정하게 유지해야 한다면, 실제로 보이는 개의 항목을 모을 때까지 계속 가져오면 됩니다.\n\n편집은 팀이 실수로 버그를 다시 도입하는 지점입니다. 핵심 질문은: 편집으로 정렬 위치가 바뀔 수 있는가? 입니다.\n\n### 스냅샷 방식과 라이브 방식 중 선택\n\n스냅샷 스타일은 스크롤 목록에 보통 더 적합합니다: 같은 불변 키로 페이지를 나누세요. 내용은 편집될 수 있지만 항목이 새 위치로 점프해서는 안 됩니다.\n\n라이브 피드는 같은 필드로 정렬하면 오래된 항목이 편집되며 위쪽으로 이동하는 등 점프가 발생할 수 있습니다. 이 방식을 선택하면 목록이 계속 변한다고 간주하고 UX를 새로고침 위주로 설계하세요.\n\n### 커서 항목이 더 이상 존재하지 않을 때\n\n커서를 "이 정확한 행을 찾아라"에 의존하지 마세요. 대신 마지막으로 반환한 항목의 처럼 위치 값을 인코딩하세요. 그러면 다음 쿼리는 행 존재 여부에 의존하지 않고 값 기반으로 동작합니다:\n\n- 내림차순인 경우: \n- 항상 같은 타이브레이커를 포함해 중복을 피하세요\n- 마지막 항목이 삭제되어도 값은 여전히 작동합니다\n- 마지막 항목이 편집되었더라도 정렬 키가 불변이라면 여전히 작동합니다\n\n## 뒤로 페이징, 새로고침, 임의 접근\n\n앞으로 페이징은 쉬운 편입니다. 더 까다로운 UX 문제는 뒤로 페이징, 새로고침, 임의 접근입니다.\n\n뒤로 페이징에는 보통 두 가지 접근이 효과적입니다:\n\n- 양방향을 반환하기: (오래된 항목)와 (더 새로운 항목)를 모두 반환하면서 화면 내 정렬은 하나로 유지하기\n- 단일 커서를 유지하되 사용자가 위로 스크롤할 때는 정렬을 반대로 요청하기\n\n커서로는 ‘20페이지’ 같은 랜덤 점프가 안정적 의미를 갖기 어렵습니다. 진짜 점프가 필요하면 ‘이 타임스탬프 주변’ 또는 ‘이 메시지 id부터 시작’ 같은 앵커로 이동한 뒤 그 지점에서 커서로 페이징하세요.\n\n모바일에서는 캐싱이 중요합니다. 쿼리+필터+정렬별로 커서를 저장하고 각 탭/뷰를 별개의 목록으로 취급하세요. 그래야 탭을 전환할 때 목록이 뒤죽박죽 되는 것을 막을 수 있습니다.\n\n## "원인 모를" 버그를 만드는 일반적인 실수\n\n대부분의 커서 페이지네이션 문제는 데이터베이스 때문이 아니라 요청 간의 작은 불일치에서 옵니다. 실사용 트래픽에서만 드러나는 경우가 많습니다.\n\n가장 큰 범인은:\n\n- 고유하지 않은 커서 사용(예: 만 사용)으로 인해 타이에서 중복 또는 누락 발생\n- 실제로 반환한 마지막 항목과 일치하지 않는 반환\n- 페이지 요청 사이에 필터나 정렬을 변경함\n- 동일한 엔드포인트에서 오프셋과 커서를 혼용함\n\nKoder.ai 같은 플랫폼 위에 앱을 올리면 웹과 모바일 클라이언트가 같은 엔드포인트를 공유하는 경우가 많아 이런 엣지케이스가 빨리 드러납니다. 하나의 명확한 커서 계약과 결정론적 정렬 규칙을 갖추면 두 클라이언트가 일관되게 동작합니다.\n\n## 배포 전에 확인할 빠른 체크리스트\n\n페이지네이션을 "완료"라고 부르기 전에 삽입, 삭제, 재시도 상황에서 동작을 확인하세요.\n\n- 정렬이 명시적이며 결정론적이고 타이브레이커를 포함하는가\n- 모든 요청이 동일한 필터와 정렬 필드를 반복하는가\n- 를 실제로 반환한 마지막 행에서 가져오는가\n- 에 안전한 최대값과 문서화된 기본값이 있는가\n- 새 항목 처리(새로고침 동작)가 정의되어 있는가\n\n새로고침에 대해서는 하나의 명확한 규칙을 정하세요: 사용자가 당겨서 새로고침하면 맨 위의 최신 항목을 가져오거나, 아니면 주기적으로 "내 첫 항목보다 최신 항목이 있는가?"를 확인해 "새 항목" 버튼을 보여주는 방식 중 하나를 선택하세요. 일관성이 있어야 목록이 불안정하게 보이지 않습니다.\n\n## 현실적인 예: 장치 간에 안정적으로 유지되는 인박스\n\n웹에서 에이전트가 사용하는 지원 인박스와 모바일에서 매니저가 확인하는 같은 인박스를 생각해보세요. 목록은 최신순이고 사람들은 하나의 기대를 가집니다: 앞으로 스크롤할 때 항목이 점프하거나 반복되거나 사라지지 말아야 한다는 것.\n\n오프셋 페이징에서는 에이전트가 1페이지(1–20)를 로드하고 으로 2페이지를 보려 할 때, 그 사이에 상단에 두 통의 새 메시지가 도착하면 은 이전과 다른 위치를 가리키게 됩니다. 사용자는 중복을 보거나 메시지를 놓칩니다.\n\n커서 페이지네이션에서는 앱이 "이 커서 뒤의 다음 20개"를 요청합니다. 커서는 사용자가 실제로 마지막으로 본 항목(보통 )을 기반으로 합니다. 새 메시지는 계속 들어오더라도 다음 페이지는 여전히 사용자가 본 마지막 메시지 바로 다음부터 시작합니다.\n\n출시 전 간단한 테스트 방법:\n\n- 페이지를 가져오는 동안 스크립트로 초당 새 메시지를 삽입하세요\n- 스크롤 중간에 몇 개의 메시지를 삭제하세요\n- 메시지를 편집하되 정렬 필드는 변경하지 마세요\n- 중복, 빈칸, 또는 순서가 뒤틀리는 일이 없는지 확인하세요\n- 모바일 앱과 웹 앱이 일관된 페이지 경계를 보여주는지 검증하세요\n\n빠르게 프로토타입을 만들고 있다면 Koder.ai는 챗 프롬프트에서 엔드포인트와 클라이언트 플로를 스캐폴딩하고, Planning Mode와 스냅샷/롤백으로 안전하게 반복할 수 있게 도와줍니다.
오프셋 페이지네이션은 "N개 건너뛰기" 방식입니다. 새 행이 삽입되거나 기존 행이 삭제되면 전체 행 수가 바뀌어 같은 오프셋이 이전과 다른 항목을 가리키게 됩니다. 이로 인해 스크롤 중에 항목이 중복되거나 누락됩니다.
커서 페이지네이션은 사용자가 마지막으로 본 항목 뒤의 위치를 나타내는 북마크를 사용합니다. 다음 요청은 그 위치에서 계속 읽기 때문에, 상단에 새 항목이 추가되거나 중간 항목이 삭제되어도 페이지 경계가 이동하지 않아 문제가 줄어듭니다.
created_at과 같은 제품 친화적 정렬값과 id를 결합해 결정론적 정렬을 사용하세요. 일반적으로 (created_at, id) 조합이 좋습니다. created_at은 사용자가 기대하는 순서를 제공하고, id는 동일한 타임스탬프가 여러 건 있는 상황에서 각 위치를 고유하게 만듭니다.
정렬을 updated_at으로 하면 편집에 따라 항목이 페이지 사이를 이동해 스크롤 순서를 깨뜨릴 수 있습니다. 실시간으로 ‘가장 최근에 수정된 것’을 보고 싶다면 UI를 새로고침 중심으로 설계하고 재정렬을 허용하세요. 안정적 스크롤을 원하면 변경불변 키(예: created_at)를 사용하세요.
응답에 불투명한 토큰 형태의 next_cursor를 포함하고, 클라이언트는 이를 그대로 다시 보내야 합니다. 간단한 방법은 마지막으로 반환한 항목의 (created_at, id)를 base64 인코딩한 JSON으로 만드는 것입니다. 중요한 점은 클라이언트가 토큰을 편집하지 않고 그대로 취급해야 한다는 것입니다.
커서 값을 사용해 위치를 정의하면 됩니다. 즉, ‘이 특정 행을 찾아라’가 아니라 마지막 항목의 (created_at, id) 값을 저장해 다음 쿼리에서 그 값보다 작은(혹은 큰) 항목을 요청하세요. 마지막 항목이 삭제되어도 그 값은 위치를 정의하므로 안전하게 계속 진행할 수 있습니다.
엄격한 비교 연산자와 고유한 타이브레이커를 사용하고, next_cursor를 실제로 반환한 마지막 항목에서 생성하세요. 대부분의 중복 버그는 <= 대신 <를 사용하지 않거나 타이브레이커를 누락하거나 잘못된 행에서 커서를 생성할 때 발생합니다.
새로고침은 상단의 최신 항목을 불러오는 동작이고, 스크롤-포워드는 기존 커서에서 오래된 항목 쪽으로 계속 가는 동작입니다. 두 동작의 의미를 섞지 마세요. 새로고침은 최신을 보여주고, 스크롤-포워드는 이미 보던 위치에서 계속해야 합니다.
커서는 특정 정렬과 필터 집합에만 유효합니다. 사용자가 정렬 모드, 검색어, 필터를 바꾸면 새로운 페이지네이션 세션을 시작해야 하며 이전 커서를 재사용하면 안 됩니다. 각 리스트 상태별로 커서를 별도로 저장하세요.
커서 페이지네이션은 순차 탐색에 적합하지만 ‘20페이지로 점프’하는 식의 랜덤 접근에는 적합하지 않습니다. 점프가 필요하면 ‘이 타임스탬프 주변’이나 ‘이 메시지 id 뒤부터’ 같은 앵커로 이동한 뒤 그 지점에서 커서로 페이징하세요.
\n\n오프셋 방식은 테이블, 관리자 목록, 검색 결과, 단순 피드에 자연스럽게 맞아떨어져 인기가 많습니다. SQL의 `LIMIT`와 `OFFSET`으로 쉽게 구현할 수 있다는 장점도 있습니다.\n\n문제는 숨겨진 가정입니다: 데이터셋이 사용자의 페이징 사이에 움직이지 않는다는 것입니다. 실제 앱에서는 새 행이 삽입되고, 삭제되며 정렬 키가 바뀝니다. 바로 그 지점에서 "원인 모를 버그"가 시작됩니다.\n\n## 왜 행이 삽입되거나 삭제되면 오프셋이 깨지는가\n\n오프셋 페이지네이션은 요청 사이에 목록이 고정되어 있다는 가정을 합니다. 그러나 실제 목록은 이동합니다. 목록이 밀리면 `skip 20` 같은 오프셋은 더 이상 같은 항목을 가리키지 않습니다.\n\n예를 들어 `created_at desc`(최신순)으로 정렬하고 페이지 크기 `3`을 사용한다고 합시다.\n\n`offset=0, limit=3`로 1페이지를 로드하면 `[A, B, C]`를 받습니다.\n\n그다음 새 항목 `X`가 생성되어 목록 맨 위에 들어오면 목록은 `[X, A, B, C, D, E, F, ...]`가 됩니다. 이제 `offset=3, limit=3`로 2페이지를 요청하면 서버는 `[X, A, B]`를 건너뛰고 `[C, D, E]`를 반환합니다.\n\n결과적으로 `C`를 중복으로 보게 되고(중복), 나중에는 어떤 항목을 놓치게 됩니다.\n\n삭제는 반대 상황을 만듭니다. 시작이 `[A, B, C, D, E, F, ...]`였고 1페이지에서 `[A, B, C]`를 봤다고 합시다. 그 사이 `B`가 삭제되어 목록이 `[A, C, D, E, F, ...]`가 되면, `offset=3`으로 2페이지를 요청하면 `[A, C, D]`를 건너뛰고 `[E, F, G]`를 반환합니다. 이 경우 `D`가 결측이 되어 버립니다.\n\n최신순 피드에서는 삽입이 맨 위에서 발생하므로 이후의 모든 오프셋이 정확히 그 영향을 받습니다.\n\n## 웹과 모바일에서 "안정적인 목록"이 의미하는 것\n\n"안정적인 목록"은 사용자가 기대하는 동작입니다: 앞으로 스크롤할 때 항목이 튀거나 반복되거나 이유 없이 사라지지 않는 것입니다. 시간 정지 같은 동작이 중요한 게 아니라, 페이징이 예측 가능해야 합니다.\n\n두 가지 개념이 종종 혼동됩니다:\n\n- 안정적 정렬: 명확한 정렬 규칙(예: `created_at`과 `id` 같은 타이브레이커)이 있어 동일한 입력으로 두 요청이 같은 순서를 반환합니다.\n- 안정적 페이징: 사용자가 앞으로 스크롤하기 시작하면 다음 페이지가 사용자가 마지막으로 본 항목에서 이어집니다. 새 항목이 추가되거나 일부가 삭제되어도 이미 본 항목의 순서는 바뀌지 않습니다.\n\n새로고침과 앞으로 스크롤은 다른 행동입니다. 새로고침은 "지금 최신을 보여줘"이고 상단이 바뀌어도 됩니다. 앞으로 스크롤은 "내가 있던 곳에서 계속해"이므로 이미 본 항목 때문에 반복이나 예기치 않은 빈칸이 생기면 안 됩니다.\n\n간단한 규칙 하나로 대부분의 페이지네이션 버그를 막을 수 있습니다: 앞으로 스크롤할 때 항목이 반복되어서는 안 된다.\n\n## 커서 페이지네이션: 간단한 아이디어\n\n커서 페이지네이션은 페이지 번호 대신 북마크로 목록을 순회합니다. "3페이지를 줘"라고 말하는 대신 클라이언트는 "여기에서 계속해줘"라고 말합니다.\n\n계약은 간단합니다:\n\n- 서버는 항목 배치와 마지막 항목 뒤의 위치를 나타내는 커서를 반환합니다.\n- 클라이언트는 그 커서를 다시 보내 다음 배치를 받습니다.\n\n이 방식은 커서가 정렬된 목록의 위치에 고정되기 때문에 삽입과 삭제에 더 관대합니다.\n\n비타협적 요구사항은 결정론적 정렬 순서입니다. 안정적인 정렬 규칙과 일관된 타이브레이커가 필요합니다. 그렇지 않으면 커서는 신뢰할 수 있는 북마크가 되지 못합니다.\n\n## 커서와 정렬 순서 선택하기\n\n먼저 사용자가 그 목록을 어떻게 읽는지에 맞는 정렬을 고르세요. 피드, 메시지, 활동 로그는 보통 최신순(newest first)입니다. 송장이나 감사 로그 같은 기록성 데이터는 오히려 오래된 것부터(오래된순)가 더 자연스러울 수 있습니다.\n\n커서는 그 정렬에서 위치를 고유하게 식별해야 합니다. 두 항목이 같은 커서 값을 가질 수 있다면 결국 중복이나 누락이 발생합니다.\n\n일반적인 선택과 주의점:\n\n- `created_at`만: 간단하지만 동일한 타임스탬프를 가진 행이 많으면 안전하지 않습니다.\n- `id`만: ID가 단조 증가(monotonic)한다면 안전하지만, 원하는 제품 정렬과 다를 수 있습니다.\n- `created_at` + `id`: 보통 가장 좋은 조합입니다(제품 친화적 정렬 + id로 타이브레이크).\n- `updated_at`을 주 정렬로 쓰는 것: 편집으로 인해 항목이 페이지 사이를 이동할 수 있어 무한 스크롤에 위험합니다.\n\n여러 정렬 옵션을 제공한다면, 각 정렬 모드는 서로 다른 목록으로 취급하고 그에 맞는 커서 규칙을 따르세요. 커서는 정확히 한 정렬에 대해서만 의미가 있습니다.\n\n## 단계별: 깔끔한 커서 API 형태\n\nAPI 표면은 작게 유지할 수 있습니다: 입력 2개, 출력 2개.\n\n### 1) 요청: limit + cursor\n\n`limit`(원하는 항목 수)과 선택적 `cursor`(어디서부터 계속할지)를 보냅니다. 커서가 없으면 서버는 첫 페이지를 반환합니다.\n\n예시 요청:\n\n\n\n### 2) 응답: items + next_cursor\n\n항목과 `next_cursor`를 반환하세요. 다음 페이지가 없으면 `next_cursor: null`을 반환합니다. 클라이언트는 커서를 토큰으로 취급하고 편집하지 않아야 합니다.\n\nlimit(created_at DESC, id DESC)(created_at, id)(created_at, id)limitcreated_at DESC, id DESClimitcreated_atedited_at{created_at, id}WHERE (created_at, id) < (:created_at, :id)idnext_cursorprev_cursorcreated_atnext_cursornext_cursorlimitoffset=20offset=20(created_at, id)