오늘의 목표
질문 상세 조회 api PR
질문 상세 조회 api 머지 후 질문 수정 api 부족한 부분 찾기
메인프로젝트를 하는 과정에서 알게 된 내용 정리
class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
category = serializers.IntegerField()
category_id = serializers.IntegerField()
from apps.qna.models import QuestionCategory
class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all()
)
...
# 요청값
{
"title": "제목",
"content": "내용",
"category": 3
}
validated_data["category"] 는 int(3)이 아니라 QuestionCategory 객체validated_data["category"] 는 int가 아니라 QuestionCategory 인스턴스category = serializer.validated_data["category"]class QuestionAuthorSerializer(serializers.Serializer[dict[str, object]]):
nickname = serializers.CharField()
profile_image_url = serializers.CharField(allow_null=True)
class QuestionListSerializer(serializers.ModelSerializer[Question]):
question_id = serializers.IntegerField(source="id")
def get_category(self, obj: Question) -> CategoryPath:
def get_category_path(self, obj: Question) -> str:
로 잇는다 | / 로 잇는다 | breadcrumb로 보여준다 | 줄바꿈으로 보여준다
- 전부 UI결정
def get_category_path(self, obj: Question) -> str:
return build_category_path(obj.category)["path"]
class CategoryInfo(TypedDict):
id: int
depth: int
names: list[str]
def build_category_info(category: QuestionCategory) -> CategoryInfo:
names: list[str] = []
current = category
while current is not None:
names.append(current.name)
current = current.parent
names.reverse()
return {
"id": category.id,
"depth": len(names) - 1,
"names": names,
}
# 결과
백엔드 (id=1, parent=None)
└─ Django (id=2, parent=1)
└─ ORM (id=3, parent=2)
{
"id": 3,
"depth": 2,
"names": ["백엔드", "Django", "ORM"]
}
profile_img_url = serializers.CharField(
source="author.profile_image_url",
allow_null=True,
)
nickname = serializers.CharField(source="author.nickname")
원인
class AuthorSerializer(serializers.Serializer):
nickname = serializers.CharField()
profile_img_url = serializers.CharField(source="profile_image_url", allow_null=True)
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
"id",
"nickname",
"profile_image_url",
]
class Answer(TimeStampedModel):
author = FK(User)
question = FK(Question)
content
is_adopted
class AnswerComment(TimeStampedModel):
author = FK(User)
answer = FK(Answer)
content
class Question(TimeStampedModel):
author = FK(User)
category = FK(QuestionCategory)
title
content
view_count
def get_images(self, obj: Question) -> list[str]:
return [img.img_url for img in obj.images.all()]
# Swagger
"images": "string" or "images": []
SerializerMethodField ❌ / images: List[str] ❌class QuestionListQuerySerializer(serializers.Serializer[dict[str, object]]):
page = serializers.IntegerField(min_value=1, default=1)
size = serializers.IntegerField(min_value=1, max_value=50, default=10)
search_keyword = serializers.CharField(required=False, allow_blank=True)
category_id = serializers.IntegerField(required=False, min_value=1)
answer_status = serializers.ChoiceField(
choices=["answered", "unanswered"],
required=False,
)
sort = serializers.ChoiceField(
choices=["latest", "oldest", "views"],
required=False,
default="latest",
)
IntegerField(min_value=1) / ChoiceField
paginator = Paginator(annotated_qs, size)
if paginator.count == 0:
raise QuestionListEmptyError()
# 결과가 0건일 경우 → 예외 없이 빈 리스트 반환
if paginator.count == 0:
return [], {
"page": page,
"page_size": size,
"total_pages": 0,
"total_count": 0,
}
try:
page_obj = paginator.page(page)
object_list = list(page_obj.object_list)
current_page = page
except (EmptyPage, PageNotAnInteger):
object_list = []
current_page = page
return object_list, {
"page": current_page,
"page_size": size,
"total_pages": paginator.num_pages,
"total_count": paginator.count,
}
# apps/qna/pagination.py
from rest_framework.pagination import PageNumberPagination
class QuestionPageNumberPagination(PageNumberPagination):
page_size = 10
page_size_query_param = "size"
max_page_size = 50
from apps.qna.pagination import QuestionPageNumberPagination
def get(self, request: Request) -> Response:
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 목록 조회")["error_detail"]
# Query 파싱 (엄격 검증 X)
query_serializer = QuestionListQuerySerializer(data=request.query_params)
query_serializer.is_valid(raise_exception=True)
# QuerySet만 받아옴 (pagination 없음)
queryset = get_question_list(**query_serializer.validated_data)
# DRF Pagination 적용
paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)
# Serializer
serializer = QuestionListSerializer(page, many=True)
# DRF 표준 응답
return paginator.get_paginated_response(serializer.data)
# 전체 흐름
Request
↓
QuerySerializer (query 파싱)
↓
Service (QuerySet 반환)
↓
DRF PageNumberPagination 🔴 여기서 빈리스트
↓
Response (results: [])
# 상세
1. 서비스 단계
queryset = get_question_list(...)
필터 조건 불일치 / 검색결과X / 존재하지 않는 카테고리 -> 결과: 빈 쿼리셋
2. View에서 pagination 적용
paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)
2-1. DRF 내부에서 -> 여기서 리스트가 됨
if queryset is empty:
page = []
3. return paginator.get_paginated_response(serializer.data)
DRF가 자동으로 만들어주는 응답
{
"count": 0,
"next": null,
"previous": null,
"results": []
}
def get(self, request: Request, question_id: int) -> Response:
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]
if question_id <= 0:
class QuestionCreateSerializer(serializers.ModelSerializer):
category_id = serializers.IntegerField()
# 따라서 서비스에서 아래 처럼 처리
category = get_category_or_raise(serializer.validated_data["category_id"])
class QuestionCreateSerializer(serializers.ModelSerializer):
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all()
)
class Meta:
model = Question
fields = ["title", "content", "category"]
{
"count": 53, # 전체 데이터 개수
"next": "http://api.example.com/questions?page=2", # 다음 페이지 URL
"previous": null, # 이전 페이지 URL
"results": [ # 실제 데이터 리스트
{
"question_id": 1,
"title": "DRF 질문입니다"
}
]
}
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
}
from rest_framework.pagination <import PageNumberPagination
class QuestionPagination(PageNumberPagination):
page_size = 10
page_size_query_param = "size" # ?size=20
max_page_size = 100
page_query_param = "page" # 기본값
# APIView
from rest_framework.views import APIView
from rest_framework.response import Response
class QuestionListAPIView(APIView):
pagination_class = QuestionPagination
def get(self, request):
qs = Question.objects.all().order_by("-created_at")
paginator = self.pagination_class()
page = paginator.paginate_queryset(qs, request)
serializer = QuestionListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
# GenericAPIView
class QuestionListAPIView(ListAPIView):
queryset = Question.objects.all()
serializer_class = QuestionListSerializer
pagination_class = QuestionPagination
# urls.py
path("questions/<int:question_id>/", QuestionDetailAPIView.as_view())
| 요청 | 결과 |
|---|---|
/questions/abc/ | ❌ 매칭 안 됨 → 404 |
/questions/-1/ | ❌ 매칭 안 됨 → 404 |
/questions/0/ | ❌ 매칭 안 됨 → 404 |
/questions/1/ | ✅ question_id = 1 |
comments = AnswerCommentSerializer(
source="comments",
many=True
)
comments = AnswerCommentSerializer(many=True)
[ 🔴 문제: 테스트 실행 시 질문 상세 조회 API에서 500 에러 발생 또는 테스트 실패가 발생 ]
AttributeError: Cannot find 'answer_comments' on Answer object 발생
[ 🟡 원인: ORM related_name 불일치 ]
팀원이 따로 전달사항 없이 모델을 변경하여 related_name가 변경
그걸 모르고 이전처럼 .prefetch_related("answer_comments__author") 사용하여
존재하지 않는 역참조 이름을 사용하여 Django ORM이 Answer 객체에서 answer_comments를 찾지 못해 AttributeError: Cannot find 'answer_comments' on Answer object 오류 발생
[ 🔵 해결: selector에서 올바른 related_name 사용 ]
.prefetch_related("comments__author") 처럼 변경된 모델의 related_name에 맞게 작성
[ 🔴 문제: AttributeError: 'NoneType' object has no attribute 'method' ]
def get_authenticators(self):
if self.request.method == "GET":
return []
서버는 정상 실행은 되었지만 Swagger 페이지 접속 시 바로 에러
딱 Swagger만 터짐
[ 🟡 원인: Swagger는 실제 HTTP 요청 없이 View를 분석하는데,
그 과정에서 self.request가 없는 상태로 get_authenticators()를 호출 ]
스웨거는 실제 http요청이 없어서 request 객체 생성안함 즉, self.request 없음 (None)
if self.request.method == "GET":
Swagger 시점에서 self.request == None 즉, 버그가 아닌 설계 문제
get_authenticators()는 request가 항상 있다고 가정하면 안되는 메서드
[ 🔵 해결: None-safe 처리 ]
def get_authenticators(self):
request = getattr(self, "request", None)
if request and request.method == "GET":
return []
return super().get_authenticators()
Swagger 단계: request is None → 조건 통과 ❌ → 에러 ❌
실제 요청 단계: request 존재 → 정상 분기
# 실제 요청이 들어오는 경우 순서
## 이 경우 self.request.method가 항상 존재
URL 매칭
→ APIView 인스턴스 생성
→ request 객체 생성
→ self.request 세팅
→ get_authenticators()
→ 인증
→ get_permissions()
→ handler(get/post/...)
# Swagger(Schema Generator)의 동작 방식
서버 실행 중
→ 모든 View를 훑으면서
→ "이 API는 어떤 인증을 쓰지?"
→ "어떤 메서드를 지원하지?"
→ 구조만 분석
- 이때는 실제 HTTP 요청 ❌ / request 객체 생성 ❌ / self.request ❌ (None)
- 그런데도 Swagger는 내부적으로 view.get_authenticators() 호출