들어가며
Django의 페이지네이션은 ORM의 기능이 아니라 django.core.paginator.Paginator라는 유틸리티 클래스입니다. 하는 일은 단순합니다. QuerySet을 일정한 크기로 잘라, 요청받은 "몇 번째 페이지"만 꺼내 줍니다.
이 자르기가 가능한 이유는 QuerySet의 슬라이싱이 SQL의 LIMIT / OFFSET으로 번역되기 때문입니다. 이 글에서는 게시판 요청 하나가 Paginator를 거쳐 어떤 SQL이 되는지, 잘못된 페이지 번호는 어떻게 처리되는지, COUNT(*)는 언제 몇 번 실행되는지를 차례로 따라갑니다.
예제 기준 버전: Django 6.1. 여기서 다루는 Paginator 동작은 Django 4.x·5.x에서도 같습니다. 글의 동작과 SQL은 실제 Django 소스를 확인하고 SQLite에서 실행해 검증했습니다.
기본 사용법과 핵심 객체
Paginator에 QuerySet과 페이지 크기를 넘기고, get_page()로 원하는 페이지를 꺼내면 끝입니다.
from django.core.paginator import Paginator
from .models import Post
qs = Post.objects.order_by("-created_at") # 정렬 필수 (이유는 아래에서)
paginator = Paginator(qs, 10) # 한 페이지에 10개
page = paginator.get_page(2) # 2페이지 Page 객체
for post in page: # page.object_list 를 순회
print(post.title)
여기에는 두 종류의 객체가 등장합니다. Paginator는 전체 목록에 대한 정보를, Page는 꺼낸 한 페이지에 대한 정보를 들고 있습니다.
| 객체 | 속성 / 메서드 | 의미 (글 95개, 10개씩, 3페이지 기준) |
|---|---|---|
| Paginator | count |
전체 객체 수 → 95 |
| Paginator | num_pages |
전체 페이지 수 → 10 |
| Paginator | page_range |
range(1, 11) |
| Paginator | get_elided_page_range(n) |
1 … 4 5 6 … 10처럼 중간을 생략한 페이지 번호 |
| Page | object_list |
이 페이지의 객체들 (슬라이스된 QuerySet) |
| Page | number |
현재 페이지 번호 → 3 |
| Page | has_previous() / has_next() |
이전·다음 페이지 존재 여부 → True / True |
| Page | previous_page_number() / next_page_number() |
2 / 4 |
| Page | start_index() / end_index() |
전체 기준 위치 → 21 / 30 |
| Page | paginator |
이 페이지를 만든 Paginator 인스턴스 |
ListView를 쓴다면 paginate_by = 10만 지정해도 같은 일을 해 주고, 템플릿에 page_obj, paginator, is_paginated가 전달됩니다.
정렬이 필요한 이유
정렬되지 않은 QuerySet을 넘기면 Paginator 생성 시점에 UnorderedObjectListWarning 경고가 발생합니다. ORDER BY 없이 OFFSET을 쓰면 DB가 행을 돌려주는 순서가 보장되지 않아, 페이지 사이에 항목이 겹치거나 빠질 수 있기 때문입니다. 모델 Meta.ordering이 지정돼 있으면 정렬된 것으로 봅니다.
시나리오로 따라가기: 요청 하나가 SQL이 되기까지
게시판에 글이 95개 있고 한 페이지에 10개씩 보여 주는 상황입니다. 뷰는 다음과 같습니다.
def post_list(request):
qs = Post.objects.order_by("-created_at")
page = Paginator(qs, 10).get_page(request.GET.get("page"))
return render(request, "posts.html", {"page": page})
{% for post in page %}{{ post.title }}{% endfor %}
{% if page.has_previous %}<a href="?page={{ page.previous_page_number }}">이전</a>{% endif %}
{{ page.number }} / {{ page.paginator.num_pages }}
{% if page.has_next %}<a href="?page={{ page.next_page_number }}">다음</a>{% endif %}
장면 1: 처음 진입 — GET /posts/
URL에 page가 없으니 request.GET.get("page")는 None이고, get_page(None)은 1페이지를 돌려줍니다. 실행되는 SQL은 두 개입니다.
SELECT COUNT(*) FROM post; -- 95 → 총 10페이지
SELECT ... FROM post ORDER BY created_at DESC LIMIT 10; -- 최신 글 1~10번째
1페이지라서 has_previous()가 False이므로 "이전" 링크는 보이지 않습니다.
장면 2: 3페이지 클릭 — GET /posts/?page=3
같은 뷰가 처음부터 다시 실행됩니다. Paginator가 하는 계산은 아래가 전부입니다.
OFFSET = (3 - 1) × 10 = 20
LIMIT = 10
SELECT COUNT(*) FROM post;
SELECT ... FROM post ORDER BY created_at DESC LIMIT 10 OFFSET 20; -- 21~30번째
서버는 사용자가 어디까지 봤는지 기억하지 않습니다. 매 요청마다 URL의 page=3만 보고 다시 계산하며, COUNT(*)도 매번 다시 실행됩니다.
장면 3: 마지막 페이지 — GET /posts/?page=10
남은 글이 5개뿐이므로, Paginator는 슬라이스 끝을 전체 개수(95)로 잘라 qs[90:95]를 만듭니다. 그래서 SQL은 LIMIT 10이 아니라 LIMIT 5입니다.
SELECT ... FROM post ORDER BY created_at DESC LIMIT 5 OFFSET 90; -- 91~95번째
has_next()가 False가 되어 "다음" 링크가 사라집니다.
데이터 쿼리는 언제 실행되나
get_page()가 돌려주는 Page의 object_list는 슬라이스된 QuerySet이라 아직 실행되지 않은 상태입니다. COUNT(*)는 get_page() 안에서 범위를 검증할 때 바로 실행되지만, 데이터 조회 SQL은 템플릿에서 {% for post in page %}로 순회하는 시점에 실행됩니다.
모바일 앱이라면: DRF PageNumberPagination
앱은 HTML 대신 JSON을 받습니다. DRF의 PageNumberPagination을 쓰면 GET /api/posts/?page=3 요청에 다음과 같이 응답합니다.
{
"count": 95,
"next": "https://example.com/api/posts/?page=4",
"previous": "https://example.com/api/posts/?page=2",
"results": [ ... 10개 ... ]
}
앱은 results를 그리고, 다음 페이지가 필요하면 next URL을 그대로 호출합니다. next가 null이면 마지막 페이지입니다. 내부적으로는 DRF도 Django의 Paginator를 사용하므로 SQL은 장면 2와 같습니다.
잘못된 페이지 번호는 어떻게 처리되나
같은 Paginator라도 어떤 메서드로, 어떤 계층에서 부르느냐에 따라 결과가 다릅니다. page()는 예외를 던지고, get_page()는 유효한 페이지로 바꿔 주며, ListView와 DRF는 404를 돌려줍니다.
| 요청 값 | paginator.page(n) |
paginator.get_page(n) |
ListView |
DRF PageNumberPagination |
|---|---|---|---|---|
3 (정상) |
3페이지 | 3페이지 | 3페이지 | 200, 3페이지 |
999 (범위 초과) |
EmptyPage |
마지막 페이지(10) | 404 | 404 |
0, -1 (1 미만) |
EmptyPage |
마지막 페이지(10) | 404 | 404 |
abc (숫자 아님) |
PageNotAnInteger |
1페이지 | 404 | 404 |
last |
PageNotAnInteger |
1페이지 | 마지막 페이지 | 마지막 페이지 |
없음 (None) |
PageNotAnInteger |
1페이지 | 1페이지 | 1페이지 |
표에서 눈여겨볼 점이 두 가지 있습니다.
첫째, get_page(0)은 1페이지가 아니라 마지막 페이지를 돌려줍니다. 1보다 작은 값도 범위 초과와 똑같이 EmptyPage로 처리되고, get_page()는 EmptyPage를 받으면 무조건 num_pages로 대체하기 때문입니다.
def get_page(self, number):
try:
number = self.validate_number(number)
except PageNotAnInteger:
number = 1
except EmptyPage: # 999든 0이든 -1이든
number = self.num_pages
return self.page(number)
둘째, ListView는 내부에서 get_page()가 아니라 page()를 쓰고, InvalidPage를 잡아 Http404로 바꿉니다. 대신 page=last라는 특수 값을 마지막 페이지로 해석해 줍니다. DRF도 범위 밖이나 숫자가 아닌 값에는 404(Invalid page.)를 돌려주고, last는 마지막 페이지로 처리합니다.
정리하면 사람이 보는 HTML 화면에서는 조용히 보정해 주는 get_page()가 편하고, API처럼 잘못된 요청을 명확히 거절해야 하는 곳에서는 page()와 예외 처리를 쓰는 편이 맞습니다.
내부 동작: COUNT로 범위를 검증하고, 인스턴스에 캐시한다
?page=999가 범위를 넘었다는 것을 Django가 아는 이유는 COUNT(*) 결과로 전체 페이지 수를 먼저 계산하기 때문입니다. 그리고 이 값은 Paginator 인스턴스에 캐시되어, 한 인스턴스에서는 COUNT(*)가 한 번만 실행됩니다.
get_page("999")의 실행 순서
validate_number("999")가 값을 정수로 바꿉니다. 실패하면PageNotAnInteger, 1보다 작으면EmptyPage입니다.- 범위를 비교하려고
num_pages에 접근하고,num_pages가count를 읽는 순간SELECT COUNT(*)가 실행됩니다. 95를 10으로 나눠 올림하면 10페이지입니다. - 999 > 10이므로
EmptyPage가 발생합니다. get_page()가 예외를 잡아page(10)을 다시 호출합니다. 이때count는 이미 캐시되어 있어COUNT(*)는 다시 실행되지 않습니다.- 순회 시점에
LIMIT 5 OFFSET 90으로 마지막 페이지 데이터만 조회합니다.
결과적으로 OFFSET 9980 같은 쿼리는 실행되지 않습니다. DB에 가는 것은 COUNT(*) 한 번과 마지막 페이지 조회 한 번뿐입니다.
관련 소스 (Django 6.1 기준, 요약)
class Paginator(BasePaginator):
@cached_property
def count(self):
c = getattr(self.object_list, "count", None)
if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
return c() # QuerySet → SELECT COUNT(*)
return len(self.object_list) # 일반 리스트 → len()
@cached_property
def num_pages(self):
if self.count == 0 and not self.allow_empty_first_page:
return 0
hits = max(1, self.count - self.orphans)
return ceil(hits / self.per_page)
def page(self, number):
number = self.validate_number(number) # 여기서 count 사용
bottom = (number - 1) * self.per_page
top = bottom + self.per_page
if top + self.orphans >= self.count:
top = self.count # 마지막 페이지는 끝을 count로 자름
return self._get_page(self.object_list[bottom:top], number, self)
이 코드에서 세 가지를 읽을 수 있습니다.
- QuerySet을 넘기면
.count()로 DB에서 개수만 셉니다. 반면Paginator(list(qs), 10)처럼 리스트를 넘기면len()을 쓰므로, 리스트로 바꾸는 순간 전체 행을 메모리에 올리게 됩니다. - 글이 0개일 때는
max(1, ...)덕분에num_pages가 1이 되어,allow_empty_first_page=True(기본값)이면 빈 1페이지를 에러 없이 보여 줄 수 있습니다. page()도 매번self.count를 참조하므로, 1페이지를 요청하든abc를 넘기든COUNT(*)는 항상 실행됩니다.
cached_property 캐시의 수명
django.utils.functional.cached_property는 처음 접근할 때 함수를 실행하고, 그 결과를 인스턴스의 __dict__에 같은 이름으로 저장합니다. 그 뒤로는 일반 속성처럼 __dict__에서 바로 꺼내므로 함수가 다시 실행되지 않습니다.
paginator = Paginator(qs, 10)
paginator.count # SELECT COUNT(*) 실행 → __dict__["count"] = 95
paginator.count # 쿼리 없음
paginator.num_pages # count 캐시 사용 → 쿼리 없음 (num_pages도 캐시됨)
템플릿의 {{ page.paginator.num_pages }}도 뷰에서 만든 바로 그 인스턴스를 가리키므로 추가 쿼리가 없습니다. 실제로 한 인스턴스에서 count, num_pages, get_page(999), page(2)를 연달아 호출해도 COUNT(*)는 한 번만 실행됩니다.
다만 캐시의 수명은 "요청"이 아니라 "인스턴스"입니다. 보통 뷰 함수 안에서 요청마다 Paginator를 새로 만들기 때문에 결과적으로 요청 단위처럼 동작하는 것입니다.
요청 A (?page=2) → Paginator#1 생성 → COUNT 1회 → 응답 → 인스턴스 소멸
요청 B (?page=3) → Paginator#2 생성 → COUNT 1회 → 응답 → 인스턴스 소멸
Paginator를 모듈 전역에 하나 만들어 재사용하는 것은 피해야 합니다. count가 처음 값으로 고정되어 새 글이 생겨도 페이지 수가 바뀌지 않습니다.
요청을 넘어 COUNT를 아끼고 싶다면
count를 오버라이드해 Django 캐시 프레임워크(Redis, Memcached 등)에 잠깐 저장하면, 여러 요청이 하나의 COUNT(*) 결과를 공유할 수 있습니다.
from django.core.cache import cache
from django.core.paginator import Paginator
from django.utils.functional import cached_property
class CachedCountPaginator(Paginator):
def __init__(self, *args, cache_key, timeout=60, **kwargs):
super().__init__(*args, **kwargs)
self.cache_key = cache_key
self.timeout = timeout
@cached_property
def count(self):
value = cache.get(self.cache_key)
if value is None:
value = super().count # 캐시에 없을 때만 실제 COUNT(*)
cache.set(self.cache_key, value, self.timeout)
return value
paginator = CachedCountPaginator(qs, 10, cache_key="post_list_count", timeout=60)
이렇게 하면 60초 동안 1, 2, 3페이지를 각각 다른 요청(다른 인스턴스)으로 조회해도 COUNT(*)는 처음 한 번만 실행됩니다. 인스턴스 안의 cached_property와 프로세스 밖의 캐시가 두 단계로 동작하는 구조입니다.
대신 두 가지 대가가 있습니다.
- 새 글이 생겨도 페이지 수는 최대
timeout만큼 늦게 반영됩니다. 그 사이 마지막 페이지 근처에서는 실제 개수와 맞지 않는 결과가 나올 수 있습니다. - 검색어나 필터가 붙는 목록이라면 조건마다 캐시 키를 달리해야 합니다. 키가 같으면 다른 조건의 개수를 잘못 가져다 씁니다.
검색·필터·정렬이 붙은 QuerySet
Paginator는 넘겨받은 QuerySet을 그대로 .count()하고 슬라이싱할 뿐이므로, 검색 조건과 정렬은 COUNT와 데이터 쿼리 양쪽에 그대로 들어갑니다. count, num_pages, 범위 검증 모두 "검색 결과 기준"으로 계산됩니다.
qs = Post.objects.filter(title__icontains="django").order_by("-created_at")
page = Paginator(qs, 10).get_page(3)
SELECT COUNT(*) FROM post WHERE title LIKE '%django%';
SELECT ... FROM post WHERE title LIKE '%django%'
ORDER BY created_at DESC LIMIT 10 OFFSET 20;
Paginator 자체는 문제가 없지만, 검색이나 정렬이 붙을 때 실수하기 쉬운 지점이 다섯 가지 있습니다.
1. 페이지 링크에서 검색 조건이 빠지는 문제
가장 흔한 버그입니다. 서버는 상태를 기억하지 않으므로, ?q=django&page=1에서 다음 링크를 ?page=2로만 만들면 2페이지에서는 검색 조건이 사라진 전체 목록이 나옵니다. Django 5.1부터 제공되는 querystring 템플릿 태그를 쓰면 기존 파라미터를 유지한 채 page만 바꿀 수 있습니다.
<a href="{% querystring page=page.next_page_number %}">다음</a>
{# 현재 URL이 ?q=django&sort=-likes&page=2 이면 → ?q=django&sort=-likes&page=3 #}
2. 정렬 기준에 동점이 있는 경우
order_by("-likes")처럼 값이 겹칠 수 있는 필드로만 정렬하면, 좋아요 수가 같은 글들 사이의 순서는 DB가 보장하지 않습니다. 그러면 같은 글이 1페이지와 2페이지에 모두 나오거나 빠질 수 있습니다. 이때는 정렬이 있으므로 UnorderedObjectListWarning도 뜨지 않습니다. 마지막에 고유한 필드를 붙여 순서를 확정하면 해결됩니다.
qs = qs.order_by("-likes", "-id")
3. 사용자가 정렬 기준을 고르는 경우
?sort= 값을 order_by()에 그대로 넣으면 없는 필드일 때 FieldError로 500 에러가 나고, 노출하고 싶지 않은 필드로도 정렬할 수 있습니다. 허용 목록으로 거르는 게 안전합니다.
ALLOWED_SORTS = {
"latest": ("-created_at", "-id"),
"popular": ("-likes", "-id"),
}
ordering = ALLOWED_SORTS.get(request.GET.get("sort"), ALLOWED_SORTS["latest"])
qs = qs.order_by(*ordering)
4. 다대다 조건으로 행이 중복되는 경우
filter(tags__name__in=["python", "django"])처럼 다대다 관계로 필터링하면 JOIN 때문에 두 태그를 모두 가진 글이 두 번 나옵니다. COUNT도 부풀려져, 실제로는 5개인 글이 10개로 계산됩니다. .distinct()를 붙이면 Django는 SELECT COUNT(*) FROM (SELECT DISTINCT ...) subquery 형태로 중복을 제거한 개수를 셉니다.
5. 검색 조건이 무거울수록 COUNT도 무거워짐
icontains는 LIKE '%...%'가 되어 일반 B-tree 인덱스를 쓰지 못하는데, 이 비용을 COUNT와 데이터 쿼리가 각각 치릅니다. 대용량에서 검색이 느리다면 페이지네이션보다 검색 방식(PostgreSQL 전문 검색, trigram 인덱스 등)을 먼저 검토하는 게 맞습니다. 앞 절의 CachedCountPaginator를 검색 목록에 쓴다면 캐시 키에 검색어와 필터 조건을 반드시 포함해야 합니다.
OFFSET 방식의 한계와 커서 페이지네이션
Paginator의 LIMIT/OFFSET 방식은 단순하고 "5페이지로 바로 가기"가 가능하지만, 데이터가 커지거나 자주 바뀌면 세 가지 문제가 드러납니다.
- 매 요청마다
COUNT(*): 범위 검증과num_pages계산에 필수라서 생략할 수 없습니다. 행이 수백만 건이면 이 쿼리 자체가 병목이 됩니다. - 뒤 페이지일수록 느려짐:
OFFSET 1000000이면 DB는 앞의 100만 행을 읽고 버린 뒤에야 결과를 돌려줍니다. - 목록이 바뀌면 중복·누락: 1페이지를 본 뒤 새 글이 하나 올라오면 모든 글이 한 칸씩 밀립니다. 그 상태로 2페이지를 요청하면 방금 1페이지 마지막에 본 글이 2페이지 첫 글로 또 나옵니다. 삭제가 일어나면 반대로 한 글을 건너뜁니다.
커서(keyset) 페이지네이션
커서 방식은 "몇 개를 건너뛸지" 대신 "마지막으로 본 값 다음부터"를 요청합니다. DRF의 CursorPagination이 이 방식을 제공합니다.
from rest_framework.pagination import CursorPagination
class PostCursorPagination(CursorPagination):
page_size = 10
ordering = "-created" # 기본값도 "-created"
첫 요청과 next 커서를 따라간 두 번째 요청에서 실제로 생성된 SQL은 다음과 같습니다.
-- GET /api/posts/
SELECT ... FROM post ORDER BY created DESC LIMIT 11;
-- GET /api/posts/?cursor=cD04Ng%3D%3D (p=86: 마지막으로 본 값)
SELECT ... FROM post WHERE created < 86 ORDER BY created DESC LIMIT 11;
COUNT(*)도 OFFSET도 없습니다. LIMIT 11은 10개를 보여 주고 1개를 더 읽어 다음 페이지가 있는지 확인하는 용도입니다. WHERE created < 86은 정렬 필드에 인덱스가 있으면 바로 그 위치로 찾아가므로, 몇 번째 페이지든 속도가 거의 같습니다. 앞에 새 글이 생겨도 기준 값이 고정돼 있어 중복이 생기지 않습니다.
대신 응답에는 next, previous, results만 있고 count가 없습니다. 전체 페이지 수를 알 수 없고 "5페이지로 바로 가기"도 불가능합니다. 또 DRF 문서는 정렬 기준으로 고유하고 값이 바뀌지 않는 필드(생성 시각, 자동 증가 ID 등)를 쓰도록 권장합니다.
| 기준 | Paginator / PageNumberPagination |
CursorPagination |
|---|---|---|
| 요청 형태 | ?page=3 |
?cursor=<불투명 문자열> |
| 실행 SQL | COUNT(*) + LIMIT/OFFSET |
WHERE 정렬필드 < 값 + LIMIT |
| 전체 개수·페이지 수 | 제공 | 제공 안 함 |
| 특정 페이지로 바로 이동 | 가능 | 불가능 |
| 깊은 페이지 성능 | OFFSET만큼 느려짐 | 일정 |
| 목록 변경 시 중복·누락 | 발생 가능 | 발생하지 않음 |
| 잘 맞는 화면 | 관리자 목록, 페이지 번호가 있는 게시판 | 무한 스크롤 피드, 대용량 API |
정리
Django의 페이지네이션은 "페이지 번호를 LIMIT/OFFSET으로 바꾸고, COUNT(*)로 범위를 검증하는 도우미"입니다.
- 클라이언트는 페이지 번호만 보내고, 서버는 매 요청마다
(번호 - 1) × 크기만큼 건너뛰어크기만큼 잘라 돌려줍니다. 마지막 페이지는 남은 개수만큼만 조회합니다. - 범위를 검증하려면 전체 개수가 필요하므로, 요청마다
COUNT(*)가 실행됩니다. 이 값은cached_property로 Paginator 인스턴스 하나에 한 번만 계산됩니다. - 데이터 조회 SQL은
Page를 순회하는 시점에 실행되고, 범위를 벗어난 번호로는 데이터 쿼리가 나가지 않습니다. - 잘못된 번호는
get_page()가 보정하고(1 미만·범위 초과 → 마지막 페이지, 숫자 아님 → 1페이지),page()는 예외를,ListView와 DRF는 404를 돌려줍니다. - 검색·필터·정렬은
COUNT와 데이터 쿼리에 그대로 반영됩니다. 페이지 링크에 검색 조건을 유지하고, 고유한 필드를 덧붙여 정렬을 확정하는 것이 핵심입니다. - 대용량이거나 자주 바뀌는 목록, 무한 스크롤에는
COUNT와OFFSET이 없는 커서 페이지네이션이 더 잘 맞습니다.
검증 환경: Django 6.1.3, Django REST framework 3.18.3, SQLite. SQL은
CaptureQueriesContext로 캡처한 실제 쿼리에서 컬럼 목록만...로 줄였습니다.
'Django' 카테고리의 다른 글
| [Django] 모델 필드의 null=True와 blank=True 조건 (0) | 2025.04.05 |
|---|---|
| [Django] select_related와 prefetch_related 비교 (0) | 2025.02.21 |
| [Django] Django Debug Toolbar의 윈도우 환경 MIME type 오동작 해결 (0) | 2025.02.20 |
| [Django] CreateView (0) | 2025.02.04 |
| [Django] PostgreSQL 연동하기 (2) | 2025.02.04 |
