MySQL pagination 성능 가이드: OFFSET보다 먼저 봐야 할 것들
DB
마지막 업데이트

MySQL pagination 성능 가이드: OFFSET보다 먼저 봐야 할 것들


목록 화면을 만들다 보면 페이지네이션은 거의 기본 기능처럼 느껴집니다. 처음에는 LIMIT ... OFFSET ... 만으로도 충분히 잘 작동해 보입니다. 그런데 데이터가 커지고 사용자가 뒤 페이지를 자주 보거나, 무한 스크롤이 붙고, 정렬 기준이 여러 개가 되면 그때부터 pagination은 단순 UI 기능이 아니라 읽는 row 수와 정렬 비용을 좌우하는 데이터 접근 전략이 됩니다.

MySQL에서는 특히 OFFSET 기반 접근이 깊은 페이지에서 점점 비싸지는 경우가 많습니다. 그리고 이 문제는 단순히 “뒤 페이지라 느리다”를 넘어서, 중복/누락처럼 보이는 UX 문제, COUNT(*) 비용, 인덱스 정렬 설계 문제까지 같이 끌고 옵니다.

이 글에서는 아래를 중심으로 정리합니다.

  • 왜 뒤 페이지가 갈수록 느려지는지
  • LIMIT ... OFFSET ... 의 실제 비용이 무엇인지
  • keyset 또는 cursor pagination이 왜 깊은 페이지에 유리한지
  • 정렬 키를 어떻게 잡아야 안정적인지
  • 전체 개수 표시와 페이지 번호 UX가 왜 성능과 충돌하는지
  • 인덱스는 어떤 식으로 설계해야 하는지

한 줄 요약은 이렇습니다. pagination 성능 문제는 “한 번에 몇 건 보여주나”보다, 원하는 20건을 얻기 위해 MySQL이 앞에서 얼마나 많이 읽고 버리느냐에 더 가깝습니다.

MySQL 페이징 성능 시각화


Quick answer

실무에서는 아래 순서로 판단하면 됩니다.

  1. 뒤 페이지 접근이 많은지 먼저 봅니다.
  2. OFFSET 이 커질수록 읽고 버리는 row가 함께 커지는지 확인합니다.
  3. 정렬 기준이 안정적이고 유일한지 봅니다.
  4. 무한 스크롤이나 “더 보기” UX라면 keyset/cursor pagination을 우선 검토합니다.
  5. numbered page UX가 꼭 필요하면 COUNT(*) 비용과 깊은 페이지 비용을 같이 감수할 가치가 있는지 봅니다.
  6. 정렬 컬럼과 필터 컬럼을 지원하는 인덱스를 설계합니다.
  7. EXPLAIN 으로 실제 row read와 정렬 비용을 검증합니다.

즉 pagination의 핵심은 “페이지를 어떻게 표시하느냐”보다 원하는 다음 집합을 얼마나 적은 읽기 비용으로 가져오느냐입니다.


1. 왜 뒤 페이지가 느려질까

가장 흔한 형태는 이렇습니다.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

겉보기에는 20건만 가져오니 가벼워 보이지만, 실제로는 MySQL이 앞의 row들을 어느 정도 지나가야 뒤쪽 20건에 도달할 수 있습니다. 즉 사용자는 20건만 보지만, DB는 그 전에 훨씬 더 많은 row를 읽고 버릴 수 있습니다.

그래서 뒤 페이지로 갈수록 비용이 커지는 핵심은:

  • 결과 건수는 20건으로 같아도
  • 접근해야 하는 앞부분 row가 계속 늘어난다는 점

입니다.

LIMIT 이 있다고 해서 항상 싸지 않습니다. 큰 OFFSET은 “읽고 버리는 비용”을 숨긴 채 남겨 둡니다.


2. OFFSET pagination은 왜 편하면서도 비싸질까

OFFSET 기반 방식의 장점은 아주 분명합니다.

  • 구현이 쉽다
  • 페이지 번호 UI와 잘 맞는다
  • 초기 개발 속도가 빠르다

하지만 단점도 분명합니다.

  • 깊은 페이지로 갈수록 느려질 수 있다
  • 최신 데이터가 계속 들어오면 페이지 경계가 흔들릴 수 있다
  • 정렬 기준이 애매하면 중복/누락처럼 보일 수 있다
  • 전체 개수 표시와 함께 쓰면 count 비용까지 붙을 수 있다

즉 OFFSET pagination은 “잘못된 방식”이라기보다, 작고 단순한 목록에는 편하지만 커질수록 비용이 선명해지는 방식에 가깝습니다.


3. keyset 또는 cursor pagination은 무엇이 다른가

cursor 기반 접근은 “몇 번째 페이지냐”보다 “어디까지 읽었느냐”를 기준으로 다음 집합을 가져옵니다.

예를 들어 최근 글 목록이라면:

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-04-13 09:00:00', 1234)
ORDER BY created_at DESC, id DESC
LIMIT 20;

처럼 마지막으로 본 row 이후를 이어 읽는 구조로 갈 수 있습니다.

이 방식의 핵심은:

  • 앞의 10,000건을 건너뛰는 대신
  • 마지막 위치에서 바로 다음 범위를 읽는 것

입니다.

그래서 deep pagination에서는 OFFSET보다 훨씬 안정적이고 싸게 동작하는 경우가 많습니다.


4. cursor가 유리한 이유는 “깊은 페이지 비용”을 줄이기 때문이다

cursor 기반 접근의 가장 큰 장점은 페이지가 깊어질수록 비용이 계속 커지는 문제를 완화한다는 점입니다.

특히 아래 상황에서 강합니다.

  • 무한 스크롤
  • “더 보기” 버튼
  • 최신순/과거순 탐색
  • 대용량 이벤트, 피드, 로그 목록

이런 구조에서는 사용자가 실제로 “50페이지”보다 “계속 다음 것”을 보는 경우가 많습니다. 그럴 때 cursor는 UX와 성능이 잘 맞습니다.

즉 cursor의 장점은 단지 새로운 유행 방식이라서가 아니라, DB가 이미 지나간 row를 매번 다시 많이 세지 않게 해준다는 것입니다.


5. 하지만 cursor pagination도 공짜는 아니다

cursor 방식이 항상 정답인 건 아닙니다. 분명한 trade-off가 있습니다.

페이지 번호 UX와 바로 맞지 않는다

사용자는 “7페이지로 이동”을 기대할 수 있지만, cursor는 보통 “다음/이전” 또는 “더 보기”와 더 자연스럽습니다.

정렬 키가 안정적이어야 한다

정렬 기준이 흔들리면 cursor도 쉽게 꼬입니다.

구현이 조금 더 복잡하다

마지막 위치를 인코딩하고, 이전/다음 방향을 관리하고, tie-breaker를 함께 처리해야 합니다.

즉 cursor는 성능 면에서는 강력하지만, 제품 UX와 구현 복잡도까지 같이 고려해야 하는 방식입니다.


6. 안정적인 정렬 키가 왜 중요할까

pagination에서 자주 놓치는 문제가 정렬의 안정성입니다.

예를 들어 created_at 만으로 정렬하면 같은 시각에 생성된 row끼리 순서가 흔들릴 수 있습니다. 그러면:

  • 어떤 row는 두 번 보이고
  • 어떤 row는 빠진 것처럼 보일 수 있습니다

그래서 실무에서는 종종 tie-breaker를 같이 둡니다.

예를 들어:

ORDER BY created_at DESC, id DESC

처럼 정렬 기준을 더 안정적으로 만듭니다.

cursor pagination에서는 이게 특히 중요합니다. 왜냐하면 “마지막으로 본 위치”를 정확히 정의해야 하기 때문입니다.

즉 pagination 성능은 인덱스만의 문제가 아니라, 정렬 순서가 얼마나 결정적이고 재현 가능한가의 문제이기도 합니다.


7. 전체 개수(COUNT(*))는 왜 생각보다 비쌀 수 있을까

페이지 번호 UI가 있으면 보통 “전체 12,842건 / 643페이지” 같은 정보가 필요해집니다. 이때 자연스럽게 COUNT(*) 가 붙습니다.

문제는 실제 목록 조회보다 전체 count가 더 부담스러울 수 있다는 점입니다. 특히:

  • 필터가 복잡하거나
  • 조인이 포함되거나
  • 테이블이 크고
  • 사용자가 목록을 자주 여는 경우

카운트 쿼리가 누적 비용이 되기 쉽습니다.

그래서 실무에서는 종종 아래 선택을 합니다.

  • 정확한 count 대신 대략 count
  • 첫 페이지에서만 count 표시
  • “더 보기” UX로 총 페이지 개수 자체를 없앰
  • 비동기로 count 갱신

즉 pagination 설계는 목록 조회뿐 아니라, 전체 개수 표시 전략과도 같이 봐야 합니다.


8. 인덱스는 pagination에서도 거의 핵심이다

pagination은 결국 정렬과 범위 읽기 문제이기 때문에 인덱스가 매우 중요합니다.

예를 들어:

WHERE user_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 20

같은 패턴이라면, 단순히 user_id 만 인덱스에 있는 것보다 정렬 축까지 함께 고려한 인덱스가 훨씬 유리할 수 있습니다.

여기서 중요한 질문은:

  • 필터와 정렬이 같이 반복되는가
  • deep page 비용이 정렬 축에서 커지는가
  • keyset 조건이 인덱스와 잘 맞는가

입니다.

즉 pagination은 LIMIT/OFFSET 문법보다 정렬 접근 경로를 어떻게 설계했는가에서 성능 차이가 크게 납니다.

인덱스 구조는 MySQL 인덱스 설계 가이드와 함께 보면 자연스럽습니다.


9. deep pagination이 꼭 필요한지 제품 요구를 다시 보는 것도 중요하다

기술적으로는 pagination 튜닝이지만, 실제로는 제품 요구를 다시 보는 편이 더 큰 개선이 되기도 합니다.

예를 들어:

  • 사용자가 정말 500페이지까지 가는가
  • 검색/필터가 있으면 deep page 필요가 줄어드는가
  • “더 보기”나 무한 스크롤이 더 자연스러운가
  • 관리 화면에서만 numbered page가 필요한가

이런 질문이 중요합니다.

왜냐하면 deep pagination이 거의 쓰이지 않는데도 이를 위해 비싼 count와 큰 offset 비용을 계속 감수하는 경우가 꽤 많기 때문입니다.

즉 pagination 성능 문제는 DB 튜닝이면서 동시에 UX 설계 문제이기도 합니다.


10. 실무에서 자주 쓰는 선택 기준

OFFSET이 잘 맞는 경우

  • 데이터 규모가 아직 작다
  • 뒤 페이지 이동이 드물다
  • numbered page UX가 중요하다
  • 운영 비용보다 구현 단순성이 더 중요하다

cursor 또는 keyset이 잘 맞는 경우

  • 데이터 규모가 크다
  • 뒤로 깊게 탐색하는 경우가 많다
  • 무한 스크롤이나 “더 보기”가 자연스럽다
  • 최신순/과거순 탐색이 핵심이다

혼합 전략이 맞는 경우

  • 관리자 화면은 OFFSET
  • 사용자 피드는 cursor

처럼 목적별로 나누는 방식도 흔합니다.

즉 pagination은 하나의 정답보다 화면 성격과 트래픽 패턴에 맞는 전략 분리가 더 현실적일 때가 많습니다.


11. 자주 하는 오해

1. LIMIT 이 있으니 쿼리는 항상 가볍다

큰 OFFSET이 있으면 앞부분 row를 많이 읽고 버리는 비용이 남습니다.

2. pagination은 프론트엔드 문제다

실제로는 정렬, 범위 조회, row read 비용을 좌우하는 DB 접근 문제입니다.

3. cursor pagination은 항상 더 좋다

성능에는 유리할 수 있지만, numbered page UX와는 잘 안 맞을 수 있습니다.

4. created_at 하나로 정렬하면 충분히 안정적이다

동일 timestamp가 많으면 중복/누락처럼 보이는 문제가 생길 수 있어 tie-breaker가 필요할 때가 많습니다.


FAQ

Q. 페이지 번호 UI가 꼭 필요하면 cursor는 못 쓰나요?

완전히 못 쓰는 것은 아닙니다. 다만 구현과 상태 관리가 복잡해지고, 보통은 OFFSET이 페이지 번호 UI와 더 자연스럽습니다.

Q. 뒤 페이지가 느릴 때 무엇부터 봐야 하나요?

OFFSET 크기, 정렬 기준, 정렬과 필터를 받쳐주는 인덱스, 그리고 count 쿼리가 같이 비싼지부터 보는 게 좋습니다.

Q. 작은 서비스도 이 문제를 미리 신경 써야 하나요?

초기에는 OFFSET으로 빠르게 가도 됩니다. 다만 데이터가 빨리 쌓이거나 피드형 UX가 핵심이면 cursor 사고방식을 일찍 익혀두는 편이 좋습니다.


먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.

광고