사용자 정의 페이지네이션#

Django Ninja 입문 가이드의 25번째 글이에요.
이전 글에서는 Django Ninja의 내장 페이지네이터를 소개하고 그것을 사용해 간단한 페이지네이션 기능을 구현해 보았어요.
내장된 PageNumberPagination이 정말 편리하긴 하지만 실무에서는 종종 몇 가지 커스터마이징 기능이 필요할 때가 있어요.
이러한 목적을 달성하기 위해서는 사용자 정의(커스텀) 페이지네이션 클래스를 만들어야 해요.
하지만 걱정하지 마세요. 처음부터 새로 만드는 것이 아니에요. Django Ninja가 제공하는 기본 페이지네이션 클래스를 상속받아서 약간의 '가공'만 거치면 된답니다.
이번 글에서는 그 방법을 알려드릴게요.
이번 글의 모든 코드 변경 사항은 이 PR을 참고해 주세요.
GitHub 예제 프로젝트#
커스터마이징 요구사항#
기본 페이지네이션 외에도 우리는 다음과 같은 기능이 가능하기를 원해요:
- 클라이언트가 직접 페이지당 표시할 데이터 개수를 선택할 수 있게 하고, 그 선택 범위를 1에서 100 사이로 제한해요.
- 응답 내용에 두 개의 필드를 추가하여 현재의 페이지 정보를 표시해요:
- 현재 페이지 번호 (
page) - 페이지당 표시 개수 (
per_page)
- 현재 페이지 번호 (
이건 의심할 여지 없이 아주 흔한 요구사항이에요. 사용자 정의 페이지네이션 클래스를 통해 이 기능들을 구현해 볼게요.
말은 이쯤 하고, 바로 시작해 볼까요!
구현: 사용자 정의 페이지네이션 클래스#
페이지네이터(페이지네이션 클래스)는 보통 프로젝트 전체에서 사용되므로 Django app 디렉터리 안에 두는 것은 적절하지 않아요. 그렇다고 exception handler처럼 프로젝트의 api.py에 두면 순환 참조(circular import)가 발생할 수 있어요.
그래서 프로젝트 디렉터리인 NinjaForum에 pagination.py라는 새로운 Python 모듈을 만들었어요.
이 새 모듈 안에 CustomPagination이라는 이름의 페이지네이션 클래스를 작성해 줘요:
from typing import Any
from django.db.models.query import QuerySet
from ninja import Field, Schema
from ninja.pagination import PaginationBase
class CustomPagination(PaginationBase):
class Input(Schema):
page: int = Field(1, ge=1)
per_page: int = Field(10, ge=1, le=100)
class Output(Schema):
items: list
page: int = Field(examples=[1])
per_page: int = Field(examples=[10])
total: int = Field(examples=[100])
def paginate_queryset(
self,
queryset: QuerySet,
pagination: Input,
**params: Any,
) -> dict[str, Any]:
start = (pagination.page - 1) * pagination.per_page
end = start + pagination.per_page
return {
'items': queryset[start:end],
'page': pagination.page,
'per_page': pagination.per_page,
'total': queryset.count(),
}
이 페이지네이션 클래스는 쿼리 매개변수(query parameters)인 page와 per_page를 통해 페이지의 크기와 번호를 결정할 수 있게 해주고, 응답에 동일한 이름의 두 필드가 추가되어 페이지 관련 부가 정보를 제공하게 돼요.
사용자 정의 페이지네이션 클래스 해설#
코드가 좀 길어 보여도 자세히 읽어보면 이해하기 어렵지 않다는 걸 알 수 있을 거예요.
지면 관계상 몇 가지 핵심만 짚고 넘어갈게요.
핵심 1: 전체 구조는 상속받은 클래스 PaginationBase에서 옴#
첫 번째로 들 법한 의문은 "아니, 페이지네이션 클래스를 이렇게 작성해야 한다는 걸 어떻게 알 수 있죠?"일 거예요.
맞아요. 우리는 당연히 모르죠. 그래서 공식 문서와 소스 코드를 봐야 해요.
공식 문서를 통해 PaginationBase라는 클래스를 상속해야 한다는 점을 알 수 있어요. 하지만 문서 설명이 약간 부족하므로 구체적인 정보는 소스 코드를 살펴봐야 해요.
그런 다음 클래스 안의 일부 속성이나 메서드를 모방하고 오버라이딩(overriding)하면 돼요. 대략 이런 식이죠.
핵심 2: Input Schema#
딱 봐도 이 Schema는 페이지네이션과 관련된 URL 쿼리 매개변수를 정의하고 검증하는 데 사용된다는 것을 아실 수 있을 거예요.게다가 Input 클래스는 인자로 paginate_queryset 메서드에 전달되어 페이지네이션 로직을 구현하는 일부분으로 쓰여요.
Input 안의 속성 하나하나가 (페이지네이션 전용) 쿼리 매개변수 하나를 의미하고, 덧붙여 Field를 사용해 세부 사항을 설정할 수 있답니다!
여기 쓰인 Field는 Pydantic의 Field로, 18편에서 자세히 소개한 바 있어요. 각 매개변수마다 기본값(default), 문서 예제, 그리고 기본적인 검증 규칙을 설정할 수 있게 해줘요.
이 예제에서 page의 기본값은 1이고 1 이상이어야 해요. per_page의 기본값은 10이고 1에서 100 사이여야 하죠. 이렇게 하면 페이지네이션 매개변수가 항상 합리적인 범위 내에 있도록 보장할 수 있어요.
동일한 이치가 Output에도 적용되어 HTTP 응답이 가져야 할 포맷, 즉 페이지네이션 응답의 Schema를 결정하게 돼요.
핵심 3: paginate_queryset 메서드#
이 메서드는 모든 페이지네이션 클래스의 핵심으로, 구체적인 페이지네이션 로직을 구현해요.
첫 번째 매개변수가 self인 것으로 보아 이 메서드가 "인스턴스 메서드"임을 알 수 있어요.
가장 주목해야 할 부분은 두 번째 매개변수인 queryset이에요. 이것은 사실 뷰 함수의 return 값이며 타입은 반드시 QuerySet이어야 해요.
paginate_queryset은 우리가 친숙한 "슬라이싱과 인덱싱"을 이용해 전달받은 QuerySet을 "자릅니다". 이것은 Django가 QuerySet을 위해 자체적으로 구현한 기능이며 동작 자체는 파이썬의 list나 tuple 같은 컨테이너와 유사해요.
이것이 클라이언트로 반환될 때 우리는 슬라이싱된 QuerySet과 사용자 정의된 응답 포맷을 얻게 돼요.
사용자 정의 페이지네이션 테스트#
위의 커스텀 클래스를 다 작성했다면 뷰 함수에는 단지 @paginate(CustomPagination) 한 줄만 추가하면 돼요. 코드 부분은 생략할게요.
바로 결과를 확인해 봅시다! 쿼리 매개변수로 /?page=2&per_page=5(2페이지, 페이지당 5개)를 입력했어요:

매우 이상적이네요!
그럼 페이지당 표시 개수를 100이 넘도록 설정하면 어떻게 될까요?
{
"detail": [
{
"type": "less_than_equal",
"loc": [
"query",
"per_page"
],
"msg": "Input should be less than or equal to 100",
"ctx": {
"le": 100
}
}
]
}
정답은 422 에러 응답이에요.
페이지네이션 기능 요약#
이 두 편의 글을 통해 Django Ninja에서 내장된 간단한 방법부터 복잡한 커스텀 페이지네이션 클래스까지 페이지네이션을 구현하는 방법을 살펴보았어요.
프로젝트의 필요에 따라 가장 적합한 페이지네이션 전략을 선택하여 모든 응답이 사용자에게 가장 알맞은 방식으로 전달되도록 해 보세요.
'다중 상태 코드 응답'이 실용적이지 않은 이유는?#
우리가 13편과 21편에서 남겨둔 복선을 기억하시나요?
<13편: 응답(1) Django Ninja HTTP 응답 처리>에서 제가 이렇게 말했죠:
하지만 제 생각에 이 '다중 상태 코드 응답' 설정은 실무에서 별로 실용적이지 않아요. 왜 그럴까요? 그건 나중에 얘기해 볼게요.
잠깐 복습해 볼까요? '다중 상태 코드 응답'이란 이런 사용법을 말해요:
그리고 뷰 함수 안에서 조건에 따라 각기 다른 return을 주는 거죠.
<21편: 오류 처리(상) HttpError와 사용자 정의 HTTP 응답>에서도 말했어요:
이런 방식은 좋아 보이고 꽤 직관적이에요. 예전에 Django REST framework를 쓸 때는 항상 이렇게 작성했으니까요.
하지만 이 방식은 Django Ninja에서 '페이지네이션 데코레이터'를 사용할 때 난관에 부딪히게 돼요.
지금은 때가 아니니, 나중에 <25편: 페이지네이션(하) 사용자 정의 페이지네이션 클래스>에서 이 점을 명확히 짚어볼게요.
바로 그 때가 왔어요!
이유는 아주 간단해요. 핵심은 이번 글의 "핵심 3: paginate_queryset 메서드" 중 이 문장에 있어요:
가장 주목해야 할 부분은 두 번째 매개변수인
queryset이에요. 이것은 사실 뷰 함수의 return 값이며 타입은 반드시 QuerySet이어야 해요.
왜냐하면 paginate_queryset 메서드에서 두 번째 매개변수 타입은 반드시 QuerySet이어야 하거든요!
우리는 paginate_queryset 내부에서 이 매개변수를 QuerySet으로 간주하고 다뤄요. 만약 전달된 값이 QuerySet이 아니라면 페이지네이션 로직은 에러를 발생시킬 거예요.
'다중 상태 코드 응답'과 paginate_queryset 메서드의 충돌#
하지만 다중 상태 코드 응답일 경우 return 타입이 QuerySet이 아닐 수 있어요 - 십중팔구 tuple이겠죠.
간단한 예시를 하나 들어보면 이해가 갈 거예요. "게시글 목록 조회" API를 다음과 같이 수정해 볼게요:
@api.get(
path="/posts",
response={200: list[PostResponse], 404: ErrorMessage}
)
@paginate(CustomPagination)
def get_posts(...) -> QuerySet[Post] | tuple[int, dict]:
posts = Post.objects.all()
if not posts.exists():
return 404, {"message": "조건에 맞는 게시글을 찾을 수 없습니다"}
return posts
이 예시는 '다중 상태 코드 응답'과 페이지네이터 사이의 충돌을 아주 잘 보여줘요:
- 조회 결과가 정상일 때, 뷰 함수는 QuerySet(즉
posts)을 반환하고 이 값이 페이지네이터에 전달되어 아무 문제 없이 동작해요. - 게시글을 찾지 못했을 때, 뷰 함수는
tuple을 반환하려 시도해요. Django Ninja의 "200이 아닌 응답"에는 반드시 상태 코드가 필요하므로 QuerySet이 아니라tuple이 되는 거죠.
이 경우 paginate_queryset 메서드는 QuerySet을 받을 것으로 예상하고 후속 작업도 그렇게 전제하기 때문에 결국 에러가 나게 돼요.
만약 프로젝트 내의 모든 API에 페이지네이션이 전혀 없다면, '다중 상태 코드 응답'을 사용하여 '200이 아닌 응답'을 처리하는 것은 완전히 실현 가능해요.
하지만 단 하나의 API라도 페이지네이션이 필요하다면, 그 페이징된 API는 위와 같은 충돌을 피하기 위해 21편에서 언급한 방식인 raise HttpError로 바꿔 써야 해요.
그리고 프로젝트 전체의 일관성을 고려하면, 나머지 API들도 동일하게 raise HttpError 방식을 채택하는 실천을 하는 것이 좋아요.
게다가 페이지네이션 요구사항은 너무나 흔하기 때문에, 결국 '다중 상태 코드 응답'은 계륵이 되어버리는 거죠.