<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Cursor Pagination on jyukki's Blog</title><link>https://jyukki.com/tags/cursor-pagination/</link><description>Recent content in Cursor Pagination on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Mon, 25 May 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/cursor-pagination/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Cursor Pagination Consistency, 변하는 목록에서 중복·누락을 줄이는 기준</title><link>https://jyukki.com/learning/deep-dive/deep-dive-cursor-pagination-consistency-playbook/</link><pubDate>Mon, 25 May 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-cursor-pagination-consistency-playbook/</guid><description>대용량 목록 API에서 offset pagination을 cursor/keyset pagination으로 바꿀 때, 정렬 안정성·동시 변경·커서 토큰·snapshot 기준을 어떻게 설계할지 실무 숫자로 정리합니다.</description><content:encoded><![CDATA[<p>목록 API는 처음에는 단순해 보입니다. <code>page=1&amp;size=20</code>으로 시작하고, 데이터가 많아지면 <code>limit=20&amp;cursor=...</code>로 바꾸면 끝이라고 생각하기 쉽습니다. 하지만 실무에서 어려운 부분은 성능보다 <strong>일관성</strong>입니다. 사용자가 피드, 주문 내역, 알림, 관리자 검색 결과를 넘기는 동안 새로운 데이터가 들어오고 기존 데이터가 수정·삭제됩니다. 이때 같은 항목이 두 번 보이거나, 중간 항목이 빠지거나, 다음 페이지 cursor가 갑자기 무효화되면 API 소비자는 &ldquo;페이지네이션이 느리다&quot;가 아니라 &ldquo;데이터를 믿기 어렵다&quot;고 느낍니다.</p>
<p>기초적인 offset과 cursor 차이는 <a href="/learning/deep-dive/deep-dive-pagination/">페이지네이션과 정렬</a>에서 다뤘습니다. 이 글은 한 단계 더 들어가서 <strong>변하는 목록에서 cursor pagination을 운영 가능한 계약으로 만드는 법</strong>을 정리합니다. 함께 보면 좋은 글은 <a href="/learning/deep-dive/deep-dive-database-indexing/">DB 인덱스 기본</a>, <a href="/learning/deep-dive/deep-dive-partial-covering-index-soft-delete-playbook/">Partial Index와 Covering Index</a>, <a href="/learning/deep-dive/deep-dive-api-versioning/">API 버전 관리</a>, <a href="/learning/deep-dive/deep-dive-bounded-staleness-read-your-writes-playbook/">Bounded Staleness와 Read-Your-Writes</a>입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>offset pagination이 언제 성능 문제가 아니라 정합성 문제로 바뀌는지 판단할 수 있습니다.</li>
<li>keyset pagination에서 stable sort, tie-breaker, cursor token을 어떻게 설계해야 중복·누락을 줄이는지 이해할 수 있습니다.</li>
<li>snapshot cursor, live cursor, read-your-writes 보장을 어느 API에 적용할지 기준을 세울 수 있습니다.</li>
<li>목록 API를 출시하기 전 확인해야 할 인덱스, 필터, 삭제, 토큰 만료, 관측 지표 체크리스트를 만들 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-offset-pagination은-뒤로-갈수록-느려지고-변하는-목록에서는-흔들린다">1) offset pagination은 뒤로 갈수록 느려지고, 변하는 목록에서는 흔들린다</h3>
<p><code>OFFSET 100000 LIMIT 20</code>은 DB가 앞의 10만 건을 건너뛰어야 해서 느립니다. 이 문제만 보면 인덱스나 쿼리 튜닝으로 어느 정도 버틸 수 있습니다. 더 큰 문제는 목록이 변할 때 발생합니다. 예를 들어 최신순 알림 목록에서 사용자가 1페이지를 본 뒤 새 알림 5건이 들어오면, 2페이지의 offset 기준이 밀립니다. 그 결과 이미 본 알림이 다시 나오거나, 원래 봐야 할 알림이 건너뛰어질 수 있습니다.</p>
<p>offset을 계속 써도 되는 기준은 제한적으로 잡는 편이 안전합니다.</p>
<table>
  <thead>
      <tr>
          <th>조건</th>
          <th>판단</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>전체 row 수가 1만 건 이하이고 변경 빈도가 낮음</td>
          <td>offset 유지 가능</td>
      </tr>
      <tr>
          <td>관리자 내부 화면이고 정확한 page number가 중요함</td>
          <td>offset + 검색 조건 고정 검토</td>
      </tr>
      <tr>
          <td>사용자 피드, 알림, 주문 내역처럼 계속 변함</td>
          <td>cursor/keyset 우선</td>
      </tr>
      <tr>
          <td><code>OFFSET</code>이 5만 이상 자주 발생</td>
          <td>keyset 전환 후보</td>
      </tr>
      <tr>
          <td>목록 API p95가 300ms를 넘고 DB CPU가 같이 증가</td>
          <td>인덱스와 pagination 방식 동시 점검</td>
      </tr>
  </tbody>
</table>
<p>즉 &ldquo;몇 페이지까지 갈 수 있나&quot;보다 <strong>정렬 기준 사이에 새 row가 끼어드는가</strong>가 핵심입니다. 변경이 잦은 목록에서 offset은 성능보다 사용자 경험의 일관성을 먼저 망가뜨립니다.</p>
<h3 id="2-cursor-pagination의-핵심은-stable-sort와-tie-breaker다">2) cursor pagination의 핵심은 stable sort와 tie-breaker다</h3>
<p>cursor pagination은 &ldquo;마지막으로 본 위치 이후를 달라&quot;는 방식입니다. 최신순 주문 목록이라면 <code>created_at &lt; last_created_at</code> 조건을 쓰고 <code>ORDER BY created_at DESC LIMIT 20</code>으로 조회합니다. 그런데 <code>created_at</code>만으로는 충분하지 않습니다. 같은 시각에 생성된 주문이 여러 개 있을 수 있고, DB timestamp 정밀도가 애플리케이션 생성 속도를 따라가지 못할 수 있습니다.</p>
<p>그래서 keyset pagination에는 반드시 tie-breaker가 필요합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> id, created_at, status, total_amount
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> orders
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> tenant_id <span style="color:#ff79c6">=</span> :tenantId
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> deleted_at <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> (
</span></span><span style="display:flex;"><span>    created_at <span style="color:#ff79c6">&lt;</span> :cursorCreatedAt
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">OR</span> (created_at <span style="color:#ff79c6">=</span> :cursorCreatedAt <span style="color:#ff79c6">AND</span> id <span style="color:#ff79c6">&lt;</span> :cursorId)
</span></span><span style="display:flex;"><span>  )
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">ORDER</span> <span style="color:#ff79c6">BY</span> created_at <span style="color:#ff79c6">DESC</span>, id <span style="color:#ff79c6">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">LIMIT</span> :limit_plus_one;
</span></span></code></pre></div><p>여기서 <code>created_at DESC, id DESC</code>가 stable order입니다. <code>id</code>는 같은 <code>created_at</code> 안에서 순서를 고정하는 tie-breaker입니다. 반대로 <code>ORDER BY updated_at DESC</code>처럼 수정될 때마다 값이 바뀌는 필드를 기준으로 삼으면 항목이 페이지 사이를 이동합니다. 검색 결과처럼 점수가 변하는 목록도 마찬가지입니다. 이런 경우에는 cursor에 <code>score + id</code>를 넣거나, snapshot 기준을 별도로 둬야 합니다.</p>
<p>출발 규칙은 간단합니다.</p>
<ul>
<li>정렬 컬럼은 가능하면 immutable이어야 한다.</li>
<li>정렬 컬럼이 중복될 수 있으면 unique tie-breaker를 반드시 붙인다.</li>
<li><code>ORDER BY</code>와 <code>WHERE cursor condition</code>의 방향이 정확히 일치해야 한다.</li>
<li>인덱스는 필터 컬럼과 정렬 컬럼 순서에 맞춰 설계한다.</li>
</ul>
<p>예를 들어 위 쿼리에는 <code>(tenant_id, deleted_at, created_at DESC, id DESC)</code> 또는 partial index를 검토합니다. soft delete가 많다면 <a href="/learning/deep-dive/deep-dive-partial-covering-index-soft-delete-playbook/">Partial Index와 Covering Index</a> 기준으로 <code>deleted_at IS NULL</code> 조건을 인덱스에 반영하는 편이 좋습니다.</p>
<h3 id="3-cursor-token은-db-값-노출이-아니라-api-계약이다">3) cursor token은 DB 값 노출이 아니라 API 계약이다</h3>
<p>커서는 단순히 마지막 row id를 넘기는 값이 아닙니다. 목록 API의 계약입니다. 커서에는 다음 페이지를 재현하는 데 필요한 최소 정보가 들어가야 합니다.</p>
<p>권장 필드:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;v&#34;</span>: <span style="color:#bd93f9">1</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;sort&#34;</span>: <span style="color:#f1fa8c">&#34;created_at_desc_id_desc&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;created_at&#34;</span>: <span style="color:#f1fa8c">&#34;2026-05-25T09:50:12.123+09:00&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;id&#34;</span>: <span style="color:#f1fa8c">&#34;ord_918273&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;filter_hash&#34;</span>: <span style="color:#f1fa8c">&#34;sha256:...&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;snapshot_at&#34;</span>: <span style="color:#f1fa8c">&#34;2026-05-25T10:00:00+09:00&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;exp&#34;</span>: <span style="color:#f1fa8c">&#34;2026-05-26T10:00:00+09:00&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>실제 응답에는 이 JSON을 그대로 노출하지 말고 base64url + 서명, 또는 서버 저장형 opaque token으로 제공합니다. 중요한 것은 cursor가 <strong>현재 필터와 정렬 조건에 묶여야 한다</strong>는 점입니다. 사용자가 <code>status=PAID</code> 목록에서 받은 cursor를 <code>status=CANCELED</code> 목록에 쓰면 거부해야 합니다. 그래서 <code>filter_hash</code>가 필요합니다.</p>
<p>토큰 만료도 명시해야 합니다. 일반 목록은 24시간, 민감하거나 빠르게 변하는 검색 결과는 10~60분으로 시작할 수 있습니다. 만료된 cursor는 400 계열 에러와 함께 첫 페이지 재조회 가이드를 줍니다. 조용히 다른 기준으로 이어 붙이면 중복·누락을 디버깅하기 어려워집니다.</p>
<h3 id="4-live-cursor와-snapshot-cursor를-구분해야-한다">4) live cursor와 snapshot cursor를 구분해야 한다</h3>
<p>모든 목록이 같은 일관성을 요구하지 않습니다. 피드나 알림은 새 항목이 계속 들어오는 live view가 자연스럽습니다. 반대로 정산 내역, 감사 로그 export, 관리자 검색 결과는 사용자가 페이지를 넘기는 동안 결과 집합이 고정되는 편이 낫습니다.</p>
<table>
  <thead>
      <tr>
          <th>API 성격</th>
          <th>권장 방식</th>
          <th>기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>소셜 피드, 알림</td>
          <td>live cursor</td>
          <td>최신 데이터 반영 우선, 약간의 이동 허용</td>
      </tr>
      <tr>
          <td>주문 내역</td>
          <td>keyset + read-your-writes 보완</td>
          <td>사용자가 방금 만든 주문은 보여야 함</td>
      </tr>
      <tr>
          <td>정산/감사/export</td>
          <td>snapshot cursor</td>
          <td>누락·중복보다 고정 결과가 중요</td>
      </tr>
      <tr>
          <td>검색 결과</td>
          <td>snapshot 또는 search-after</td>
          <td>relevance score 변동 관리 필요</td>
      </tr>
      <tr>
          <td>관리자 대량 작업</td>
          <td>snapshot + job id</td>
          <td>재현성과 감사 증거 우선</td>
      </tr>
  </tbody>
</table>
<p>snapshot cursor는 <code>snapshot_at</code> 또는 <code>snapshot_version</code>을 기준으로 &ldquo;이 시점까지의 데이터만 보여준다&quot;는 계약입니다. DB가 MVCC snapshot을 장시간 유지하기 어렵다면, 검색 인덱스의 point-in-time 기능, 임시 결과 테이블, export job 방식으로 분리합니다. API 요청 하나에서 긴 snapshot transaction을 유지하는 방식은 위험합니다. DB vacuum, undo/old version 보존, connection 점유 비용이 커질 수 있기 때문입니다.</p>
<p>현실적인 기준은 이렇습니다. 사용자가 3~5페이지 안에서 탐색하는 일반 목록은 live cursor로 충분합니다. 하지만 결과를 업무 증거로 써야 하거나, &ldquo;총 12,431건 중 전체 다운로드&quot;처럼 완전성이 중요한 경우는 snapshot 또는 비동기 export로 분리합니다.</p>
<h3 id="5-삭제와-권한-변경은-cursor-일관성을-흔드는-숨은-변수다">5) 삭제와 권한 변경은 cursor 일관성을 흔드는 숨은 변수다</h3>
<p>목록에서 row가 삭제되거나 사용자의 권한이 바뀌면 커서가 가리키던 위치 주변이 사라질 수 있습니다. soft delete라면 필터 조건이 바뀐 것이고, hard delete라면 tie-breaker row가 아예 없어집니다. 이때 cursor에 row 존재를 의존하면 취약합니다. cursor는 &ldquo;마지막 row를 다시 찾는 키&quot;가 아니라 &ldquo;정렬 공간의 위치&quot;여야 합니다. 즉 <code>created_at</code>, <code>id</code> 값만 있으면 마지막 row가 삭제되어도 다음 범위를 조회할 수 있어야 합니다.</p>
<p>권한 변경은 더 어렵습니다. 1페이지를 볼 때 접근 가능했던 프로젝트가 2페이지 조회 전에 권한 해제될 수 있습니다. 이 경우 보안이 우선입니다. 누락 없는 탐색보다 현재 권한 기준 필터가 먼저입니다. 단, 감사·정산 export처럼 결과 고정이 필요한 작업은 시작 시점 권한과 결과 생성 권한을 별도 receipt로 남기는 편이 맞습니다. 이 관점은 <a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log</a>와도 연결됩니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-목록-api-설계-순서를-고정한다">1) 목록 API 설계 순서를 고정한다</h3>
<p>처음부터 cursor 토큰 포맷을 고민하기보다 아래 순서로 결정합니다.</p>
<ol>
<li>목록의 주 사용 목적: 탐색, 업무 처리, 감사, export 중 무엇인가</li>
<li>정렬 기준: immutable인지, 동점이 얼마나 자주 생기는지</li>
<li>필터 기준: tenant, status, soft delete, 권한 조건이 인덱스에 반영되는지</li>
<li>일관성 수준: live, bounded staleness, snapshot 중 무엇인지</li>
<li>cursor token: version, sort key, tie-breaker, filter hash, 만료</li>
<li>관측 지표: duplicate report, empty page rate, cursor error rate, DB p95</li>
</ol>
<p>이 순서가 중요한 이유는 cursor가 API 모양이 아니라 데이터 접근 계약이기 때문입니다. 정렬과 필터가 불안정한데 토큰만 예쁘게 만들면 문제는 그대로 남습니다.</p>
<h3 id="2-limit--1로-다음-페이지-존재를-판단한다">2) <code>limit + 1</code>로 다음 페이지 존재를 판단한다</h3>
<p><code>COUNT(*)</code>를 매번 계산해 전체 페이지 수를 보여주려 하면 비용이 커집니다. cursor 기반 API에서는 보통 <code>limit + 1</code>개를 조회하고, 하나가 더 있으면 <code>has_next=true</code>와 다음 cursor를 내려줍니다. 예를 들어 클라이언트 limit이 20이면 서버는 21개를 조회합니다.</p>
<p>기본 제한도 필요합니다.</p>
<ul>
<li>기본 limit: 20~50</li>
<li>최대 limit: 일반 API 100, 내부 batch API 500~1,000</li>
<li>cursor token 만료: 일반 24시간, 검색 10~60분</li>
<li>cursor invalid rate가 1%를 넘으면 클라이언트 사용 방식 또는 토큰 호환성 점검</li>
<li>empty page rate가 5%를 넘으면 삭제·권한 변경·filter drift를 점검</li>
</ul>
<p>전체 count가 꼭 필요하면 비동기 집계나 추정치를 별도로 제공합니다. 사용자 목록에서 &ldquo;정확히 12,431페이지&quot;를 보여주기 위해 매 요청마다 count를 때리는 것은 대부분의 서비스에서 비용 대비 가치가 낮습니다.</p>
<h3 id="3-인덱스는-cursor-쿼리와-같이-리뷰한다">3) 인덱스는 cursor 쿼리와 같이 리뷰한다</h3>
<p>cursor pagination은 인덱스가 맞지 않으면 offset보다 나을 게 없습니다. PR 리뷰에서 목록 API가 추가될 때 아래를 같이 확인해야 합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- 예: tenant별 최신 주문 목록
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">INDEX</span> CONCURRENTLY idx_orders_tenant_active_created_id
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">ON</span> orders (tenant_id, created_at <span style="color:#ff79c6">DESC</span>, id <span style="color:#ff79c6">DESC</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> deleted_at <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span>;
</span></span></code></pre></div><p>쿼리 리뷰 기준:</p>
<ul>
<li><code>WHERE</code>의 equality 필터가 인덱스 앞쪽에 있는가</li>
<li><code>ORDER BY</code>가 인덱스 정렬과 같은 방향인가</li>
<li>tie-breaker가 unique하고 정렬 마지막에 있는가</li>
<li>soft delete, status 필터를 partial index로 줄일 수 있는가</li>
<li>covering index가 필요한지, 아니면 row lookup 비용이 허용 가능한가</li>
</ul>
<p>운영에서는 <code>EXPLAIN</code>만 보지 말고 실제 p95, scanned rows, buffer hit, DB CPU를 같이 봅니다. 목록 API 하나가 홈 화면에 붙으면 호출량은 생각보다 빠르게 커집니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, cursor pagination은 임의 페이지 이동이 어렵습니다. &ldquo;37페이지로 바로 가기&quot;가 중요한 관리자 화면에서는 offset이 더 편할 수 있습니다. 이 경우에는 필터를 강하게 제한하고, 큰 offset 접근을 막거나 export job으로 유도하는 방식이 현실적입니다.</p>
<p>둘째, stable sort는 제품 요구와 충돌할 수 있습니다. 사용자는 <code>updated_at</code> 기준 최신 활동순을 원하지만, 수정이 잦은 필드를 cursor 기준으로 쓰면 항목 이동이 심해집니다. 이때는 <code>activity_sequence</code>처럼 append-only 정렬 키를 따로 만들거나, snapshot cursor를 적용해야 합니다.</p>
<p>셋째, cursor token은 버전 관리 대상입니다. 정렬 기준이나 필터 해시 방식이 바뀌면 기존 cursor가 깨질 수 있습니다. 토큰에 <code>v</code>를 넣고, 최소 1개 이전 버전을 해석하거나 명시적으로 만료시키는 정책이 필요합니다. 이 부분은 <a href="/learning/deep-dive/deep-dive-api-versioning/">API 버전 관리</a>와 같은 문제입니다.</p>
<p>넷째, snapshot cursor는 완전성을 주지만 운영 비용이 큽니다. 긴 transaction, 임시 테이블, 검색 인덱스 point-in-time, export job 중 무엇을 쓰든 저장 공간과 만료 정리가 필요합니다. 그래서 모든 목록에 snapshot을 적용하기보다 감사·정산·대량 작업처럼 재현성이 실제 가치가 있는 곳부터 적용하는 편이 낫습니다.</p>
<p>의사결정 우선순위는 <strong>보안 필터 정확성 &gt; 중복·누락 최소화 &gt; p95 지연 &gt; 임의 페이지 이동 편의성 &gt; 정확한 전체 count</strong>입니다. 목록 API에서 편의 기능을 먼저 챙기다 보면 가장 중요한 접근 제어와 데이터 신뢰성이 뒤로 밀립니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="운영-체크리스트">운영 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>ORDER BY</code>에 unique tie-breaker가 포함되어 있다.</li>
<li><input disabled="" type="checkbox"> cursor 조건과 정렬 방향이 정확히 일치한다.</li>
<li><input disabled="" type="checkbox"> cursor token에 version, sort key, filter hash, 만료 시간이 있다.</li>
<li><input disabled="" type="checkbox"> 마지막 row가 삭제되어도 다음 페이지 조회가 동작한다.</li>
<li><input disabled="" type="checkbox"> 권한 조건은 cursor보다 우선 적용된다.</li>
<li><input disabled="" type="checkbox"> <code>limit + 1</code> 방식으로 <code>has_next</code>를 판단한다.</li>
<li><input disabled="" type="checkbox"> 큰 offset 접근을 막거나 별도 export/job 흐름으로 분리한다.</li>
<li><input disabled="" type="checkbox"> 인덱스가 tenant/filter/order/tie-breaker 순서에 맞게 설계되어 있다.</li>
<li><input disabled="" type="checkbox"> cursor invalid rate, empty page rate, duplicate report, DB p95를 모니터링한다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 만든 목록 API 1개를 골라 <code>ORDER BY</code> 컬럼이 immutable인지 확인해 보세요. mutable이면 cursor 기준으로 써도 되는지 반례를 적어 봅니다.</li>
<li><code>created_at DESC</code>만 쓰는 쿼리에 <code>id DESC</code> tie-breaker를 추가하고, 같은 timestamp row 100개를 넣어 중복·누락이 없는지 테스트합니다.</li>
<li><code>status</code>, <code>tenant_id</code>, <code>deleted_at</code> 필터가 있는 목록에 맞는 partial index를 설계하고 <code>EXPLAIN</code>으로 scanned rows 차이를 비교합니다.</li>
<li>cursor token을 JSON으로 먼저 설계해 보세요. <code>v</code>, <code>sort</code>, <code>last_key</code>, <code>filter_hash</code>, <code>exp</code> 5개가 빠지지 않으면 출발점으로 충분합니다.</li>
</ol>
<p>좋은 cursor pagination은 &ldquo;다음 페이지가 빠르다&quot;에서 끝나지 않습니다. 사용자가 페이지를 넘기는 동안 데이터가 바뀌어도, API가 어떤 기준으로 이어 붙이는지 설명할 수 있어야 합니다. 목록은 서비스에서 가장 자주 호출되는 읽기 경로입니다. 작은 중복·누락이 반복되면 신뢰가 먼저 떨어지므로, 처음 설계할 때부터 정렬 안정성과 커서 계약을 함께 잡는 편이 장기적으로 싸게 먹힙니다.</p>
]]></content:encoded></item></channel></rss>