user = cast(User, request.user)typing.cast 함수는 정적 타입 검사기(Mypy, Pyright 등)와 request.user)는 지금부터 무조건 User 클래스의 AnonymousUser일 수도 있기 때문에user.nickname)를 자동으로 추천해주지 못할 때가 많음cast는 강제로 타입을 규정하는 것이라request.user가 익명 사용자(AnonymousUser)인 상태에서 user.nickname 같은 속성에 접근할 때 비로소 AttributeError가 발생page_size = 10page_size_query_param = "size"max_page_size = 50class PostPageNumberPagination(PageNumberPagination):
"""
프로젝트 전역에서 사용할 커스텀 페이지네이션 클래스입니다.
페이지 범위를 벗어난 요청 시 에러 대신 빈 리스트를 반환하도록 설계되었습니다.
"""
page_size = 10 # 기본 페이지 당 데이터 개수
page_size_query_param = "size" # 클라이언트가 size 파라미터로 개수 조절 가능
max_page_size = 50 # 최대 페이지 당 데이터 개수 제한
def paginate_queryset(...):
...
def get_paginated_response(...):
return Response({...})
def paginate_queryset(
self,
queryset: QuerySet[Any],
request: Request,
view: Optional[APIView] = None,
) -> list[Any] | None:
try:
# DRF의 부모 클래스가 제공하는 기본 페이지네이션 로직을 먼저 수행
# 정상적인 페이지 요청(예: 100개 데이터 중 1페이지 요청)이라면 여기서 결과가 반환됨
return super().paginate_queryset(queryset, request, view)
# 만약 요청한 페이지에 데이터가 없을 때(NotFound 예외 발생 시) 아래 로직을 수행
# ex. 전체 2페이지까지 있는데 사용자가 5페이지를 요청한 경우
except NotFound:
# URL 파라미터에서 'page' 값을 가져오고, 없으면 기본값 '1'을 사용
page_param = request.query_params.get(self.page_query_param, "1")
try:
# 가져온 page 값이 숫자인지(예: ?page=abc) 검사
page_number = int(page_param)
except (TypeError, ValueError):
raise ValidationError("page는 정수여야 합니다.")
# page 번호가 0 이하(예: ?page=-1)인지 검사
if page_number <= 0:
raise ValidationError("page는 1 이상이어야 합니다.")
# 현재 설정된 페이지 크기(size)를 가져옴
page_size = self.get_page_size(request)
if page_size is None:
return list(queryset)
paginator = self.django_paginator_class(queryset, page_size)
"""
메타데이터(전체 개수 등)를 유지하기 위한 처리
장고의 기본 Paginator를 생성하여 전체 페이지 정보 등을 계산
"""
self.page = paginator.page(paginator.num_pages)
"""
마지막 페이지 정보를 self.page에 강제로 설정
이렇게 하면 응답 JSON에 'count'(총 개수) 등은 올바르게 포함
"""
self.page.object_list = []
"""
실제 결과물(object_list)은 빈 리스트로 비워서 반환
결과적으로 프론트엔드는 404 에러 대신 '결과가 없는 빈 배열'을 받게 됨
"""
return []
# DRF가 최종적으로 JSON 응답을 만들 때 호출하는 메서드입니다.
def get_paginated_response(self, data):
return Response(
{
# 전체 데이터의 개수를 보냅니다.
"count": self.page.paginator.count,
# 장고의 paginator가 이미 계산해둔 '총 페이지 수'를 프론트엔드로 보냅니다.
"total_pages": self.page.paginator.num_pages,
# 다음 페이지로 가는 API URL을 보냅니다.
"next": self.get_next_link(),
# 이전 페이지로 가는 API URL을 보냅니다.
"previous": self.get_previous_link(),
# 실제 게시글 데이터(리스트)를 보냅니다.
"results": data,
}
)
paginator.get_paginated_response(serializer.data)get_paginated_response 메서드가 호출될 때PageNumberPagination 내부에 정의된 로직에 따라 count : 전체 데이터의 개수next : 다음 페이지로 이동할 수 있는 API URLprevious : 이전 페이지로 이동할 수 있는 API URLresults : 현재 페이지에 해당하는 실제 데이터 목록 (Serializer를 통해 변환된 데이터)class PostAPIView(APIView):
pagination_class = PostPageNumberPagination
@extend_schema(tags=["포스트"], summary="전체 포스트 피드 조회")
def get(self, request: Request):
# 1. 서비스 레이어에서 전체 포스트 목록(QuerySet)을 가져옴
posts = get_global_posts()
# 2. 설정된 페이지네이션 객체를 생성
paginator = self.pagination_class()
# 3. 쿼리셋에 페이지네이션을 적용('범위 초과 시 빈 리스트 반환' 도 내부에서 처리)
page = paginator.paginate_queryset(posts, request, view=self)
# 4. 페이지 결과가 있다면(성공 혹은 커스텀 처리된 빈 리스트) 결과를 반환
if page is not None:
serializer = PostListSerializer(page, many=True)
# 5. 표준화된 페이지네이션 응답 형식(count, next 등 포함)으로 반환
return paginator.get_paginated_response(serializer.data)
return Response(PostListSerializer(posts, many=True).data)
{
"count": 100,
"next": "http://.../?page=2",
"previous": null,
"results": [ { "id": 1, "title": "첫 글" }, ... ]
}
?page=99를 요청한 상황{
"count": 100,
"next": null,
"previous": "http://.../?page=10",
"results": []
}
DRF의 PageNumberPagination 클래스가 기본적으로 제공하는 응답 규격
class PostPageNumberPagination(PageNumberPagination):PageNumberPagination 은 페이지네이션된 결과를 반환할 때
PostPageNumberPagination 클래스에서 get_paginated_response 메서드를 오버라이드(재정의)from rest_framework.response import Response
class PostPageNumberPagination(PageNumberPagination):
...
def get_paginated_response(self, data):
return Response({
'total_count': self.page.paginator.count,
'next_link': self.get_next_link(),
'prev_link': self.get_previous_link(),
'post_list': data
})
def get_post_detail(post_id: int) -> Post:
"""
특정 ID의 게시글을 상세 조회합니다. (삭제되지 않은 글만)
"""
# 1. Post 객체를 가져올 때 작성자(user) 정보를 한 번에 가져오도록(select_related) 최적화합니다.
# 2. deleted_at이 None인(삭제되지 않은) 데이터만 필터링합니다.
return Post.objects.select_related("user").filter(id=post_id, deleted_at__isnull=True).first()