← 목록으로 돌아가기

API 페이지네이션 설계: Offset과 Cursor의 차이, 정렬 안정성, 무한 스크롤 일관성까지

Backend

페이지네이션은 목록을 자르는 문제가 아니다

목록 API를 만들 때 page와 size만 받으면 충분해 보입니다. 데이터가 적을 때는 offset 기반 페이지네이션이 단순하고 편합니다. 하지만 데이터가 늘고, 사용자가 무한 스크롤을 하고, 목록 중간에 새 데이터가 계속 들어오면 문제가 드러납니다. 같은 항목이 두 번 보이거나, 어떤 항목은 건너뛰거나, 뒤 페이지로 갈수록 쿼리가 느려집니다.

페이지네이션은 사용자 경험, 데이터 일관성, 데이터베이스 성능이 만나는 지점입니다. 단순 관리자 화면과 대규모 피드 API는 다른 전략이 필요합니다. 검색 결과처럼 특정 시점의 스냅샷이 중요한 목록과, 최신순 피드처럼 계속 갱신되는 목록도 다르게 설계해야 합니다.

이 글에서는 offset과 cursor 방식의 차이, 정렬 안정성을 만드는 방법, cursor token에 담아야 할 정보, 무한 스크롤에서 중복과 누락을 줄이는 기준을 정리합니다.


API 페이지네이션과 응답 설계

1. Offset은 단순하지만 깊은 페이지에서 느려진다

Offset 방식은 page=3, size=20 또는 offset=40, limit=20처럼 요청합니다. 구현이 쉽고 특정 페이지로 바로 이동하기 좋습니다. 관리자 테이블, 검색 결과, 작은 데이터셋에는 충분히 실용적입니다. SQL도 ORDER BY created_at DESC LIMIT 20 OFFSET 40처럼 단순합니다.

문제는 offset이 커질수록 데이터베이스가 앞의 행을 건너뛰는 비용을 치른다는 점입니다. OFFSET 100000 LIMIT 20은 20개만 가져오는 것처럼 보이지만, 데이터베이스는 정렬 조건에 따라 앞의 많은 행을 확인해야 할 수 있습니다. 인덱스가 있어도 깊은 페이지에서는 비용이 커집니다.

또 다른 문제는 데이터 변경입니다. 사용자가 1페이지를 본 뒤 2페이지를 요청하기 전에 새 글이 5개 추가되면 offset 기준이 밀립니다. 그 결과 1페이지에서 본 글이 2페이지에 다시 나오거나, 일부 글이 건너뛰어질 수 있습니다. 변경이 적은 데이터라면 괜찮지만, 최신순 피드에서는 사용자에게 바로 보입니다.


2. Cursor는 위치를 값으로 기억한다

Cursor 방식은 "몇 개를 건너뛸지"가 아니라 "어디 이후를 가져올지"를 요청합니다. 최신순 목록이라면 마지막으로 본 항목의 created_at과 id를 cursor로 넘기고, 서버는 그보다 오래된 항목을 가져옵니다. 이렇게 하면 새 데이터가 앞에 추가되어도 다음 페이지 기준이 흔들리지 않습니다.

Cursor의 핵심은 정렬 조건과 같은 값을 담는 것입니다. ORDER BY created_at DESC만 쓰면 같은 created_at을 가진 항목 사이 순서가 불안정합니다. 반드시 id 같은 고유 값을 보조 정렬로 추가해야 합니다. 예를 들어 ORDER BY created_at DESC, id DESC를 쓰고, cursor에도 created_at과 id를 함께 담습니다.

Cursor token은 클라이언트가 해석하지 않아도 됩니다. 서버가 base64로 인코딩한 JSON을 내려주고, 다음 요청에서 그대로 받는 방식이 흔합니다. 중요한 것은 token에 필터 조건과 정렬 기준 버전을 함께 고려하는 것입니다. 사용자가 필터를 바꿨는데 이전 cursor를 보내면 잘못된 페이지가 나올 수 있으므로 서버가 검증해야 합니다.


3. 정렬 안정성이 없으면 어떤 방식도 깨진다

페이지네이션 버그의 많은 원인은 정렬 안정성 부족입니다. created_at이 같은 글이 여러 개 있을 수 있고, score가 같은 검색 결과도 많습니다. ORDER BY score DESC만 쓰면 데이터베이스는 같은 점수의 행을 매번 다른 순서로 반환할 수 있습니다. 그러면 페이지 사이 중복과 누락이 생깁니다.

안정적인 정렬은 항상 마지막에 고유한 tie-breaker를 둡니다. created_at DESC, id DESC처럼 같은 created_at 안에서도 순서가 결정되어야 합니다. 검색 점수라면 score DESC, id DESC를 사용할 수 있습니다. 단, id가 시간 순서를 의미하지 않는다면 제품 요구와 맞는 보조 키를 선택해야 합니다.

정렬 기준이 사용 중에 바뀌는 값인지도 봐야 합니다. 좋아요 수, 댓글 수, 랭킹 점수처럼 계속 변하는 값으로 무한 스크롤을 하면 사용자가 내려가는 동안 항목 위치가 바뀝니다. 이런 목록은 스냅샷 시간을 cursor에 포함하거나, 특정 요청 세션 동안 ranking_version을 고정하는 방식이 필요할 수 있습니다.


4. API 응답 형태와 운영 체크리스트

좋은 페이지네이션 응답은 다음 요청에 필요한 정보를 명확히 줍니다. items와 nextCursor, hasNext 정도면 무한 스크롤에는 충분합니다. 전체 개수가 꼭 필요하지 않다면 count 쿼리를 피하는 것이 좋습니다. 대형 테이블에서 정확한 count는 생각보다 비쌉니다. 관리자 화면처럼 전체 페이지 수가 필요한 곳에만 별도로 계산합니다.

Cursor 방식에서도 이전 페이지 이동이 필요할 수 있습니다. 이때는 before cursor를 지원하거나, 브라우저 히스토리에 이전 응답을 보관하는 방식이 있습니다. 무한 스크롤 UX에서는 뒤로 가기 복원도 중요합니다. 사용자가 상세를 보고 돌아왔을 때 스크롤 위치와 기존 items가 유지되어야 합니다.

체크리스트는 다음과 같습니다.

  • 최신순 피드에 offset을 쓰고 있지 않은가
  • ORDER BY 마지막에 고유한 tie-breaker가 있는가
  • cursor에 정렬 기준과 필터 조건이 반영되는가
  • 필터 변경 시 이전 cursor를 거부하거나 무시하는가
  • 전체 count가 꼭 필요한 화면인지 확인했는가
  • 무한 스크롤에서 뒤로 가기와 스크롤 복원이 동작하는가
  • 페이지 경계 중복을 테스트 데이터로 검증했는가

5. Cursor token은 신뢰 경계를 지나간다

Cursor token은 클라이언트가 다시 보내는 값이므로 서버는 신뢰하면 안 됩니다. 단순히 createdAt과 id를 base64로 인코딩해 내려주는 것만으로는 충분하지 않을 수 있습니다. 사용자가 token을 수정해 다른 범위의 데이터를 요청할 수 있기 때문입니다. 민감한 목록이나 권한이 걸린 데이터라면 token에 서명하거나, 서버가 token 내용을 검증해야 합니다.

서명된 cursor는 HMAC으로 만들 수 있습니다. payload에는 정렬 기준, 마지막 항목의 key, 필터 조건, 발급 시각, 버전 정보를 넣고, signature를 함께 붙입니다. 서버는 다음 요청에서 signature를 검증하고, 현재 요청의 filter와 cursor payload가 일치하는지 확인합니다. 필터가 바뀌었는데 이전 cursor를 재사용하면 400으로 거부하거나 cursor를 무시하고 첫 페이지부터 다시 시작합니다.

Cursor 버전도 유용합니다. 나중에 정렬 기준을 created_at에서 published_at으로 바꾸거나, id tie-breaker 방향을 바꾸면 이전 cursor와 호환되지 않을 수 있습니다. token payload에 version을 넣어두면 서버가 구버전 cursor를 명확히 처리할 수 있습니다. 무한 스크롤 API는 한 번 배포되면 모바일 앱이나 오래된 웹 탭에서 예전 cursor가 들어올 수 있다는 점을 고려해야 합니다.

Cursor에 개인정보나 내부 ID를 그대로 노출하는 것도 피해야 할 수 있습니다. 내부 sequence id가 비즈니스 규모를 드러내거나, 다른 tenant의 데이터 추측에 사용될 수 있다면 opaque token으로 감싸는 편이 낫습니다. 클라이언트는 cursor를 해석하지 않고 그대로 보관하는 계약을 지켜야 합니다.


6. 삭제와 삽입이 많은 목록은 별도 정책이 필요하다

목록 데이터가 계속 추가되기만 한다면 cursor 설계는 비교적 단순합니다. 하지만 항목이 삭제되거나, 상태 변경으로 목록에서 사라지거나, 정렬 기준이 바뀌는 경우에는 더 복잡합니다. 사용자가 첫 페이지를 본 뒤 어떤 항목이 삭제되면 다음 페이지에서 개수가 줄어들 수 있습니다. 이는 자연스러운 현상이지만, 중복과 누락을 최소화하는 기준은 필요합니다.

무한 스크롤 피드에서는 보통 "사용자가 이미 본 영역은 최대한 유지하고, 새 항목은 새로고침이나 상단 배너로 안내"하는 방식이 안정적입니다. 사용자가 아래로 읽는 중에 새 글을 자동으로 끼워 넣으면 스크롤 위치가 밀리고 문맥이 깨집니다. "새 글 5개 보기" 버튼을 눌렀을 때 목록 상단을 갱신하는 UX가 더 예측 가능합니다.

관리자 화면처럼 정확성이 중요한 목록에서는 cursor보다 snapshot 기반 접근이 필요할 수 있습니다. 검색 조건을 실행한 시점의 snapshot id를 만들고, 이후 페이지 요청은 같은 snapshot을 기준으로 처리합니다. 비용은 더 들지만 사용자는 페이지를 넘기는 동안 같은 결과 집합을 보게 됩니다. 감사, 정산, export 같은 기능에서는 이 안정성이 중요합니다.

테스트는 경계 조건을 포함해야 합니다. 같은 created_at을 가진 항목 30개, 첫 페이지 조회 후 중간 항목 삭제, 다음 페이지 조회 전 새 항목 삽입, cursor 변조, 필터 변경 후 이전 cursor 재사용을 자동 테스트로 검증합니다. 페이지네이션 버그는 정상 데이터에서는 잘 보이지 않고 경계 데이터에서 드러납니다.


7. 데이터베이스 인덱스는 페이지네이션 방식과 함께 설계한다

Cursor 페이지네이션을 도입했는데 인덱스가 정렬 조건과 맞지 않으면 성능 이점을 얻기 어렵습니다. ORDER BY created_at DESC, id DESC를 사용한다면 같은 순서의 복합 인덱스를 검토해야 합니다. 필터 조건이 status나 tenant_id를 포함한다면 인덱스 앞쪽에 어떤 컬럼을 둘지 실제 query plan을 보고 결정해야 합니다.

Offset 방식도 인덱스가 필요하지만, 깊은 offset 비용 자체를 없애지는 못합니다. 데이터베이스는 여전히 많은 row를 건너뛰어야 할 수 있습니다. Cursor 방식은 마지막으로 본 key 이후의 범위를 조건으로 좁히기 때문에 인덱스 range scan과 잘 맞습니다. WHERE (created_at, id) < (:createdAt, :id) 형태의 조건이 인덱스를 타는지 확인해야 합니다.

필터가 많은 목록에서는 모든 조합에 인덱스를 만들 수 없습니다. 자주 쓰는 정렬과 필터 조합을 로그로 확인하고, 핵심 화면부터 최적화합니다. 관리자 검색처럼 조합이 많은 경우에는 검색 엔진이나 별도 read model이 더 나을 수 있습니다. 페이지네이션은 API 설계이면서 데이터 접근 패턴 설계입니다.

운영에서는 느린 페이지 요청을 별도로 추적합니다. 첫 페이지는 빠른데 20번째 요청부터 느려지는지, 특정 필터에서만 느린지, count 쿼리가 병목인지 봅니다. 페이지네이션 성능은 평균보다 tail latency가 중요합니다. 사용자가 계속 내려갈 때 느려지는 목록은 체감 품질이 크게 떨어집니다.


8. 클라이언트 캐시와 페이지네이션을 함께 맞춘다

무한 스크롤은 API만으로 완성되지 않습니다. 클라이언트가 이미 받은 페이지를 어떻게 보관하고, 상세 화면에서 돌아왔을 때 어디까지 복원할지 정해야 합니다. 사용자가 긴 목록을 내려가다가 상세를 보고 돌아왔는데 첫 페이지로 돌아가면 경험이 나빠집니다. 페이지 데이터와 스크롤 위치를 함께 유지해야 합니다.

캐시 무효화도 섬세해야 합니다. 새 항목 작성 후 모든 페이지를 즉시 날리면 사용자는 읽던 위치를 잃을 수 있습니다. 상단에 새 항목을 삽입하거나, 새로고침 배너를 보여주는 방식이 더 자연스럽습니다. 목록 API의 일관성과 클라이언트 캐시 전략이 맞아야 무한 스크롤이 안정적으로 느껴집니다.


결론: 안정적인 목록은 정렬 계약에서 시작한다

페이지네이션은 단순히 LIMIT을 붙이는 일이 아닙니다. 사용자가 보는 목록이 어떤 순서로 고정되는지, 데이터가 바뀌어도 다음 페이지 기준이 유지되는지, 데이터베이스가 깊은 페이지를 감당할 수 있는지 함께 설계해야 합니다.

Offset은 단순한 화면에 적합하고, Cursor는 계속 변하는 대규모 목록에 적합합니다. 어떤 방식을 쓰든 정렬 안정성과 cursor 검증이 없으면 중복과 누락은 피하기 어렵습니다. 목록 API의 품질은 첫 페이지가 아니라 사용자가 계속 내려갈 때 드러납니다.