2025/12/22 MainProject - 13

김기훈·2025년 12월 22일

TIL

목록 보기
91/194

오늘의 목표

질문 상세 조회 api PR

질문 상세 조회 api 머지 후 질문 수정 api 부족한 부분 찾기

메인프로젝트를 하는 과정에서 알게 된 내용 정리


오늘 학습 내용 ✅


PR riview

  • 1. PrimaryKeyRelatedField를 사용해서 엔터티 간의 관계를 명확히

class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    category = serializers.IntegerField()
    category_id = serializers.IntegerField()
  • 목적
    • “이 필드는 그냥 int가 아니라 QuestionCategory 엔터티를
      • 참조하는 FK라는 걸 Serializer 레벨에서 명확히 표현하라”
  • 해결
from apps.qna.models import QuestionCategory

class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    category = serializers.PrimaryKeyRelatedField(
        queryset=QuestionCategory.objects.all()
    )
		...

# 요청값
{
  "title": "제목",
  "content": "내용",
  "category": 3
}
  • 코드 분석
    • queryset=QuestionCategory.objects.all()
      • 이 필드가 참조할 수 있는 ‘유효한 대상 후보 집합
      • 클라이언트가 보내는 값은 QuestionCategory 테이블의 PK 값이고,
        • 그 PK는 이 queryset 안에 있는 객체여야만 한다
  • 장점
    • validated_data["category"] 는 int(3)이 아니라 QuestionCategory 객체
    • 그리하여 Service에서 get_category_or_raise(category_id)이 코드 필요없어짐
  • 변경사상의 의미

    • category = serializers.PrimaryKeyRelatedField
      • 이 한줄로 인해 요청에서는 여전히 숫자(id)를 보냄
      • validated_data["category"] 는 int가 아니라 QuestionCategory 인스턴스
        • category = serializer.validated_data["category"]
        • 굳이 한번 더 서비스에서 검증할 필요가 없어짐
      • get_category_or_raise 호출 필요 X
  • 리뷰 질문

    • 카테고리 없음이 원래 404인데
    • 이번에 수정하면서 카테고리는 요청 body의 FK 입력값이기 때문에
      • 존재 여부 검증을 Serializer validation 단계에서 처리하여 400으로 받게 했다.
    • FK 유효성 검증을 Serializer에서 처리했음, 도메인 로직이 아니라 입력 검증으로 봄
  • 내가 한 일

      1. Serializer 수정
      1. Service에서 카테고리 검증 함수 제거
      1. View에서 category 꺼내기 방식 변경

  • 2. 조회 대상 객체가 엔드포인트에서 드러날거기 때문에 기존 필드명 그대로 id로 줘도괜찮지않을까요?

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")
  • 해석
    • 굳이 question_id로 이름을 바꿀 필요가 없다
    • question_id = serializers.IntegerField(source="id")
      • 내부 모델의 id → 외부 응답에서는 question_id 로 의미를 명확히 하려고 바꾼거지만
      • 코치께서 굳이 그럴 필요가 있냐고 물어보신 듯
    • 조회 대상 객체가 엔드포인트에서 이미 드러난다

      • GET /questions/{id} 이러한 맥락 자체가 이미 question을 조회하는 API 라는게 명확
      • 따라서 "id": 1 이렇게 들어가도 클라이언트는 이 id가 뭔지에 대해서 해석하지 않음
        • 엔드포인트가 /questions | 응답 객체가 question이기 때문에
        • id = question의 id 라는게 명확
  • 해결
    • 굳이 question_id 말고 그냥 id 로 하는데 ModelSerializer는 어차피 자동 매핑
    • 따라서 fields에 id만 넣어놔서 별도의 선언은 제거

  • 3. 카테고리의 Depth를 ui에맞게 표기하는건 프론트에서 해야할 일

    • 백엔드는 카테고리의 Depth를 나타내주기만 하면 좋을 것
def get_category(self, obj: Question) -> CategoryPath:
def get_category_path(self, obj: Question) -> str:
  • 리뷰의 원인
    • build_category_path는 내부에서 "백엔드 > Django > ORM" UI용 문자열을 만들어 줌
    • 로 잇는다 | / 로 잇는다 | breadcrumb로 보여준다 | 줄바꿈으로 보여준다

      • 전부 UI결정
def get_category_path(self, obj: Question) -> str:
    return build_category_path(obj.category)["path"]
  • 해석
    • 백엔드는 카테고리의 Depth를 나타내주기만 하면 좋을 것
      • 백엔드는 가공되지 않은 구조 정보만 내려달라
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"]
}
  • 요약

    • “현재 카테고리가 트리에서 어디에 있는지”를 UI에 종속되지 않은 구조적 데이터로 만들어 준다.

  • 4.작성자에 관한 정보도 Nested Serializer를 사용해서 Author의 Pk, nickname을 함께 내려주자

profile_img_url = serializers.CharField(
    source="author.profile_image_url",
    allow_null=True,
)
nickname = serializers.CharField(source="author.nickname")

  • 원인

    • author 관련 정보가 Serializer 최상위에 흩어져 있음
    • 상세조회에서는 이미 AuthorSerializer를 쓰고 있음(불일치)
  • 5. 모델 시리얼라이저로 변경해서 작성자 Pk인 id와 함께 내려주세요

class AuthorSerializer(serializers.Serializer):  
    nickname = serializers.CharField()
    profile_img_url = serializers.CharField(source="profile_image_url", allow_null=True)
  • 해석
    • 작성자 Serializer를 Serializer 말고 ModelSerializer로 바꾸고
      • 작성자의 PK(id)도 같이 내려달라
  • 원인
    • id 없음 → 작성자 식별 불가
    • Model과 연결 안 됨 → 필드 하나하나 수동 선언
    • 다른 Serializer에서 재사용/확장 불편
  • 4.5 해결
    • 아래의 코드로 한번에 해결
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = [
            "id",
            "nickname",
            "profile_image_url",
        ]

  • 6. 모델 시리얼라이저로 변경해서 id로 내려주기

  • 해석
    • Answer / AnswerComment / Question은 명확한 모델이 있으니
      • 전부 ModelSerializer로 바꾸고, PK는 id 그대로 내려라
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
  • 이유

    • Model이 있는데 Serializer로 다시 정의
    • PK 이름을 굳이 바꿔서 혼란 유발 / Serializer 책임이 커짐
  • 변경 후 장점

    • 모델 ↔ Serializer 1:1 대응
    • PK 규칙 통일 (id)
    • 중첩 구조도 자연스러움
    • 유지보수 비용 감소

  • 7. 질문에 사용된 이미지 링크들도 Nested Serializer를 활용해서 swagger ui 에 함께 반영되도록 하는게 좋습니다

    def get_images(self, obj: Question) -> list[str]:
        return [img.img_url for img in obj.images.all()]

# Swagger
"images": "string" or "images": []
  • 리뷰 이유
    • 이미지가 어떤 구조인지 / 객체인지 / 문자열인지 / 필드명이 뭔지
    • Swagger UI에서는 전혀 알 수 없음
  • 해석
    • SerializerMethodField ❌ / images: List[str] ❌
    • Image 전용 Serializer를 만들고 QuestionSerializer 안에 중첩해서 선언하라

  • 8. 정렬, 조회, 필터링에 사용되는 쿼리파라미터가 반드시 유효해야하는 경우가 아니라면

    • (예를 들어 객체의 수정, 삭제, 생성 등에 활용)
    • 유효한 쿼리파라미터가 아닐경우 빈리스트를 반환해주는게 좋습니다
    • 해당시리얼라이저에서는 Validation Error가 발생하기 때문에 상기내용대로라면 불필요할 것 같아요
  • 해석
    • 목록 조회에서 쓰는 쿼리 파라미터는 ‘엄격히 유효해야 할 대상’이 아니다.
      • 잘못 와도 400을 내지 말고, 그냥 비어있는 결과가 되도록 처리하자
      • 즉, 조건이 안맞으면 결과가 0건
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

    • 잘못된 값이 오면 ValidationError(400) 를 발생시킴
  • 요약

    • List API는 관대해야 한다
    • QuerySerializer는 검증기(validation) 가 아니라
    • 파서(parser) 사용하자
  • 해결

    • 형식 검증(min_value, choices) 제거
    • 기본값(default)은 유지
    • 실제 필터링 판단은 Service/Selector에서
  • 요약

    • 목록 조회 API에서는 쿼리 파라미터가 유효하지 않은 경우에도 ValidationError를 발생시키지 않고,
      • 필터 결과가 없는 경우 빈 리스트를 반환하도록 수정

  • 9. 쿼리셋 조회시 카운트가 0이라면 빈리스트를 반환하도록 수정

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,
}
  • annotated_qs 결과가 0건
    • 필터 조건 불일치
    • 검색 결과 없음
    • 존재하지 않는 category_id
    • answered 조건과 안 맞음
      • 전부 동일하게 처리

  • 10.

    • DRF에서는 PageNumberPagination, LimitOffsetPagination, CursorPagination
      • 기본적으로 제공
      • 질문 목록 조회라면 PageNumberPagination을 활용해도 괜찮을 것 같은데
        • 공부해보시고 해당 페이지네이션의 시그니처에 따라 응답하도록 변경해보자
  • 적용
      1. Pagination 클래스 만들기
# 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
    1. view에서 GET 메서드 수정 (핵심)
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)

  • 빈 리스트

    • 빈 리스트 출력은 Service도 View도 아니고, DRF의 PageNumberPagination이 자동으로 처리
# 전체 흐름
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": []
}

  • 11. 이동작은 아마 장고의 Path converters 의해서 수행되는 걸로 알고 있습니다.

    def get(self, request: Request, question_id: int) -> Response:
        self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]

        if question_id <= 0:

PrimaryKeyRelatedField 🔴

  • PrimaryKeyRelatedField를 쓰면
    • “이 값은 그냥 숫자가 아니라 다른 엔터티의 PK를 통해 맺어진 ‘관계’다”
    • 라는 사실을 Serializer 레벨에서 명확하게 표현할 수 있다.
  • PrimaryKeyRelatedField 미사용 🟢

class QuestionCreateSerializer(serializers.ModelSerializer):
    category_id = serializers.IntegerField()
  • category_id 이대로 처리하면 동작은 함
    • 하지만, Serializer 입장에서는 category_id가 그냥 숫자일 뿐
      • “이 숫자가 QuestionCategory의 PK인지”
      • “실제로 존재하는 카테고리인지”
      • “ForeignKey 관계인지”
        • 이걸 Serializer 자체는 전혀 모른다
# 따라서 서비스에서 아래 처럼 처리
category = get_category_or_raise(serializer.validated_data["category_id"])
  • PrimaryKeyRelatedField 사용 할 경우 🟢

    • Serializer의 인식
      • “이 값은 QuestionCategory 엔터티의 PK다”
      • “DB에 실제로 존재해야 한다”
      • “ForeignKey 관계다”
class QuestionCreateSerializer(serializers.ModelSerializer):
    category = serializers.PrimaryKeyRelatedField(
        queryset=QuestionCategory.objects.all()
    )

    class Meta:
        model = Question
        fields = ["title", "content", "category"]
  • 쓰기 좋은 경우

    • CRUD 중심 API
    • Serializer가 곧 도메인 경계
    • View / Service 단순화가 목표

DRF PageNumberPagination

  • Django REST Framework(DRF)에서 가장 기본이 되는 페이지 번호 기반 페이지네이션 방식
    • 페이지 번호(page)와 페이지 크기(page_size)를 기준으로
    • “몇 번째 페이지를 몇 개씩 가져올 것인가” 를 정하는 방식
      • page → 몇 번째 페이지 / page_size → 한 페이지에 몇 개의 데이터
  • 기본 동작 방식
    • View에서 QuerySet 반환 Pagination 클래스가 QuerySet을 slice
      • 결과 + 메타정보를 감싼 Response 반환
  • 기본 응답 구조
    • PageNumberPagination을 쓰면 응답 형태가 강제
{
  "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,
}
  • 커스텀 Pagination 클래스

    • 프론트 명세랑 맞춤 가능 / page / size 이름 통제 가능 / 무한 page_size 방지
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"         # 기본값
  • View에서 사용하는 방법

# 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

path converter

  • path converter가 해주는 것

    • url이 아래와 같이 정의되어 있다면
      • <int:question_id> 는 양의 정수만 허용한다.
# urls.py
path("questions/<int:question_id>/", QuestionDetailAPIView.as_view())
요청결과
/questions/abc/❌ 매칭 안 됨 → 404
/questions/-1/❌ 매칭 안 됨 → 404
/questions/0/❌ 매칭 안 됨 → 404
/questions/1/✅ question_id = 1

페이지네이션

새롭게 알게된 내용 ✅

  • 필드명과 source가 동일한 경우 source의 지정은 금지
comments = AnswerCommentSerializer(
    source="comments",
    many=True
)

comments = AnswerCommentSerializer(many=True)

  • ModelSerializer의 기본 동작

    • ModelSerializer는 모델 필드를 자동으로 매핑
    • Question 모델에 id가 있으면
    • Meta.fields에 "id"만 써도 자동으로 포함됨

오늘 발생한 문제(발생 했다면) ✅

[ 🔴 문제: 테스트 실행 시 질문 상세 조회 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() 호출
profile
안녕하세요.

0개의 댓글