oz_externship - 11~ 13 코드 작업

김기훈·2025년 12월 18일

부트캠프 프로젝트

목록 보기
22/39

[2025.12.18] 질문 상세조회 쿼리 심화 학습 및 SpecAPi / 1차 코드 작성

[2025.12.19] api명세의 입력/출력 형식 맞추기

등록/조회 전체의 반 이상 수정

[2025.12.20] QueryParameter형식 맞추기 / 상세조회 api 테스트코드 작성

정렬 필터 추가 / 에러메세지 ems 처리


Spec

views.py

from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404
from django.db.models import Prefetch

from apps.qna.models import Question, Answer, AnswerComment
from apps.qna.spec.question.spec_question_detail.serializers import QuestionDetailSpecSerializer

class QuestionDetailSpecAPIView(APIView):
    authentication_classes = []  # 전체 공개

    @extend_schema(
        tags=["Questions"],
        summary="질문 상세 조회 API (Spec)",
        description="실제 저장 없이 mock 데이터로 동작하는 질문 등록 Spec API입니다.",
        responses={
            200: QuestionDetailSpecSerializer,
            400: {"object": "object", "example": {"error": "Bad Request"}},
            404: {"object": "object", "example": {"error": "Bad Request"}},
        },
    )
    def get(self, request, question_id: int) -> Response:
        mock_response = {
            "question_id": question_id,
            "title": "Django ORM annotate 질문",
            "content": "annotate와 aggregate 차이가 궁금합니다.",
            "images": [
                "https://cdn.example.com/questions/12/img1.png",
                "https://cdn.example.com/questions/12/img2.png",
            ],
            "category_path": "백엔드 > Django > ORM",
            "view_count": 128,
            "created_at": "2025-12-18T09:30:00Z",
            "author": {
                "nickname": "졸린개발자",
                "profile_img_url": "https://cdn.example.com/profile/user3.png",
            },
            "answers": [
                {
                    "answer_id": 55,
                    "content": "annotate는 queryset에 가상 컬럼을 추가하는 기능입니다.",
                    "created_at": "2025-12-18T10:10:00Z",
                    "is_adopted": True,
                    "author": {
                        "nickname": "ORM마스터",
                        "profile_img_url": "https://cdn.example.com/profile/user9.png",
                    },
                    "comments": [
                        {
                            "comment_id": 201,
                            "content": "이 답변 덕분에 이해됐어요!",
                            "created_at": "2025-12-18T10:30:00Z",
                            "author": {
                                "nickname": "초보개발자",
                                "profile_img_url": "https://cdn.example.com/profile/user5.png",
                            },
                        }
                    ],
                }
            ],
        }

        return Response(mock_response, status=status.HTTP_200_OK)


serializers.py

from rest_framework import serializers
from apps.qna.models import Question, Answer, AnswerComment
from apps.qna.services.question.question_list.category_utils import build_category_path


class UserSimpleSpecSerializer(serializers.Serializer):
    nickname = serializers.CharField()
    profile_img_url = serializers.CharField(source="profile_image_url", allow_null=True)


class AnswerCommentSpecSerializer(serializers.ModelSerializer):
    comment_id = serializers.IntegerField(source="id")
    author = UserSimpleSpecSerializer()

    class Meta:
        model = AnswerComment
        fields = [
            "comment_id",
            "content",
            "created_at",
            "author",
        ]


class AnswerDetailSpecSerializer(serializers.ModelSerializer):
    answer_id = serializers.IntegerField(source="id")
    author = UserSimpleSpecSerializer()
    comments = AnswerCommentSpecSerializer(source="answer_comments", many=True)

    class Meta:
        model = Answer
        fields = [
            "answer_id",
            "content",
            "created_at",
            "is_adopted",
            "author",
            "comments",
        ]


class QuestionDetailSpecSerializer(serializers.ModelSerializer):
    question_id = serializers.IntegerField(source="id")
    images = serializers.SerializerMethodField()
    category_path = serializers.SerializerMethodField()
    author = UserSimpleSpecSerializer()
    answers = AnswerDetailSpecSerializer(many=True)

    class Meta:
        model = Question
        fields = [
            "question_id",
            "title",
            "content",
            "images",
            "category_path",
            "view_count",
            "created_at",
            "author",
            "answers",
        ]

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

    def get_category_path(self, obj: Question) -> str:
        return build_category_path(obj.category)["path"]

실전 코드

serializers.py

from rest_framework import serializers

from apps.qna.models import Question
from apps.qna.services.question.question_list.category_utils import build_category_path


class AuthorSerializer(serializers.Serializer):
    nickname = serializers.CharField()
    profile_img_url = serializers.CharField(source="profile_image_url", allow_null=True)

class AnswerCommentSerializer(serializers.Serializer):
    comment_id = serializers.IntegerField(source="id")
    content = serializers.CharField()
    created_at = serializers.DateTimeField()
    author = AuthorSerializer()


class AnswerSerializer(serializers.Serializer):
    answer_id = serializers.IntegerField(source="id")
    content = serializers.CharField()
    created_at = serializers.DateTimeField()
    is_adopted = serializers.BooleanField()
    author = AuthorSerializer()
    comments = AnswerCommentSerializer(
        source="answer_comments", many=True
    )

class QuestionDetailSerializer(serializers.Serializer):
    question_id = serializers.IntegerField(source="id")
    title = serializers.CharField()
    content = serializers.CharField()
    images = serializers.SerializerMethodField()
    category_path = serializers.SerializerMethodField()
    view_count = serializers.IntegerField()
    created_at = serializers.DateTimeField()
    author = AuthorSerializer()
    answers = AnswerSerializer(many=True)

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

    def get_category_path(self, obj: Question) -> str:
        return build_category_path(obj.category)["path"]

  • AnswerCommentSerializer (답변 댓글)

    • AnswerComment 모델 1개 → 댓글 응답 객체 1개
    • comment_id = serializers.IntegerField(source="id")
      • DB 필드: id / API 응답 필드명: comment_id
        • 내부 모델 구조와 외부 API 스펙을 분리하기 위한 전형적인 패턴
    • author = AuthorSerializer()
      • User 객체를 그대로 넘겨서 중첩 Serializer로 변환
  • AnswerSerializer (답변)

    • Answer 1개 + 그에 딸린 댓글들
    • comments = AnswerCommentSerializer(source="answer_comments", many=True)
      • answer_comments 는 Answer → AnswerComment FK의 related_name
        • Answer의 필드에는 없음(있었으면 source사용X)
      • DRF가 내부적으로
        • obj.answer_comments.all() 호출
        • 결과(QuerySet)를 AnswerCommentSerializer로 many=True 직렬화
      • FK로 연결되어 있기 때문에 필드명이 달라도 source로 연결 가능
  • QuestionDetailSerializer (질문 상세의 핵심)

    • serializers.ModelSerializer가 아닌 serializers.Serializer 사용 이유
      • 질문 + 이미지 + 카테고리 path + 답변 + 댓글
      • 단순 모델 필드 매핑을 넘어서 가공 데이터가 많음 그래서 Serializer가 더 적합
  • SerializerMethodField (가공 데이터)

# QuestionImage 모델
class QuestionImage(TimeStampedModel):
    question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="images")
    question_id: Optional[int]

    img_url = models.CharField(max_length=255)
# images
images = serializers.SerializerMethodField()

def get_images(self, obj: Question) -> list[str]:
    return [img.img_url for img in obj.images.all()]
    
# 실행 흐름
- 1. obj.images.all() → QuestionImage 모델 (related_name="images")
- 2. img.img_url만 뽑아서 리스트로 반환 → 이미지 객체를 그대로 노출하지 않고 URL만 내려주는 설계
# category
category = serializers.SerializerMethodField()

def get_category_path(self, obj: Question) -> str:
    return build_category_path(obj.category)["path"]

# obj.category → QuestionCategory
build_category_path: 소분류 → 중분류 → 대분류

views.py

from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
from apps.qna.serializers.question.question_detail import QuestionDetailSerializer
from apps.qna.services.question.question_detail.service import get_question_detail

class QuestionDetailAPIView(APIView):
    authentication_classes = []

    def get(self, request: Request, question_id: int) -> Response:
        self.validation_error_message = "유효하지 않은 질문 상세 조회 요청입니다."

        if question_id <= 0:
            raise ValidationError("question_id는 1 이상이어야 합니다.")

        question = get_question_detail(question_id=question_id)

        return Response(
            QuestionDetailSerializer(question).data,
            status=status.HTTP_200_OK,
        )

  • View 역할

    • URL로 들어온 question_id를 검증하고, 서비스에서 질문 데이터를 가져와
      • Serializer로 JSON 응답을 만들어 반환한다.
    • def get(self, request: Request, question_id: int) -> Response:
      • URL path parameter로 question_id 받음
    • question = get_question_detail(question_id=question_id)
      • View는 아무것도 모름 / View는 그냥 위임 / 전부 Service 책임

service

from apps.qna.exceptions.question_exceptions import QuestionNotFoundError
from apps.qna.models import Question
from apps.qna.services.question.question_detail.selectors import get_question_detail_queryset

def get_question_detail(*, question_id: int) -> Question:
    question = get_question_detail_queryset(question_id)

    if question is None:
        raise QuestionNotFoundError()

    question.view_count += 1
    question.save(update_fields=["view_count"])

    return question

  • service의 역할

    • 질문 ID로 질문을 조회하고 없으면 도메인 예외를 발생시키며 있으면 조회수를 증가시키고 반환
    • 쿼리 로직을 selector로 분리
      • Service는 “무엇을 할지”만 알고 / Selector는 “어떻게 가져올지”만 앎
  • 코드 리뷰

    • def get_question_detail(*, question_id: int) -> Question:
      • * → 키워드 전용 인자 실수 → 방지 + 가독성 좋음
        • get_question_detail(1) ❌
        • get_question_detail(question_id=1) ⭕️
    • 조회수 증가 로직
      • question.view_count += 1
        • 조회수 증가 = 비즈니스 규칙 → 무조거 service에 있어야 함
      • question.save(update_fields=["view_count"])

selector

from django.db.models import Prefetch
from apps.qna.models import Question, Answer

def get_question_detail_queryset(question_id: int) -> Question | None:
    return (
        Question.objects
        .select_related("author", "category")
        .prefetch_related(
            "images",
            Prefetch(
                "answers",
                queryset=Answer.objects
                .select_related("author")
                .prefetch_related(
                    "answer_comments__author"
                )
            )
        )
        .filter(id=question_id)
        .first()
    )

  • 코드 리뷰

    • def get_question_detail_queryset(question_id: int):
      • 질문 하나만 가져오는 읽기 전용 selector / 없으면 None 반환 → 판단은 Service 책임
  • QuerySet

    • Question.objects. → QuerySet 시작점
    • .select_related("author", "category")
      • FK 관계 (1:1 / N:1) → Question.author / Question.category
      • JOIN 한 번으로 즉시 로딩 / Serializer에서 추가 쿼리 안 남
    • .prefetch_related("images")
      • QuestionImage (1:N) → JOIN ❌

  • answers에 Prefetch를 쓰는 이유

Prefetch(
    "answers",
    queryset=Answer.objects
        .select_related("author")
        .prefetch_related("answer_comments__author")
)

코드 목표

  • 한 번의 “질문 상세 조회” 요청에서 질문 1개와
    • 연관 데이터(작성자/카테고리/이미지/답변/답변작성자/답변댓글/댓글작성자) 까지
      • 쿼리 수를 최소화해서 한 번에 “객체 그래프”를 만들어주는 흐름
  • 이 함수의 최종 목표

    • question_id 하나를 받아서 최종적으로 이런 구조의 객체 만들기
      • Question 객체 1개
        • question.author (유저 객체)
        • question.category (카테고리 객체)
        • question.images (이미지 객체들)
        • question.answers (답변 객체들)
          • 각 answer.author (답변 작성자 객체)
          • 각 answer.answer_comments (댓글 객체들)
            • 각 comment.author (댓글 작성자 객체)
    • 즉, “질문 상세 페이지”에 필요한 걸 한 번에 준비

⭐️

from django.db.models import Prefetch
from apps.qna.models import Question, Answer

def get_question_detail_queryset(question_id: int) -> Question | None:
    return (
        Question.objects
        .select_related("author", "category")
        .prefetch_related(
            "images",
            Prefetch(
                "answers",
                queryset=Answer.objects
                .select_related("author")
                .prefetch_related(
                    "answer_comments__author"
                )
            )
        )
        .filter(id=question_id)
        .first()
    )

코드 리뷰

  • Question.objects

    • “Question을 기반으로 조회 설계 시작”(아직 DB 쿼리 실행 안 됨)
    • “Question 테이블에서 데이터를 가져오겠다” QuerySet(조회 계획서) 를 만드는 단계
  • .select_related("author", "category")

    • FK는 JOIN으로 같이 가져오기(author, category는 Question의 ForeignKey)
    • question.author.nickname, question.category.name
      • 추가 쿼리가 안 나가게 question 객체 안에 author/category 객체를 이미 채워놓음
      • “Question + author + category”가 한 번의 SQL로 같이 로딩
  • .prefetch_related("images", Prefetch(...))

    • 1:N/M:N은 “별도 쿼리 + 파이썬에서 붙이기”
      • images는 보통 QuestionImage 같은 테이블이 question_id FK로 붙어있는 1:N 관계
      • 이런것을 JOIN으로 땡기면 행이 뻥튀기(Question이 이미지 개수만큼 중복 row로 늘어남)
    • Django의 해결법

        1. Question 먼저 가져오고
        1. images는 별도의 쿼리로 한 번에 싹 가져온 다음
        1. 파이썬에서 question.images에 매칭해서 붙여
    • question.images.all()을 나중에 호출해도 추가 쿼리 없이 이미 붙어있음
  • Prefetch("answers", queryset=...)

    • answers를 “커스텀 방식으로 프리패치”
    Prefetch(
        "answers",
        queryset=Answer.objects
            .select_related("author")
            .prefetch_related("answer_comments__author")
    )
    • prefetch_related("answers")만 사용시
      • answers를 그냥 기본 QuerySet으로 가져와서 붙여줌
      • answers를 가져올 때 추가 최적화(작성자, 댓글까지) 를 하기 위해서
        • Prefetch로 “answers를 어떤 쿼리셋으로 가져올지”를 직접 지정
    • .select_related("author") (answer.author 접근할 때 추가 쿼리 없음)

      • Answer의 author(FK)는 JOIN으로 같이 가져옴
    • .prefetch_related("answer_comments__author")

      • answer_comments : Answer 1:N Comment
      • author : Comment의 FK
    • “답변들의 댓글들”을 한 번에 가져오고
      • 그 댓글들의 작성자도 같이 가져와서
      • 나중에 comment.author.nickname 접근해도 쿼리 안 나가게 함
  • .filter(id=question_id).first()

    • 최종 실행 + 1개만 가져오기
      • filter(id=...) : 해당 질문만 타겟
      • first() : 첫 번째 결과를 가져오되 없으면 None

추가

  • question.author.nickname, question.category.name
    • 추가쿼리 발생 시
      • 질문 목록 조회 → 1번 / 질문 개수만큼 author 조회 → N번 = N + 1 쿼리 발생
question = Question.objects.get(id=1)

# author_id, category_id는 숫자로만 존재
question.author_id = 3 / question.category_id = 7

# 아직 User 테이블, Category 테이블은 전혀 조회 안 됨
# question.author.nickname 호출
question.author.nickname

# author 객체가 메모리에 없다. author_id는 있으니까 User 테이블을 한번 조회 = 추가 쿼리
## 이를 지연로딩 이라고 함 

selector 쿼리

  • Question + author + category (select_related로 JOIN) → 1번
  • images (prefetch) → 1번
  • answers + answer.author (Prefetch 내부 select_related) → 1번
  • answer_comments (prefetch) → 1번
  • comment.author (prefetch) → 1번

selector 요약

  • 질문 1개를 가져오되, FK는 JOIN(select_related)으로 붙이고,
    • 컬렉션(1:N)은 prefetch로 따로 모아서 붙이며,
      • answers는 Prefetch로 “답변 + 답변작성자 + 답변댓글 + 댓글작성자”까지 한 번에 준비한다.

selector 목표

  • Success Response Schema를 요구사항에 맞게 응답구조를 만들기 위해 사용
    • serializer는 형태를 바꾸는 역할이고, selector는 재료를 다 준비해두는 역할

응답구조

{
  "question_id": bigint,
  "title": str,
  "content": str,
  "images": [str],
  "category_path": str,
  "view_count": int,
  "created_at": str,
  "author": {
    "nickname": str,
    "profile_img_url": str
  },
  "answers": [
    {
      "answer_id": bigint,
      "content": str,
      "created_at": str,
      "is_adopted": bool,
      "author": {...},
      "comments": [
        {
          "comment_id": bigint,
          "content": str,
          "created_at": str,
          "author": {...}
        }
  • 이 구조의 특징

    • 깊이가 4단계 이상
    • FK / 1:N 관계가 연속적으로 중첩
    • serializer에서 접근하는 속성들이:
      • question.author.nickname
      • question.answers.all()
      • answer.author.nickname
      • answer.answer_comments.all()
      • comment.author.nickname
        • selector을 사용하지 않고 가져오면 N+1 지옥
  • 레벨별 필요 객체?

    • Question 레벨
      • question.author
      • question.category
      • question.images
    • Answer 레벨
      • question.answers
      • answer.author
    • Comment 레벨
      • answer.answer_comments
      • comment.author
  • selector 없이 serializer만

for answer in question.answers.all():
    answer.author.nickname
    for comment in answer.answer_comments.all():
        comment.author.nickname
단계쿼리
question 조회1
author 조회+1
answers 조회+1
answer마다 author+N
comment 조회+N
comment마다 author+M

  • Question 레벨

# question 객체 자체
question = Question.objects.get(...)

# Response 최상위 객체
{
  "question_id": ...,
  "title": ...,
  "content": ...,
  "images": [...],
  "category_path": ...,
  "view_count": ...,
  "created_at": ...,
  "author": {...},
  "answers": [...]
}
# question.author
question.author.nickname
question.author.profile_image_url

# Response
"author": {
  "nickname": str,
  "profile_img_url": str
}

- 1. Question ↔ User (FK)
- 2. select_related("author") 덕분에 바로 접근 가능
# question.category
"category_path": str

# 의미
category_path는 question.category를 재료로 가공한 값

build_category_path(question.category)
# question.images
question.images.all()

# Response
"images": [str]

- 1. Question ↔ QuestionImage (1:N)
- 2. [obj.img_url for obj in question.images.all()]

  • Answer 레벨

# question.answers
question.answers.all()

# Response
"answers": [
  {
    "answer_id": ...,
    "content": ...,
    "created_at": ...,
    "is_adopted": ...,
    "author": {...},
    "comments": [...]
  }
]

- 1. Question ↔ Answer (1:N)
- 2. 이 리스트 하나당 Answer 객체 하나
# answer.author
answer.author.nickname
answer.author.profile_image_url

# Response
"author": {
  "nickname": str,
  "profile_img_url": str
}

- 1. Answer ↔ User (FK)
- 2. Question의 author 구조와 완전히 동일한 형태
  - 구조가 같아서 AuthorSerializer를 재사용

  • Comment 레벨

# answer.answer_comments
answer.answer_comments.all()

# Response
"comments": [
  {
    "comment_id": ...,
    "content": ...,
    "created_at": ...,
    "author": {...}
  }
]

- 1. Answer ↔ AnswerComment (1:N)
- 2. Answer 하나에 달린 댓글들
# comment.author
comment.author.nickname
comment.author.profile_image_url

# Response
"author": {
  "nickname": str,
  "profile_img_url": str
}

- 1. AnswerComment ↔ User (FK)
- 2. 구조는 Question/Answer author와 동일

객체 접근Response 위치
question.idquestion_id
question.titletitle
question.contentcontent
question.images.all()images
question.categorycategory_path (가공됨)
question.view_countview_count
question.created_atcreated_at
question.author최상위 author
question.answers.all()answers[]
answer.idanswers[].answer_id
answer.contentanswers[].content
answer.is_adoptedanswers[].is_adopted
answer.authoranswers[].author
answer.answer_comments.all()answers[].comments[]
comment.idcomments[].comment_id
comment.authorcomments[].author

2025.12.19


질문 조회 수정

  • api요구사항에 안맞는 부분 살짝 수정

# 요구 스키마						# 현재 구현
{									{
  "page": int,							"page": page,
  "size": int,							"page_size": page_size,
  "total_count": int,					"total_pages": ...,
  "questions": [...]					"total_count": ...
}									}
# 캐시
self._category_path_cache[category_id] = build_category_path(obj.category)

# 캐시 내용
self._category_path_cache = {
    3: {
        "id": 3,
        "path": "백엔드 > Django > ORM"
    }
}

# 즉,
self._category_path_cache[category_id]는 문자열이 아닌 dict

# 그리하여 
return self._category_path_cache[category_id]["path"]하여 문자열만 꺼냄

# ["path"] 안쓰면 캐시 내용 통째로 나감

2025.12.20


EMS 적용

  • from apps.core.exceptions.exception_messages import EMS

질문 등록 API (POST /questions)

상태기존 의미
400유효하지 않은 요청
401로그인 안 됨
403학생 아님
404카테고리 없음
# 400 ValidationError - views

self.validation_error_message = "유효하지 않은 질문 등록 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 등록")["error_detail"]

# 401 로그인 안 됨 (Permission Exception) - exceptions

default_detail = "로그인한 수강생만 질문을 등록할 수 있습니다."
default_detail = EMS.E401_STUDENT_ONLY_ACTION("질문을 등록")["error_detail"]

# 403 학생 아님 (Permission message) - permissions

message = "질문 등록 권한이 없습니다."
message = EMS.E403_QNA_PERMISSION_DENIED("등록")["error_detail"]

# 404 카테고리 없음 - exceptions

default_detail = "선택한 카테고리를 찾을 수 없습니다."
default_detail = EMS.E404_NOT_FOUND("카테고리")["error_detail"]

질문 목록 조회 (GET /questions)

# 400 ValidationError 메시지 - views

self.validation_error_message = "유효하지 않은 목록 조회 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 목록 조회")["error_detail"]

# 404 질문 없음 - exceptions
default_detail = "조회 가능한 질문이 존재하지 않습니다."
default_detail = EMS.E404_NO_QUESTIONS_AVAILABLE["error_detail"]

질문 상세 조회 (GET /questions/{id})

# 400 ValidationError 메시지 - views
self.validation_error_message = "유효하지 않은 질문 상세 조회 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]

# 404 질문 없음 - exceptions
default_detail = "해당 질문을 찾을 수 없습니다."
default_detail = EMS.E404_NOT_FOUND("질문")["error_detail"]

테스트코드의 경우

  • Service 테스트에서는 메시지 비교 자체를 하지 않는 게 정답
    • 메시지는 View 책임
    • Service는 “예외 발생 여부”만 검증


profile
안녕하세요.

0개의 댓글