DB 페이지네이션 중복/누락 근본 원인 해결

애플리케이션이나 모바일 API를 설계할 때 페이지네이션(Pagination)은 필수적인 기능입니다. 하지만 서비스 운영 중 "다음 페이지로 넘겼는데 이전 페이지의 게시물이 또 나온다"거나 "특정 게시물이 목록에서 아예 사라졌다"는 버그 리포트를 접하는 경우가 빈번합니다. 이는 단순한 캐싱 문제가 아니며, 데이터베이스의 정렬 매커니즘을 정확히 이해하지 못했을 때 발생하는 구조적 결함입니다. 본 글에서는 SQL 표준의 정렬 특성으로 인해 발생하는 데이터 무결성 문제를 분석하고, 이를 해결하기 위한 결정적 정렬(Deterministic Sort) 기법과 대규모 트래픽 환경에서의 성능 최적화 전략인 커서 기반 페이지네이션(Cursor-based Pagination)을 다룹니다.

1. 비결정적 정렬(Non-deterministic Sort)과 데이터 이상

관계형 데이터베이스(RDBMS)에서 ORDER BY 절을 사용할 때, 개발자들은 결과 순서가 항상 보장된다고 가정하는 경향이 있습니다. 그러나 SQL 표준에 따르면, 정렬 기준이 되는 컬럼의 값이 중복될 경우 해당 로우(Row)들 간의 순서는 정의되지 않습니다. 이를 비결정적 정렬이라고 합니다.

예를 들어, posted(작성일시)를 기준으로 내림차순 정렬을 수행하는 일반적인 게시판 쿼리를 살펴보겠습니다.


SELECT id, title, posted 
FROM article 
ORDER BY posted DESC 
LIMIT 5 OFFSET 5;

만약 posted 값이 `2023-10-27 12:00:00`인 게시물이 10개 존재한다면, 데이터베이스 엔진은 쿼리 실행 시점의 상황(디스크 I/O 순서, 병렬 처리 스레드 상태, 버퍼 풀 상태 등)에 따라 이 10개 로우의 반환 순서를 임의로 결정합니다. 1페이지 호출 시에는 `ID: 12, 11` 순서로 반환했다가, 2페이지 호출 시 `ID: 11, 10` 순서로 반환할 수 있습니다. 결과적으로 사용자는 `ID: 11`을 두 번 보게 되고(중복), `ID: 12`는 누락되는 현상이 발생합니다.

Risk Alert: 클러스터링 인덱스나 물리적 저장 순서에 의존하지 마십시오. 옵티마이저의 실행 계획(Execution Plan)이 변경되거나 테이블 리빌드(VACUUM, Optimize Table)가 발생하면 정렬 순서는 언제든 바뀔 수 있습니다.

2. 결정적 정렬(Deterministic Sort) 구현 전략

이 문제를 해결하기 위해서는 정렬 결과가 항상 유일하도록 보장해야 합니다. 이를 위해 정렬 기준에 유니크 키(Unique Key)를 타이브레이커(Tie-breaker)로 추가합니다. 가장 확실한 방법은 Primary Key(PK)를 사용하는 것입니다.

수정된 쿼리는 다음과 같습니다. `posted` 컬럼이 동일할 경우, 고유한 `id` 값을 기준으로 2차 정렬을 수행하여 순서를 고정합니다.


SELECT id, title, posted 
FROM article 
ORDER BY posted DESC, id DESC -- id를 타이브레이커로 추가
LIMIT 5 OFFSET 5;
정렬 전략 중복 값 처리 데이터 안정성
비결정적 (Unstable) 순서 보장 없음 낮음 (중복/누락 가능)
결정적 (Stable) PK로 순서 강제 높음 (항상 동일 결과)

이렇게 하면 데이터베이스 내부 상태와 무관하게 언제나 동일한 정렬 순서가 보장되므로, 오프셋 기반 페이지네이션에서의 중복 노출 문제를 기술적으로 차단할 수 있습니다.

3. Offset 방식의 한계와 Cursor 기반 최적화

결정적 정렬을 도입하더라도 `OFFSET` 기반 페이지네이션은 대규모 데이터셋에서 성능 이슈를 가집니다. `LIMIT 10 OFFSET 1,000,000`과 같은 쿼리는 데이터베이스가 앞선 100만 개의 레코드를 읽고 정렬한 뒤 버리는 작업(Discard)을 수행해야 하므로, 페이지 번호가 뒤로 갈수록 쿼리 비용이 선형적으로 증가합니다.

이를 해결하기 위해 커서 기반 페이지네이션(Cursor-based Pagination), 혹은 Keyset Pagination을 사용합니다. 이는 "몇 번째 데이터부터"가 아니라 "마지막으로 조회한 데이터 다음부터" 조회하는 방식입니다. 이를 위해서는 앞서 정의한 결정적 정렬 컬럼(`posted`, `id`)을 커서로 활용해야 합니다.


-- 직전 페이지의 마지막 데이터가 (posted='2023-10-27 12:00:00', id=11) 인 경우

SELECT id, title, posted 
FROM article 
WHERE (posted, id) < ('2023-10-27 12:00:00', 11) -- 튜플 비교(Tuple Comparison)
ORDER BY posted DESC, id DESC
LIMIT 5;
SQL Tip: MySQL 5.7+, PostgreSQL 등 대부분의 최신 RDBMS는 (col1, col2) < (val1, val2) 형태의 Row Value Constructor 문법을 지원하며, 이를 통해 복잡한 AND/OR 조건 없이 간결하게 커서 조건을 처리할 수 있습니다.

3.1. 인덱스(Index) 설계의 중요성

커서 기반 페이지네이션의 성능 이점을 극대화하려면 적절한 인덱스가 필수적입니다. 위 쿼리의 경우 `posted`와 `id`를 결합한 복합 인덱스(Composite Index)가 필요합니다.


CREATE INDEX idx_article_posted_id ON article (posted DESC, id DESC);

이 인덱스가 존재하면 데이터베이스는 전체 테이블을 스캔(Full Scan)하거나 파일소트(Filesort)를 수행하지 않고, B-Tree 인덱스에서 조건에 맞는 시작 위치(Seek)를 찾은 뒤 5개의 노드만 순차적으로 읽고(Scan) 종료합니다. 결과적으로 데이터 양이 수억 건으로 늘어나도 응답 속도는 일정하게 유지됩니다.

결론

데이터베이스 페이지네이션의 안정성은 UX 품질과 직결되는 중요한 엔지니어링 요소입니다. 비결정적 정렬로 인한 데이터 무결성 훼손을 방지하기 위해 반드시 유니크 키를 포함한 결정적 정렬을 사용해야 합니다. 또한, 서비스 초기에는 `OFFSET` 방식이 구현 편의성이 높을 수 있으나, 데이터 규모가 커질 것을 대비한다면 커서 기반 페이지네이션과 그에 맞는 복합 인덱스 설계를 아키텍처 단계에서 고려하는 것이 바람직합니다. 이는 시스템의 부하를 획기적으로 줄이고, 사용자에게 끊김 없는 탐색 경험을 제공하는 가장 확실한 방법입니다.

Post a Comment