
[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"]
- 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 (가공 데이터)
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 = 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 = serializers.SerializerMethodField()
def get_category_path(self, obj: Question) -> str:
return build_category_path(obj.category)["path"]
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 ❌
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.author.nickname, question.category.name
- 추가쿼리 발생 시
- 질문 목록 조회 → 1번 / 질문 개수만큼 author 조회 → N번 = N + 1 쿼리 발생
question = Question.objects.get(id=1)
question.author_id = 3 / question.category_id = 7
question.author.nickname
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.objects.get(...)
{
"question_id": ...,
"title": ...,
"content": ...,
"images": [...],
"category_path": ...,
"view_count": ...,
"created_at": ...,
"author": {...},
"answers": [...]
}
question.author.nickname
question.author.profile_image_url
"author": {
"nickname": str,
"profile_img_url": str
}
- 1. Question ↔ User (FK)
- 2. select_related("author") 덕분에 바로 접근 가능
"category_path": str
category_path는 question.category를 재료로 가공한 값
build_category_path(question.category)
question.images.all()
"images": [str]
- 1. Question ↔ QuestionImage (1:N)
- 2. [obj.img_url for obj in question.images.all()]
question.answers.all()
"answers": [
{
"answer_id": ...,
"content": ...,
"created_at": ...,
"is_adopted": ...,
"author": {...},
"comments": [...]
}
]
- 1. Question ↔ Answer (1:N)
- 2. 이 리스트 하나당 Answer 객체 하나
answer.author.nickname
answer.author.profile_image_url
"author": {
"nickname": str,
"profile_img_url": str
}
- 1. Answer ↔ User (FK)
- 2. Question의 author 구조와 완전히 동일한 형태
- 구조가 같아서 AuthorSerializer를 재사용
answer.answer_comments.all()
"comments": [
{
"comment_id": ...,
"content": ...,
"created_at": ...,
"author": {...}
}
]
- 1. Answer ↔ AnswerComment (1:N)
- 2. Answer 하나에 달린 댓글들
comment.author.nickname
comment.author.profile_image_url
"author": {
"nickname": str,
"profile_img_url": str
}
- 1. AnswerComment ↔ User (FK)
- 2. 구조는 Question/Answer author와 동일
| 객체 접근 | Response 위치 |
|---|
question.id | question_id |
question.title | title |
question.content | content |
question.images.all() | images |
question.category | category_path (가공됨) |
question.view_count | view_count |
question.created_at | created_at |
question.author | 최상위 author |
question.answers.all() | answers[] |
answer.id | answers[].answer_id |
answer.content | answers[].content |
answer.is_adopted | answers[].is_adopted |
answer.author | answers[].author |
answer.answer_comments.all() | answers[].comments[] |
comment.id | comments[].comment_id |
comment.author | comments[].author |
2025.12.19
질문 조회 수정
{ {
"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"]하여 문자열만 꺼냄
2025.12.20
EMS 적용
from apps.core.exceptions.exception_messages import EMS
질문 등록 API (POST /questions)
| 상태 | 기존 의미 |
|---|
| 400 | 유효하지 않은 요청 |
| 401 | 로그인 안 됨 |
| 403 | 학생 아님 |
| 404 | 카테고리 없음 |
self.validation_error_message = "유효하지 않은 질문 등록 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 등록")["error_detail"]
default_detail = "로그인한 수강생만 질문을 등록할 수 있습니다."
default_detail = EMS.E401_STUDENT_ONLY_ACTION("질문을 등록")["error_detail"]
message = "질문 등록 권한이 없습니다."
message = EMS.E403_QNA_PERMISSION_DENIED("등록")["error_detail"]
default_detail = "선택한 카테고리를 찾을 수 없습니다."
default_detail = EMS.E404_NOT_FOUND("카테고리")["error_detail"]
질문 목록 조회 (GET /questions)
self.validation_error_message = "유효하지 않은 목록 조회 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 목록 조회")["error_detail"]
default_detail = "조회 가능한 질문이 존재하지 않습니다."
default_detail = EMS.E404_NO_QUESTIONS_AVAILABLE["error_detail"]
질문 상세 조회 (GET /questions/{id})
self.validation_error_message = "유효하지 않은 질문 상세 조회 요청입니다."
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 상세 조회")["error_detail"]
default_detail = "해당 질문을 찾을 수 없습니다."
default_detail = EMS.E404_NOT_FOUND("질문")["error_detail"]
테스트코드의 경우
- Service 테스트에서는 메시지 비교 자체를 하지 않는 게 정답
- 메시지는 View 책임
- Service는 “예외 발생 여부”만 검증