TECH/sql

대용량 데이터의 적, OFFSET 페이징 탈출하기: No-Offset(커서 기반) 완벽 가이드

런코리치 2026. 3. 12. 08:00

게시판이나 API를 만들 때 우리는 관성적으로 OFFSET과 LIMIT을 사용하여 페이징을 구현하곤 합니다. 하지만 데이터가 수백만 건 이상 쌓인 환경에서 사용자가 1,000번째 페이지를 클릭하는 순간, 시스템은 비명을 지르기 시작합니다. 단순한 쿼리 하나가 왜 데이터베이스(DB) 전체의 성능 재앙을 불러오는지, 그리고 구글이나 인스타그램 같은 글로벌 서비스들은 이 문제를 어떻게 우아하게 해결하고 있는지 심층적으로 분석해 보겠습니다.


1. OFFSET 방식의 치명적인 함정: "읽고 버리는 비용"

대부분의 개발 입문자가 사용하는 방식은 다음과 같습니다. 10,001번째부터 100개의 데이터를 조회하고 싶을 때 우리는 보통 아래와 같은 SQL을 작성합니다.

SQL
 
-- 10,001번째부터 100개의 데이터를 조회하고 싶을 때
SELECT * FROM posts 
ORDER BY id DESC 
LIMIT 100 OFFSET 10000;

이 쿼리의 내부 동작을 들여다보면 충격적인 사실이 숨어 있습니다. DB 엔진은 우리가 원하는 10,001번째 데이터를 마법처럼 바로 찾아가지 못합니다.

  1. 전체 스캔: 우선 정렬 조건에 맞춰 앞의 10,000건의 데이터를 전부 읽어서 메모리에 올립니다.
  2. 데이터 폐기: 읽어온 10,000건을 사용자에게 보여주지 않고 그대로 버립니다.
  3. 최종 반환: 그제야 그다음 100건을 가져와서 사용자에게 전달합니다.

즉, 뒤로 갈수록 '읽고 버려야 하는 데이터'가 기하급수적으로 늘어납니다. 만약 1,000,000번째 데이터를 조회하려고 시도한다면, DB는 앞의 100만 건을 모두 디스크에서 읽어 메모리(Buffer Pool)에 올리고 버리는 과정을 거쳐야 합니다. 이 과정에서 CPU 점유율이 치솟고 디스크 I/O가 폭발하며 다른 쿼리들의 처리 속도까지 늦추게 됩니다.


2. 해결책: No-Offset (Cursor 기반 페이징) 전략

이 성능 지옥을 탈출하는 가장 확실한 방법은 "내가 마지막으로 본 데이터의 위치(ID 등)"를 기억하는 것입니다. 그리고 다음 페이지를 요청할 때, 그 위치를 기준으로 이후 데이터만 즉시 읽어오는 방식입니다. 이를 흔히 Cursor 기반 페이징 또는 No-Offset 전략이라고 부릅니다.

#### ✅ No-Offset 쿼리 동작 원리

이 방식은 OFFSET을 쓰지 않고 WHERE 절에 인덱스가 걸린 특정 값(Primary Key 등)을 직접 비교합니다.

SQL
 
-- 첫 번째 페이지 조회
SELECT * FROM posts 
ORDER BY id DESC 
LIMIT 100;

-- 두 번째 페이지 조회 (마지막으로 본 ID가 9500번이라면?)
SELECT * FROM posts 
WHERE id < 9500  -- OFFSET 대신 조건문을 사용!
ORDER BY id DESC 
LIMIT 100;

- 왜 No-Offset이 압도적으로 빠른가?

  1. 인덱스 최적화: DB는 인덱스를 타고 9,500번 위치로 즉시 '점프'합니다.
  2. 불필요한 데이터 무시: 9,500번 이전의 데이터는 아예 건드리지도 않습니다. 따라서 전체 데이터가 100건이든 1억 건이든, 어느 페이지를 조회하더라도 성능이 일정하게(O(1)에 가깝게) 유지됩니다.

3. 실무 시나리오: 금융 데이터 및 대규모 트랜잭션 관리

소프트웨어 개발자로서 우리가 다루는 데이터는 단순한 게시글에 그치지 않습니다. 예를 들어 수천 개의 종목과 수억 건의 일별 시세 데이터가 쌓인 금융 시스템을 가정해 봅시다. 삼성전자(005930)의 최근 시세를 50건씩 끊어서 보여줄 때, OFFSET 방식은 서비스 장애의 원인이 됩니다.

  • AS-IS (OFFSET): 페이지가 뒤로 갈수록 전체 시세 데이터를 풀 스캔하며 서버 응답 속도가 3초, 5초로 늘어납니다.
  • TO-BE (No-Offset): 사용자가 마지막으로 확인한 created_at 시간이나 sequence_id를 기준으로 조회하면, 10년 전 데이터를 조회하더라도 0.01초 내에 응답이 가능합니다.

특히 MyBatis 환경에서는 아래와 같이 동적 쿼리를 활용해 유연하게 구현할 수 있습니다.

XML
 
<select id="getPostList" resultType="Post">
    SELECT * FROM posts
    WHERE 1=1
    <if test="lastId != null">
        AND id &lt; #{lastId}
    </if>
    ORDER BY id DESC
    LIMIT #{pageSize}
</select>

4. No-Offset의 한계와 극복 방법

완벽해 보이는 No-Offset 방식도 서비스 기획에 따라 적용이 어려울 수 있습니다.

  1. 페이지 점프 불가: "1페이지에서 바로 15페이지로 점프"하는 UI에는 적합하지 않습니다. 오직 '다음' 또는 '이전'만 가능하기 때문입니다. 그래서 주로 무한 스크롤(인스타그램, 페이스북)이나 [더보기] 버튼 형태에 최적화되어 있습니다.
  2. 정렬 기준의 유니크성: 만약 created_at(생성일)으로 정렬하는데 동일한 시간에 생성된 데이터가 여러 건 있다면, 데이터가 누락될 위험이 있습니다. 이럴 때는 WHERE (created_at, id) < (#{lastTime}, #{lastId}) 처럼 복합 컬럼을 기준으로 커서를 잡아야 합니다.
  3. 인덱스 설계의 중요성: WHERE 절에 들어가는 컬럼에는 반드시 인덱스가 걸려 있어야 합니다. 인덱스가 없다면 No-Offset 방식 역시 결국 전체 테이블을 스캔하게 되어 무용지물이 됩니다.

🏁 마치며: 성능은 디테일한 설계에서 결정됩니다

대용량 데이터를 다루는 백엔드 개발자에게 OFFSET은 가급적 지양해야 할 습관 중 하나입니다. 데이터가 적을 때는 문제가 드러나지 않지만, 서비스가 성장하고 데이터가 쌓이는 순간 가장 먼저 무너지는 지점이 바로 이곳입니다.

성능 최적화는 단순히 코드를 예쁘게 짜는 것을 넘어, DB 엔진이 데이터를 어떻게 찾아가는지 이해하는 것에서 시작합니다. 만약 여러분의 서비스가 확장성을 고려해야 한다면, 초기 기획 단계에서부터 No-Offset 기반의 페이징을 적극적으로 제안해 보세요. 인덱스를 활용해 '점프'하는 쿼리 하나가 서버의 수명을 연장하고, 수많은 방문자에게 쾌적한 사용자 경험을 제공할 것입니다.


💡 개발 팁: 현재 프로젝트에서 MyBatis를 사용 중이라면, lastId 파라미터를 Optional하게 설계하여 첫 페이지 조회와 이후 조회를 하나의 로직으로 통합해 보시기 바랍니다.