커서 페이지네이션으로 바꿨더니 왜 중복과 타임아웃이 동시에 터졌을까?

결론부터 적으면, 원인은 두 곳이었다. 하나는 커서를 만드는 정렬 기준이 유일하지 않았던 것, 다른 하나는 커서 방식으로는 원래부터 지원되지 않는 "총 개수 + 임의 페이지 이동"을 관리자 리포트 화면에 그대로 남겨둔 것이다. 커서 페이지네이션 자체의 결함이 아니라, 화면별 요구사항을 구분하지 않고 한 가지 방식으로 밀어붙인 게 문제였다.

  • 무한스크롤 피드에서 같은 게시글이 두 번 보이는 증상은 커서 인코딩에 tie-breaker가 없어서 생겼다
  • 관리자 리포트의 502는 커서 방식이 애초에 총 개수·임의 점프에 약하다는 구조적 한계 때문이었다
  • GitLab 문서는 커서를 정렬 필드로 인코딩한다고 설명하는데, 이때 정렬 필드가 유일해야 한다는 전제가 빠지기 쉽다 (GitLab GraphQL 문서)
  • Totara 문서는 커서 페이지네이션에서 전체 결과 수의 가치가 낮아지고 임의 페이지 이동이 안 된다고 명시한다
  • 재발 방지는 "화면 성격에 따라 커서와 오프셋을 나눠 쓰는 것"으로 끝났다

전환 배경은 단순했다. 기존 REST 목록 API가 offset, page, limit을 섞어 쓰면서 페이지 30 근처에서 새 글이 계속 끼어드는 피드 화면에서 항목이 밀리거나 사라지는 민원이 반복됐다. GraphQL로 옮기면서 pageInfo.startCursor/endCursor와 after/before를 쓰는 커서 방식으로 바꿨고, 1주일 뒤 두 가지 증상이 동시에 보고됐다. 피드에서 같은 게시글이 스크롤 중 두 번 나타난다는 사용자 제보, 그리고 어드민 대시보드의 "전체 게시글 리포트" 탭이 간헐적으로 502를 뱉는다는 내부 알림이었다.

무한스크롤에서 게시글이 두 번 보이는 원인은 뭐였을까?

원인은 커서를 created_at 단일 필드로만 인코딩한 데 있었다. 서버 시각 기준으로 같은 초, 심지어 같은 밀리초에 여러 게시글이 생성되는 케이스가 하루 평균 40~60건 정도 있었는데, 이 구간에서 커서가 가리키는 "다음 위치"가 모호해졌다. after 커서로 다음 페이지를 요청하면 정렬이 안정적이지 않아 방금 받은 항목이 다음 응답에도 다시 포함되는 경우가 생겼다.

GitLab 문서는 커서를 정렬 필드로 인코딩해 만든다고 설명하지만, 그 필드가 유일하지 않으면 같은 정렬값을 가진 레코드들 사이에서 순서가 흔들릴 수 있다는 전제는 문서에 별도로 강조되지 않는다. Totara 쪽 설명이 이 부분을 더 직접적으로 짚는데, 커서 기반 페이지네이션은 "순차적이고 유일한 컬럼(또는 조합)"에 의존해야 한다고 명시한다. 우리는 이 전제를 놓쳤다.

관리자 리포트 화면이 502를 뱉은 이유는 뭐였을까?

원인은 커서 방식 자체가 총 개수·임의 페이지 점프에 맞지 않는데도 리포트 화면 요구사항을 그대로 남겨둔 것이다. 어드민 대시보드는 "12페이지로 바로 이동", "전체 3,482건 중 251~275건 표시" 같은 UI를 그대로 쓰고 있었는데, 이걸 커서 방식 위에서 구현하려고 백엔드가 매 요청마다 전체 테이블을 스캔해 총 개수와 오프셋에 해당하는 위치를 다시 계산했다. 데이터가 8만 건을 넘어가는 테이블에서 이 스캔이 요청당 3~5초씩 걸렸고, 동시 요청이 몰리는 시간대에 커넥션 풀이 고갈되면서 502로 이어졌다.

Totara 문서는 커서 기반 페이지네이션에서 전체 결과 수의 가치가 낮아지고 특정 페이지로 바로 이동할 수 없다고 명시적으로 적고 있다. 우리는 이 한계를 몰랐던 게 아니라, "GraphQL로 다 옮기면 편해질 것"이라는 기대로 화면별 요구사항 검토를 건너뛴 게 진짜 원인이었다.

재현 절차와 로그로 원인을 어떻게 확정했나?

같은 밀리초에 생성된 게시글 두 건을 스테이징 DB에 강제로 넣고, after 커서로 페이지를 순차 요청하는 스크립트를 돌려 중복 여부를 확인했다. 커서를 created_at만 base64 인코딩했을 때는 10회 중 3회 꼴로 중복 항목이 응답에 포함됐고, 여기에 id를 튜플로 묶어 (created_at, id)를 인코딩하자 100회 반복에서 중복이 재현되지 않았다.

# 기존: created_at만 인코딩
cursor = base64(created_at)

# 수정: created_at + id 튜플로 유일성 보장
cursor = base64(f"{created_at}|{id}")

리포트 502는 APM 트레이스에서 SELECT COUNT(*)와 OFFSET 계산이 포함된 쿼리의 평균 응답시간이 3.2초, p95가 4.8초로 잡히면서 확정됐다. 커서 기반 pageInfo만으로는 이 쿼리를 대체할 수 없었고, 억지로 흉내 내려던 시도가 병목이었다는 게 로그로 드러났다.

무엇을 바꿨고 어떤 지표로 확인했나?

두 가지를 바꿨다. 첫째, 피드처럼 순차 탐색만 필요한 화면은 커서에 (정렬필드, 유일키) 튜플을 쓰도록 전면 수정했다. 둘째, 관리자 리포트처럼 총 개수와 임의 페이지 이동이 필요한 화면은 커서 방식을 걷어내고 기존 REST 오프셋 페이지네이션을 유지했다. GraphQL과 REST를 화면 성격에 따라 병행하는 구조로 되돌린 셈이다.

수정 후 2주간 피드 중복 제보는 0건이었고, 리포트 화면의 p95 응답시간은 4.8초에서 610ms로 돌아왔다. Apollo 문서가 안내하는 대로 connection.pageInfo.endCursor를 기준으로 다음 요청을 구성하는 패턴은 그대로 유지했지만, 커서를 만드는 쪽의 유일성 보장을 별도 점검 항목으로 추가했다 (Apollo 커서 페이지네이션 문서).

같은 함정을 피하려면 무엇을 기본값으로 삼아야 할까?

커서 페이지네이션을 기본값으로 쓸 화면과 오프셋을 기본값으로 쓸 화면을 먼저 나누는 게 순서다. 데이터가 계속 추가·삭제되는 피드, 채팅, 이벤트 로그처럼 "다음 묶음"만 필요한 곳에는 커서가 맞고, 총 개수나 임의 페이지 이동이 UI 요구사항에 들어 있는 관리자 화면에는 오프셋을 남겨두는 편이 낫다. 그리고 커서를 만들 때는 정렬 필드 하나만 믿지 말고, 유일성이 보장되는 키를 반드시 튜플로 묶어야 한다는 걸 이번에 다시 확인했다.

핵심 정리

  • 커서 페이지네이션 장애의 원인은 두 가지였다: 정렬 필드의 유일성 부족, 그리고 커서 방식에 맞지 않는 화면 요구사항을 그대로 둔 것
  • 커서는 정렬 필드 단독이 아니라 (정렬필드, 유일키) 튜플로 만들어야 동시 생성 레코드에서도 안정적이다
  • 총 개수·임의 페이지 이동이 필요한 화면은 커서 방식과 근본적으로 맞지 않으며, 억지로 흉내 내면 전체 스캔 병목이 생긴다
  • 수정 후 리포트 화면 p95 응답시간은 4.8초에서 610ms로, 피드 중복 제보는 2주간 0건으로 확인됐다
  • 화면 성격별로 GraphQL 커서와 REST 오프셋을 병행하는 구조가, 하나의 방식으로 통일하는 것보다 실무에서는 더 안전했다