| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- database
- Security
- SRE
- auth
- observability
- PostgreSQL
- version-control
- JavaScript
- react
- Git
- CI
- 버전관리
- Infra
- Kubernetes
- HTTP
- backend
- Performance
- NextJS
- Ops
- aws
- Operations
- architecture
- Debugging
- web
- 성능
- CSS
- reliability
- frontend
- DevOps
- API
- Today
- Total
고민보단 실천을
API Pagination 실전: Offset vs Cursor, 정렬 안정성으로 중복/누락 방지하기 본문
API Pagination 실전: Offset vs Cursor, 정렬 안정성으로 중복/누락 방지하기
페이지네이션은 구현이 쉬워 보여도, 데이터가 계속 변하는 서비스에서는 중복/누락과 성능 문제로 바로 운영 이슈가 된다.
Offset과 Cursor(Seek) 방식의 차이를 비교하고, 정렬 안정성과 커서 설계까지 포함해 바로 적용 가능한 형태로 정리한다.
Offset이 쉬운 만큼 위험한 이유
- OFFSET이 커질수록 앞쪽 행을 더 많이 스캔/버린다.
- 데이터 삽입/삭제가 동시에 일어나면 페이지 사이에 중복/누락이 생긴다.
- 정렬 키가 유일하지 않으면 결과가 흔들린다(createdAt만 정렬 등).
Cursor(Seek) 페이지네이션 기본 쿼리
실무에서는 (createdAt, id)처럼 유일해지는 조합을 정렬 키로 만든다.
-- 정렬 안정성: createdAt + id
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 다음 페이지(커서 이후만)
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < (:cursorCreatedAt, :cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 20;인덱스: Cursor가 빨라지려면
-- PostgreSQL 예시
CREATE INDEX CONCURRENTLY idx_posts_created_at_id_desc
ON posts (created_at DESC, id DESC);Cursor 토큰 설계 팁
커서는 보통 createdAt/id를 묶어 직렬화한다. 토큰은 조작될 수 있으므로, 서버는 파싱/범위/형식 검증을 하고 필요하면 서명(HMAC)으로 위변조를 감지한다.
payload = { createdAt: '2026-04-01T12:34:56.789Z', id: 123456 }
token = base64url(JSON.stringify(payload))
// 민감정보를 넣지 말 것(누구나 디코드 가능)필터가 섞일 때의 규칙
- filter/sort가 바뀌면 cursor는 무효다(서버에서 400).
- cursor에 filter hash를 포함시키는 방식도 있다(토큰에 scope를 넣는 방법).
- 검색 결과는 데이터가 변동되기 쉬우므로 LIMIT을 보수적으로 잡고 캐시 전략을 같이 고려한다.
적용 순서(실무 플로우)
긴 글을 한 번에 다 적용하기보다, 아래 순서대로 '작게' 넣고 관측하면서 키우는 게 실무에서 성공률이 높다.
- 현재 상태를 수치로 확인한다(지표/로그/샘플 트래픽).
- 팀 규칙(키/상태/응답 포맷/설정)을 문서로 고정한다.
- 핵심 경로 1개(가장 중요한 엔드포인트/잡/토픽)부터 적용한다.
- 부하/동시성/실패를 재현하는 테스트를 만든다(운영과 비슷하게).
- 관측(대시보드/알람)을 붙인다: 실패가 '조용히' 넘어가지 않게.
- 점진적으로 확장한다(적용 범위를 넓히기 전에 효과를 확인).
- 배포/롤백 계획을 문서화한다(누가, 언제, 어떤 조건에서 되돌릴지).
- 1~2주 운영 데이터를 보고 규칙/기본값을 재조정한다(처음 값은 대개 틀린다).
운영 체크포인트
- 페이지네이션 파라미터(필터/정렬)가 바뀌면 cursor를 무효 처리한다(서버가 강제하는 게 안전).
- nextCursor가 null인 경우(끝)와 빈 리스트인 경우를 분리해 클라이언트 버그를 줄인다.
- 정렬 키 변경(예: createdAt -> updatedAt)은 API 버전 변경 수준으로 취급한다(호환성 깨짐).
- 대상 테이블이 커지면: 인덱스와 LIMIT 값을 재검토한다(무조건 100은 위험).
- 검색/필터가 붙는 엔드포인트는 explain을 기본으로 확인한다(인덱스 누락이 흔함).
운영 지표/알람 추천
- 에러율(4xx/5xx)과 실패 원인 top N
- 지연(p95/p99)과 타임아웃 비율
- 재시도 횟수/비율(있다면)
- 핵심 비즈니스 지표(성공률 등)와 상관관계
빠른 점검 명령/쿼리
장애가 났을 때 '어디부터 볼지'가 정해져 있으면 대응 속도가 빨라진다. 아래는 팀에서 그대로 템플릿으로 쓰기 좋은 최소 목록이다.
# 지표: 에러율/지연(p95/p99)/타임아웃/재시도 비율 확인
# 로그: correlation id로 요청 1건을 end-to-end 추적
# 설정: 기본값(타임아웃/리밋/락/인덱스)을 문서에서 재확인구조화 로그 필드 추천
- traceId/requestId/eventId 중 하나는 반드시 포함
- endpoint/method/status/latencyMs 같은 기본 필드
- 실패 이유(reasonCode)와 재시도 횟수(retryAttempt)
- 민감정보 마스킹(토큰/비밀번호/개인정보)
{
"level": "INFO",
"message": "request completed",
"requestId": "...",
"method": "POST",
"path": "/api/orders",
"status": 201,
"latencyMs": 123,
"userId": 1004,
"reasonCode": null
}테스트 케이스 샘플
테스트 케이스(최소 3종):
1) 정상: 기대 응답/상태 전이가 맞는지
2) 실패: 입력 오류/다운스트림 오류가 '예상한 에러 포맷'으로 떨어지는지
3) 동시성/재시도: 같은 요청이 2~3번 들어와도 데이터가 깨지지 않는지(멱등성)
추가(가능하면):
- 지연/타임아웃: 느린 상황에서 서킷/타임아웃이 상한을 지키는지
- 재시도 폭주: retry가 장애를 키우지 않는지트레이드오프/대안
- 기본값은 팀/서비스에 따라 달라진다: 숫자는 '정답'이 아니라 '출발점'이다.
- 가용성 vs 보호(fail-open vs fail-closed) 결정을 미루면 장애 때 더 큰 혼란이 온다.
- 관측 없이 최적화하면: 좋아졌는지 나빠졌는지 판단이 안 된다.
- 단순한 구현이 항상 좋은 건 아니다: 운영/디버깅 비용까지 합쳐서 판단해야 한다.
성공 기준(SLO) 예시
- 에러율: 5xx 0.1% 이하(서비스 특성에 맞게)
- 지연: p95 300ms 이하(핵심 API 기준)
- 타임아웃: 전체 요청의 0.01% 이하
- 중복 실행(멱등): 0건(또는 '부작용 0건')
자주 터지는 실수/트러블슈팅
- 정렬 키가 유일하지 않다: tie-breaker(id)가 없어서 페이지 사이에 항목이 튄다.
- cursor에 페이지 번호를 섞는다: 설계가 섞여 결국 OFFSET 비용이 남는다.
- cursor 파싱 에러를 500으로 처리한다: 400으로 명확히 반환한다.
- 필터가 바뀌었는데 이전 cursor를 재사용한다: 서버에서 필터+cursor 묶음 규칙을 강제한다.
바로 적용 템플릿
팀 문서/코드 리뷰에서 바로 복붙할 수 있게 최소 규격을 템플릿으로 남겨두는 게 반복 작업을 줄인다.
Cursor 규격(팀 합의용):
1) 정렬: createdAt DESC, id DESC
2) cursor payload: { createdAt: ISO-8601(Z), id: number }
3) 필터 변경 시 cursor 무효
4) 응답: items, nextCursor, hasNext검증 방법
- 동일 조건으로 1~N 페이지 연속 호출 시 중복/누락이 없는지 자동 테스트한다.
- 동시에 삽입/삭제가 일어나는 상황을 시뮬레이션해도 결과가 안정적인지 확인한다.
- EXPLAIN으로 인덱스가 실제 사용되는지 확인한다(Seq Scan 방지).
장애 대응 Runbook(초안)
- 현상: 무엇이 깨졌나(에러/지연/중복/누락) 한 문장으로 적기
- 범위: 언제부터/어느 사용자/어느 엔드포인트/어느 파티션인지
- 증거: 로그 3줄 + 지표 1개로 재현 가능한 단서 만들기
- 임시 조치: 제한(레이트리밋), 차단(서킷), 롤백/스위치 등
- 근본 원인: 키/정렬/락/타임아웃/재시도 등 어떤 규칙이 깨졌는지
- 재발 방지: 테스트 추가 + 대시보드/알람 + 문서 업데이트
- 후속 조치: 고객 공지/내부 공유(영향 범위, 원인, 재발 방지) 템플릿으로 남기기
리뷰 체크리스트
- 성공/실패 기준이 수치로 정의돼 있다(지표, 임계값).
- 입력 검증/에러 처리가 400/409/429 등으로 명확하다(500 남발 금지).
- 동시성/재시도 상황에서도 데이터가 깨지지 않는다(멱등성/락/유니크).
- 타임아웃이 상한으로 존재한다(무한 대기 금지).
- 부하 증가 시 동작이 예측 가능하다(인덱스/큐/풀 고갈 대비).
- 로그에 상관관계 키(traceId/requestId/eventId)가 있다.
- 설정 기본값이 문서로 남아 있다(왜 이 값인지).
- 롤백/비상 조치(runbook)가 준비돼 있다.
- 테스트에 최소 1개 이상의 실패 케이스가 있다(운영 재현 목적).
- 참고/출처(공식 문서)로 팀이 더 깊게 확인할 경로가 있다.
팀 문서 템플릿
팀 문서 템플릿(복붙용):
1) 목표/배경: 무엇을 해결하나(한 문장)
2) 범위: 적용 대상(엔드포인트/잡/토픽/테넌트)
3) 규칙: 키/정렬/상태/응답 포맷/기본값
4) 예외: 허용하지 않는 케이스(차단/에러 코드)
5) 운영: 지표/알람/대시보드 링크
6) 장애 대응: 임시 조치 + 롤백 절차
7) 변경 이력: 언제/누가/무엇을 바꿨나FAQ(자주 묻는 질문)
Q. 이걸 도입하면 성능이 무조건 좋아지나요?
A. 항상 그렇진 않다. 목표는 보통 '장애 반경 축소'와 '예측 가능성'이다. 성능은 인덱스/타임아웃/풀/캐시처럼 병목을 같이 잡아야 체감이 나온다.
Q. "API Pagination 실전: Offset vs Cursor, 정렬 안정성으로 중복/누락 방지하기"를 적용했는데도 문제가 남아요. 어디부터 봐야 하나요?
A. 먼저 로그/지표로 실패 지점을 좁히고, 설정/키/인덱스 같은 고정 요소부터 점검한다. 재현 가능한 체크리스트를 먼저 만든다.
Q. 운영에서 가장 흔한 실수는요?
A. 규칙을 문서화하지 않고 팀마다 다르게 구현하는 것이다. 같은 이름의 기능이라도 데이터 모델/키/상태 정의가 다르면 장애가 난다.
Q. 최소 도입으로 효과를 보려면?
A. 체크리스트 1~2개만이라도 먼저 적용해 효과가 보이는 변화를 만든 뒤 범위를 넓히는 게 안전하다.
Q. 테스트를 어디까지 해야 하나요?
A. 최소로는 (1) 정상 케이스, (2) 실패 케이스, (3) 동시성/재시도 케이스 3가지는 자동화하는 게 좋다. 운영 이슈의 대부분이 3번에서 나온다.
