Offset vs Cursor 페이지네이션 with DB 관점

페이지네이션의 필요성

게시판, 소셜 미디어 피드, 상품 목록 등 대용량 데이터를 다루는 서비스에서 전체 데이터를 한 번에 조회하고 전달하는 방식은 시스템 전반에 부하를 발생시킬 수 있으며 영역별로는 다음과 같은 문제가 발생할 수 있다.

  • 인프라 영역: 거대한 데이터를 전송하며 발생하는 네트워크 대역폭 낭비
  • 애플리케이션 영역: 수백만 개의 객체를 생성하며 발생하는 서버 메모리 부족
  • 데이터베이스 영역: 정렬 및 Full Table Scan으로 인한 I/O 및 CPU 리소스 점유

이러한 문제가 발생하는 경우 시스템, 사용자, 비용별 이런 결과가 예상될 수 있다.

  • 시스템: 메모리 부족으로 인한 서버 다운, DB 응답 지연
  • 사용자: 페이지 로딩에 수 초 이상 소요되는 사용자 경험
  • 비용: 불필요한 트래픽 및 컴퓨팅 자원 사용으로 인한 운영 비용 증가

이러한 문제를 해결하기 위해 데이터를 일정 크기로 나누어 제공하는 Pagination 처리는 선택이 아닌 필수라 할 수 있다.

페이지네이션의 두 가지 방식인 Offset 기반과 Cursor 기반 페이지네이션의 개념과 각 개념을 RDBMS에서는 어떻게 동작하는지와 함께 알아보겠다.

1. Offset 기반

MySQL 기준으로 설명하겠습니다.

동작 원리

조건에 해당하는 데이터를 읽으며 원하는 OFFSET 도달 시점에서 LIMIT 만큼 데이터를 읽는 방식.

쿼리 예시

-- 기본 형태
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
 
-- page, size 파라미터 기반
SELECT * FROM products
ORDER BY created_at DESC
LIMIT {size} OFFSET ({page} - 1) * {size};

장점

  • 구현 단순: 대부분의 RDBMS에서 LIMIT, OFFSET 키워드를 기본적으로 제공하므로 추가적인 로직 없이 쿼리만으로 쉽게 구현 가능.
  • 페이지 이동 수월: 원하는 페이지 번호로 바로 이동하는 UI 구현에 적합.
  • 전체 정보 제공 가능: 전체 데이터 개수와 마지막 페이지 번호를 사용자에게 보여줄 수 있어 사용자 입장에서 탐색 범위를 파악 용이.
  • 정렬 변경 쉬움: 복잡한 조건 없이 ORDER BY 컬럼만 변경하면 즉시 정렬 기준을 바꿀 수 있음.
  • 캐싱 유리: '정렬 기준 + 페이지 번호' 조합을 캐시 키로 활용하여 반복적인 요청에 대해 성능을 최적화 가능.

단점

  • 성능 저하: OFFSET이 커질수록 DB는 앞선 레코드를 모두 스캔 후 버려야 하므로 검색 속도가 느려짐.
  • 데이터 중복/누락: 조회를 수행하는 사이에 새로운 데이터가 추가되거나 삭제되면, 사용자가 다음 페이지를 요청했을 때 이전 페이지에서 봤던 데이터가 다시 나타나거나 특정 데이터가 건너뛰어질 수 있음.
  • COUNT 비용: 전체 정보 제공시 totalElements 계산을 위한 SELECT COUNT(*) 추가 필요

인덱스가 있는데 왜 OFFSET은 굳이 '하나씩' 세면서 갈까?

처음 이 내용을 접했을 때 나는 이런 생각을 했었다.

"인덱스가 이미 정렬되어 있다면, 그 정렬된 상태 그대로 순서 정보도 같이 들고 있으면 안 되나?"

하지만 이게 가능하려면 아래와 같은 비현실적인 조건이 전제돼야 한다.

  • 레코드가 특정 기준에 따라 정수로 1씩 정확히 증가하며 저장되어야 함
  • 중간에 어떠한 레코드도 삭제되지 않음을 보장해야 함

현실에서 운영되는 DB에서 데이터는 수시로 추가되고 삭제된다. 또한, 만약 인덱스가 순번 정보를 들고 있다면, 데이터 하나가 삭제될 때마다 그 뒤에 있는 데이터의 순번을 매번 새로 업데이트해야 할 것이다. MySQL의 B+Tree 인덱스는 "ID 500이 어디 있는가?"는 즉시 찾지만, "500번째 데이터가 어디 있는가?"는 모른다.

B+Tree의 리프 노드는 연결 리스트 구조. OFFSET 10000을 실행하면 인덱스를 타고 첫 데이터로 가서 연결 고리를 따라 10,000번을 이동하며 직접 숫자를 세야 된다. 그리고 10,000 번째에 와서야 LIMIT 만큼 데이터를 또 읽고 그 데이터만을 응답한다. 기껏 읽은 10,000개는 그냥 순서세기용으로 쓰인 것이다.

즉, 정렬된 순서에서 10,001번째 데이터를 찾기 위해 앞의 10,000개를 '스캔'해야 하는 구조적 한계가 있다. 데이터가 수백만 건이 넘어가고 OFFSET이 커질수록, 실제로는 보지도 않을 데이터를 위해 디스크 I/O를 발생시키느라 성능이 저하된다.

만약 인덱스가 없는 조건에서 OFFSET을 한다면?

데이터베이스 입장에서는 정말 큰 부하가 발생하는데 과정이 다음과 같다.

1. Full Table Scan (Clustered Index 통째로 퍼 올리기)

세컨더리 인덱스(non-clustered index)가 없기 때문에 MySQL은 조건에 맞는 데이터를 찾기 위해 Clustered Index의 리프 노드를 처음부터 끝까지 훑어야 한다.

InnoDB에서 Clustered Index는 곧 '테이블의 실제 데이터' 그 자체이므로, 디스크에 저장된 거대한 데이터 블록 전체를 버퍼풀로 퍼 올리는 가장 무거운 작업이 시작된다.

풀 스캔 과정

  1. 출발 리프 노드 찾기
  2. 리프 노드 끼리는 연결 리스트이므로 수평 탐색(Sequential Read, Read-Ahead도 알아보기)

2. Filesort

OFFSET은 보통 ORDER BY와 함께 쓰일 것이다. 인덱스가 있다면 이미 정렬되어 있으니 이 과정이 생략될거지만, 지금은 없으니 앞서 메모리로 퍼 올린 수백만 건의 Clustered Index 데이터를 메모리(Sort Buffer)나 디스크의 임시 공간에 때려 넣고 Filesort를 수행한다.

실제 데이터 덩어리를 정렬하는 것이라 메모리가 금방 꽉 차고, 결국 느린 디스크까지 사용(디스크 임시 파일 기반 merge sort)하게 되면 성능이 떨어질 것이다.

3. 레코드 세고 버리기

클러스터링 인덱스의 실제 데이터 전체를 하나하나 읽어가며 10,000개를 세고 이후 필요한 것만 응답하고 이제 버린다.

기억하기:

  • 클러스터링 인덱스 안에 '레코드'가 들어있음. 노드 == 페이지
  • MySQL은 디스크에서 버퍼 풀로 데이터를 가져올 때, 레코드 하나하나를 퍼 올리지 않는다. 기본적으로 16KB 크기의 '페이지(Page)' 단위로 통째로 퍼 올린다.
  • B+Tree의 Root, Branch: 자료 구조 정보
  • B+Tree의 Leaf: clustered index 는 실제 데이터, non-clustered index는 clustered index 의 키(PK)

2. Cursor 기반

사용자에게 제공된 마지막 데이터의 식별자(커서)를 기준으로 다음 데이터를 가져오는 방식.

ElasticSearch의 search_after 도 커서 방식인점 참고.

동작 원리

클라이언트가 마지막으로 조회한 레코드를 '커서'로 지정하여 서버에 전달한다. 서버는 해당 커서 값을 기준으로 인덱스 탐색을 수행하여 그 다음 레코드부터 LIMIT 개수만큼만 읽는다.

OFFSET 처럼 데이터를 읽고 버리는 과정이 없으므로 페이지 깊이와 상관없이 항상 일정하고 빠른 성능을 보장함.

쿼리 예시

-- 1. 첫 페이지 조회 (커서 없음)
SELECT * FROM products
ORDER BY id DESC
LIMIT 20;
 
-- 2. 다음 페이지 조회 (이전 페이지의 마지막 id가 100일 때)
SELECT * FROM products
WHERE id < 100 -- cursor = 100
ORDER BY id DESC
LIMIT 20;
 
-- 3. 복합 인덱스 사용 시 (created_at DESC, id DESC)
-- (created_at이 중복될 수 있으므로 id를 식별자로 함께 사용)
-- 복합 정렬 (created_at + id)
SELECT * FROM products
WHERE (created_at, id) < ('{lastCreatedAt}', {lastId})
ORDER BY created_at DESC, id DESC
LIMIT 20;

장점

  • 일관된 성능: 데이터가 아무리 많아져도, 몇 번째 페이지를 조회하든 인덱스를 통해 시작 위치로 바로 접근하므로 속도 저하 없음.
  • 데이터 안정성: OFFSET은 전체 행 순서를 기준으로 하므로, 조회 중 데이터가 추가, 삭제되면 순서가 밀려 중복 노출이나 누락이 발생할 수 있다. 반면 Cursor 방식은 **'마지막으로 본 값'**을 고정된 이정표로 삼기 때문에, 데이터 변화와 상관없이 다음 읽을 지점을 유지하여 데이터 불일치를 방지함.

단점

  • 구현 복잡도: ORDER BY 기준이 유니크하지 않다면(예: 가격순, 날짜순), 중복 값 처리를 위해 PK(id)를 보조 정렬 조건으로 포함해야 하며 WHERE 절 조건이 복잡해짐.
  • 랜덤 액세스 불가: "10페이지로 이동" 같은 기능 구현이 어려움. 반드시 순차적으로 데이터를 탐색해야 하므로 '더 보기'나 '무한 스크롤' UI에 적합.

왜 Cursor 방식은 빠를까?

Cursor 방식은 인덱스를 타고 커서 값에 해당하는 위치(리프 노드)를 단숨에 찾아낸다. 그리고 그 위치에서부터 필요한 만큼만 순차적으로 읽으면 끝이다.

앞에 100만 개의 데이터가 있든 1,000만 개의 데이터가 있든, 내가 가야 할 커서만 알면 바로 이동할 수 있기 때문에 성능 저하가 발생하지 않는 것이다.