무한 API 호출 막기: 커서 페이지네이션 종료 조건
판매 목록 API가 반복 호출된 사례로 커서 페이지네이션의 종료·진행성·중복 제거·안정 정렬을 검증하는 방법을 설명한다.
이 글의 목차당시 기록으로 확인되는 범위12
여러 상품을 한꺼번에 올린 뒤 판매 목록 화면을 열었는데 요청이 끝나지 않았다.
화면은 새 목록을 기다렸고, 네트워크 패널에는 같은 종류의 API가 계속 쌓였다.
API(Application Programming Interface)는 화면과 서버가 정해진 형식으로 데이터를 주고받는 접점이다.
한 번 실패해 다시 요청한 수준이 아니었다.
기존 목록 갱신 조건이 종료 상태에 도달하지 못하면서 다음 호출을 계속 만들고 있었다.
나는 다중 업로드 뒤의 상태 변화부터 목록 요청까지 따라가며 동일 요청이 반복되는 경로를 찾았다.
그리고 끝나는 조건 없이 다시 호출하던 로직을 제거했다.
이 장애에서 가장 오래 남은 질문은 “왜 요청했는가”보다 “언제 멈추기로 했는가”였다.
정상 응답을 받는 코드만 있어도 목록은 한 페이지까지 잘 보인다.
데이터가 여러 페이지가 되고 화면 상태가 갱신되는 순간, 종료 조건과 다음 위치를 명시하지 않으면 정상 응답이 오히려 다음 요청을 무한히 부를 수 있다.
당시 기록으로 확인되는 범위
장애는 Major 등급으로 남아 있다.
Major는 서비스의 중요한 기능을 정상적으로 쓰기 어려워 빠른 복구가 필요한 수준이다.
기록상 시작일은 2025년 10월 14일, 완료일은 10월 22일이다.
별도로 장애시간은 24시간이라고 적혀 있다.
날짜 범위는 8일이므로 발견부터 완료까지의 기간과 실제 사용자 영향 시간이 같은 값은 아니다.
원본 타임라인이 없어서 8일 내내 장애가 지속됐다고도, 정확히 연속 24시간이었다고도 단정하지 않는다.
확인되는 원인과 조치는 다음과 같다.
- 다중 업로드 상태에서 목록 갱신 조건이 종료 상태로 수렴하지 않았다.
- 같은 서버 요청이 반복되는 경로를 추적했다.
- 종료 조건 없이 다시 호출하던 반복 로직을 제거했다.
- 같은 시기에 개수 제한,
count제거, 타임아웃 조정 이력도 있었다. - 정상 요청 횟수의 전후 측정값과 자동 회귀 테스트 결과는 남아 있지 않다.
count 변경과 타임아웃 조정은 같은 시기의 관련 이력이다.
무한 호출을 멈춘 직접 원인이었다고 합치지 않는다.
또 당시 목록 API가 실제로 Relay 방식의 커서를 사용했다는 네트워크 로그도 보존되지 않았다.
Relay Cursor Connections는 GraphQL 목록의 항목과 다음 위치 정보를 일정한 형태로 주고받도록 정한 페이지네이션 규격이다.
따라서 아래 커서 예시는 그날의 코드를 복원한 것이 아니라, 같은 종류의 반복 요청을 지금 예방할 때 적용할 수 있는 공식 규격 기반 설계다.
성공 응답이 반복의 연료가 될 수 있었다
요청이 실패하면 오류 처리와 재시도를 의심하기 쉽다.
이번 기록의 핵심은 목록 갱신 조건이 끝나지 않았다는 데 있다.
응답이 성공할 때마다 “목록이 아직 최신이 아니다”라는 상태가 남고, 그 상태가 다시 같은 요청을 만들 수 있다.
단순화한 실패 형태는 다음처럼 보일 수 있다.
async function loadAll() {
while (true) {
const page = await loadSalesPage();
items.push(...page.items);
}
}while (true)는 내부에서 break, return, 예외 중 하나가 발생할 때까지 반복한다.
이 예시에는 종료와 다음 위치가 모두 없다.
서버가 매번 같은 첫 페이지를 정상으로 반환해도 클라이언트는 그것을 새 데이터처럼 계속 붙인다.
실제 장애 코드가 이 모양이었다는 뜻은 아니다.
목록 갱신 조건이 종료되지 않은 문제를 가장 작은 형태로 보여 주기 위한 예시다.
반복 호출은 화면만 느리게 만들지 않는다.
같은 조회가 DB와 캐시에 계속 도착하고, 모바일 데이터와 배터리를 사용하며, 다른 사용자의 정상 요청까지 밀어낼 수 있다.
업로드 뒤 목록을 다시 읽는 단순한 기능도 서버 전체의 부하 문제로 커질 수 있다.
페이지 번호와 커서는 쓰임이 다르다
많은 데이터를 나눠 받는 방식은 크게 페이지 번호 기반과 커서 기반으로 나눠 볼 수 있다.
페이지 번호 기반은 page=3&size=50처럼 몇 번째 묶음을 원하는지 보낸다.
관리자 표처럼 사용자가 특정 페이지로 바로 이동해야 하고 조회 중 데이터 변화가 적다면 이해하고 구현하기 쉽다.
커서 기반은 “이 항목 다음부터”라는 위치 표식을 서버에 돌려준다.
새 게시물이 계속 추가되는 피드나 상품 목록처럼 데이터가 자주 바뀌고 다음 묶음을 이어 읽는 화면에 잘 맞는다.
| 요구사항 | 페이지 번호·offset | 커서 |
|---|---|---|
| 10페이지로 바로 이동 | 구현하기 쉽다 | 중간 위치의 커서가 없으면 어렵다 |
| 새 데이터가 계속 들어오는 목록 | 항목이 밀려 중복·누락될 수 있다 | 안정적인 정렬과 커서를 쓰면 변화에 대응하기 낫다 |
| 단순한 관리자 목록 | 구조가 명확하다 | 필요 이상으로 복잡할 수 있다 |
| 무한 스크롤·연속 수집 | 뒤로 갈수록 큰 offset 비용이 생길 수 있다 | 마지막 위치부터 이어 읽는다 |
어느 방식을 골라도 종료 조건은 필요하다.
페이지 번호라면 전체 개수나 마지막 페이지 계약을 확인하고, 커서라면 hasNextPage와 다음 커서의 진행성을 확인한다.
커서는 페이지 번호가 아니라 다음 위치의 표식이다
페이지네이션은 많은 결과를 한 번에 보내지 않고 여러 묶음으로 나누어 가져오는 방식이다.
Relay Cursor Connections 규격에서는 앞으로 이동할 때 first와 after를 사용한다.
first는 한 번에 받을 최대 항목 수이고, after는 어느 커서 뒤부터 읽을지 나타낸다.
커서는 클라이언트가 내부 구조를 해석하지 않는 불투명 문자열이다.
불투명하다는 것은 숫자처럼 더하거나 DB ID라고 가정하지 않고 서버가 준 값을 그대로 돌려준다는 뜻이다.
응답의 pageInfo에는 다음 두 값이 중요하다.
hasNextPage: 현재 결과 뒤에 다음 페이지가 있는지endCursor: 현재 페이지의 마지막 항목을 가리키는 커서
기본 순회는 현재 endCursor를 다음 요청의 after로 전달하고, hasNextPage가 false가 되면 멈추는 구조다.
query Sales($first: Int!, $after: String) {
sales(first: $first, after: $after) {
nodes {
id
status
}
pageInfo {
hasNextPage
endCursor
}
}
}GraphQL은 클라이언트가 필요한 필드를 요청 형태로 명시하는 API 질의 방식이다.
$first의 느낌표(!)는 값이 반드시 있어야 한다는 뜻이고, $after는 첫 요청에서는 비워 둘 수 있다.
nodes에는 화면에 사용할 항목을, pageInfo에는 순회를 계속할 판단 정보를 받는다.
응답 배열의 길이가 first보다 작으면 끝이라고 추측하지 않는다.
필터나 권한 때문에 한 페이지가 짧아도 뒤에 데이터가 있을 수 있고, 서버가 내부 제한으로 요청 수보다 적게 반환할 수도 있다.
공식 계약인 hasNextPage를 종료 기준으로 사용한다.
hasNextPage만 봐도 충분하지 않았다
서버가 hasNextPage: true를 주면서 endCursor를 비워 보내거나, 이전과 같은 커서를 반복해서 보내면 클라이언트는 같은 페이지를 다시 요청할 수 있다.
규격상 올바른 서버라면 일어나지 않아야 하지만, 클라이언트가 무한 호출로 서버 오류를 증폭시킬 필요는 없다.
현재 다시 만든다면 커서의 진행성을 별도로 검사한다.
진행성은 반복할 때마다 다음 위치가 이전보다 실제로 바뀌는 성질이다.
async function loadAllSales() {
const items = new Map<string, Sale>();
const seenCursors = new Set<string>();
let after: string | null = null;
while (true) {
const page = await loadSalesPage({
first: 50,
after,
});
for (const sale of page.nodes) {
items.set(sale.id, sale);
}
if (!page.pageInfo.hasNextPage) {
return [...items.values()];
}
const next = page.pageInfo.endCursor;
if (!next || next === after || seenCursors.has(next)) {
throw new Error('페이지 커서가 앞으로 진행하지 않습니다.');
}
seenCursors.add(next);
after = next;
}
}first: 50은 한 요청에서 최대 50개를 받겠다는 선택이다.
50이 모든 서비스의 정답은 아니며 응답 크기와 서버 처리시간을 측정해 정해야 한다.
Map은 같은 sale.id가 다시 오면 이전 값을 덮어써 화면의 중복을 줄인다.
Set은 이미 사용한 커서를 기억한다.
hasNextPage가 거짓이면 정상 종료하고, 다음 커서가 없거나 이전 커서와 같거나 이미 본 값이면 계약 오류로 중단한다.
이 진행성 검사는 Relay 규격의 별도 필드가 아니라 무한 반복을 막기 위해 클라이언트에 추가한 방어 로직이다.
과거 장애의 실제 수정 코드라고 주장하지 않는다.
중복 제거와 멱등성은 다른 문제다
목록에서 같은 ID를 한 번만 보여 주는 것과 서버 작업을 한 번만 실행하는 것은 다르다.
멱등성은 같은 요청을 여러 번 보내도 최종 결과가 한 번 보낸 것과 같도록 만드는 성질이다.
Map으로 중복 화면을 줄여도 업로드 완료 알림이나 정산 요청 같은 외부 부작용이 이미 두 번 실행됐다면 되돌릴 수 없다.
커서 페이지네이션 규격은 목록 순회 방법을 정의하지만 애플리케이션 작업의 멱등성을 자동으로 보장하지 않는다.
조회 뒤 후속 작업을 실행한다면 안정적인 항목 ID와 별도의 처리 완료 키를 사용한다.
서버가 재시도 키를 지원하는 경우 같은 업무 요청에는 같은 키를 보내 중복 처리를 막는다.
재시도 키의 저장 기간과 충돌 정책은 서비스의 데이터 특성에 맞게 정해야 한다.
정렬이 흔들리면 커서가 움직여도 빠질 수 있다
페이지마다 정렬 순서가 달라지면 커서가 새 값이어도 항목이 중복되거나 누락될 수 있다.
예를 들어 createdAt만으로 정렬하는데 같은 시각의 항목이 여러 개라면 DB가 그 사이의 순서를 매번 같게 보장하지 않을 수 있다.
서버에서는 고유 ID를 보조 정렬 키로 포함해 결정적인 순서를 만든다.
결정적 정렬은 같은 데이터와 조건에서 항상 같은 순서가 나오는 정렬이다.
개념적인 SQL은 다음과 같다.
SELECT id, status, created_at
FROM sale
ORDER BY created_at DESC, id DESC
LIMIT 50;ORDER BY created_at DESC, id DESC는 최신 생성 시각부터 정렬하고, 시각이 같으면 고유 ID의 내림차순으로 다시 순서를 고정한다.
LIMIT 50은 최대 50행만 돌려준다.
이 SQL은 커서 조건을 생략한 설명용 예시이며 실제 회사 테이블 이름을 사용하지 않았다.
커서의 인코딩과 동시 쓰기 중 스냅샷을 유지하는 방법은 서버 구현에 따라 다르다.
“커서를 쓰면 중복과 누락이 절대 없다”고 말할 수 없는 이유다.
타임아웃은 종료 조건을 대신하지 않는다
관련 이력에는 타임아웃 조정도 남아 있다.
타임아웃은 요청이나 작업을 일정 시간 이상 기다리지 않고 실패로 끝내는 제한이다.
무한 요청 한 건이 너무 오래 열려 있는 일을 줄일 수 있지만, 실패 뒤 같은 로직이 새 요청을 만들면 전체 반복은 계속된다.
반복 횟수 제한도 마지막 안전망으로는 쓸 수 있다.
예를 들어 최대 1,000페이지 뒤 강제로 멈추면 서버 계약 오류가 전체 서비스를 압박하는 것을 막는다.
하지만 정상 데이터가 그보다 커질 수 있으므로 숫자를 종료의 주 기준으로 삼지는 않는다.
주 종료 조건은 hasNextPage, 방어 조건은 커서 진행성, 마지막 회로 차단은 비정상 최대 반복 수로 나눈다.
재발 방지 테스트는 정상 마지막 페이지부터 시작하지 않는다
끝까지 잘 내려오는 한 사례만 테스트하면 반복 문제를 놓치기 쉽다.
현재의 테스트 목록은 실패하는 계약을 일부러 만든다.
- 첫 페이지에서
hasNextPage: false가 오는 경우 - 여러 페이지 뒤 정상 종료하는 경우
hasNextPage: true인데endCursor가 없는 경우- 같은
endCursor가 두 번 오는 경우 - 이전에 본 커서가 순환해서 다시 오는 경우
- 페이지 사이에 같은 노드 ID가 포함되는 경우
- 요청 중 사용자가 화면을 떠나 취소해야 하는 경우
- 한 페이지가 비어 있지만
hasNextPage: true인 비정상 응답
네트워크 테스트에서는 요청 횟수도 검증한다.
세 페이지 응답을 준비했다면 정확히 세 번 호출하고 끝나는지 확인한다.
오류 상황에서는 다음 요청이 추가로 만들어지지 않고 사용자에게 재시도 가능한 상태를 보여 주는지 본다.
운영에서는 같은 요청의 속도를 본다
무한 요청은 개별 오류 한 건보다 호출 패턴으로 먼저 보일 수 있다.
경로와 사용자 세션 단위로 짧은 시간의 반복 횟수를 관찰한다.
민감한 토큰이나 전체 요청 본문은 로그에 남기지 않는다.
대신 다음 정보를 구조화해 기록한다.
- API 경로와 응답 상태
- 페이지 크기
- 커서의 원문 대신 안전한 해시 또는 반복 여부
hasNextPage- 한 화면에서 발생한 연속 호출 수
- 중단 이유: 정상 종료, 커서 없음, 커서 반복, 사용자 취소
해시는 원문을 그대로 저장하지 않고 같은 값인지 비교할 수 있게 만든 짧은 식별값이다.
정상 트래픽의 분포를 먼저 본 뒤, 한 화면에서 같은 경로가 비정상적으로 반복될 때 경고한다.
당시에는 요청 수의 전후값이 남지 않아 개선률을 계산할 수 없다.
다음에는 장애 전 최대 연속 호출 수와 수정 뒤 정상 페이지 수를 같은 기준으로 남겨야 한다.
결론
판매 목록 장애를 복구하며 제거한 것은 “느린 요청”이 아니라 끝을 찾지 못하는 반복 로직이었다.
기록에는 다중 업로드 상태에서 같은 서버 요청이 반복됐고, 종료되지 않는 재호출을 제거해 조치했다는 사실이 남아 있다.
정확히 어떤 커서 필드가 문제였는지는 증거가 없어 지어내지 않았다.
대신 현재의 커서 페이지네이션에서는 hasNextPage를 정상 종료로, endCursor의 변화를 진행성으로, 안정적인 ID를 중복 방어로 사용한다.
반복 API의 안전성은 요청이 성공했는지가 아니라, 다음 요청이 이전보다 앞으로 갔고 언젠가 반드시 끝나는지를 증명하는 데서 시작한다.