
질문 목록 조회 API
[2025.12.13] 질문 목록 조회 API 시작 전 요구사항 확인 및 SpecAPI 작성 준비
[2025.12.14] 질문 목록 조회 SpceAPI 작성 및 1차 조회API 완성(TestCode는 미완)
[2025.12.15] 질문등록 API PR 리뷰사항 반영 완료 및 PR승인 / Merge 진행
질문 목록 조회 API에서 카테고리 관련 기능 추가 = 중분류(대분류) 선택시 하위 포함 처리
[2025.12.16] 질문등록 API 예외처리 수정 및 전체적인 코드 리팩토링 및 QuerySet 이해 노력
[2025.12.17] 질문등록 API view 하나로 합치기 / 테스트코드 작성 및 페이지네이션 추가 예외 처리
2025.12.13
?page=1 # 페이지 번호
?page_size=10 # 페이지당 개수
?answered=true | false # 답변 여부 필터
?category_id=12 # 카테고리 필터
?keyword=django # 제목/내용 검색
{
"count": 123,
"next": "/api/v1/qna/questions?page=2",
"previous": null,
"results": []
}
{
"id": 1,
"category": {
"id": 15,
"path": "백엔드 > 웹프레임워크 > Django"
},
"author": {
"nickname": "졸린개발자",
"profile_image_url": "https://example.com/profile.png"
},
"title": "Django에서 N+1 문제 해결 방법",
"content_preview": "Django ORM을 사용하다 보면 N+1 문제가 자주 발생합니다...",
"answer_count": 3,
"view_count": 124,
"created_at": "2025-12-13T10:30:00Z",
"thumbnail_image_url": "https://example.com/thumb.png",
"is_answered": true
}
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1, # 상세 조회 /questions/{id} 이동용
"category": { # 프론트에서 불가 백엔드에서 문자열로 내려주는 게 정답
"id": 15,
"path": "백엔드 > 웹프레임워크 > Django"
},
"author": { # 개인정보 최소화
"nickname": "졸린개발자",
"profile_image_url": "https://example.com/profile1.png"
},
"title": "Django ORM 질문", # 전체 content ❌ / UX + 성능 고려
"content_preview": "Django에서 annotate를 사용하는 방법이 궁금합니다...",
"answer_count": 1,
"view_count": 120, # 답변 개수 표시
"created_at": "2025-12-13T09:00:00Z",
"thumbnail_image_url": "https://example.com/thumb1.png",
"is_answered": true # 답변 내역O
},
{
"id": 2,
"category": {
"id": 22,
"path": "백엔드 > 데이터베이스 > PostgreSQL"
},
"author": {
"nickname": "초보개발자",
"profile_image_url": null
},
"title": "PostgreSQL 인덱스 질문",
"content_preview": "인덱스를 언제 사용하는 게 좋은지 궁금합니다...",
"answer_count": 0,
"view_count": 45,
"created_at": "2025-12-12T18:40:00Z",
"thumbnail_image_url": null,
"is_answered": false
}
]
}
2025.12.14
from rest_framework import serializers
class QuestionListSpecSerializer(serializers.Serializer):
id = serializers.IntegerField()
category = serializers.DictField(
child=serializers.CharField()
)
author = serializers.DictField(
child=serializers.CharField(allow_null=True)
)
title = serializers.CharField()
content_preview = serializers.CharField()
answer_count = serializers.IntegerField()
view_count = serializers.IntegerField()
thumbnail_image_url = serializers.URLField(
allow_null=True,
required=False
)
created_at = serializers.DateTimeField()
is_answered = serializers.BooleanField()
title = serializers.CharField()
content_preview = serializers.CharField()
answer_count = serializers.IntegerField()
view_count = serializers.IntegerField() # 실제 API에서는 Count() 같은 집계
from datetime import datetime
from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.qna.spec.question.spec_question_list.serializers import (
QuestionListSpecSerializer,
)
class QuestionListSpecAPIView(APIView):
permission_classes = []
@extend_schema(
tags=["Questions"],
summary="질문 조회 API (Spec)",
description="실제 저장 없이 mock 데이터로 동작하는 질문 조회 Spec API입니다.",
request=QuestionListSpecSerializer,
responses={
200: QuestionListSpecSerializer,
400: {"object": "object", "example": {"error": "Bad Request"}},
},
)
def get(self, request: Request) -> Response:
mock_results = [
{
"id": 1,
"category": {
"id": 15,
"path": "백엔드 > 웹프레임워크 > Django",
},
"author": {
"nickname": "졸린개발자",
"profile_image_url": "https://example.com/profile1.png",
},
"title": "Django ORM 질문",
"content_preview": "Django에서 annotate를 사용하는 방법이 궁금합니다...",
"answer_count": 1,
"view_count": 120,
"thumbnail_image_url": "https://example.com/thumb1.png",
"created_at": datetime(2025, 12, 13, 9, 0, 0),
"is_answered": True,
},
{
"id": 2,
"category": {
"id": 22,
"path": "백엔드 > 데이터베이스 > PostgreSQL",
},
"author": {
"nickname": "초보개발자",
"profile_image_url": None,
},
"title": "PostgreSQL 인덱스 질문",
"content_preview": "인덱스를 언제 사용하는 게 좋은지 궁금합니다...",
"answer_count": 0,
"view_count": 45,
"thumbnail_image_url": None,
"created_at": datetime(2025, 12, 12, 18, 40, 0),
"is_answered": False,
},
]
serializer = QuestionListSpecSerializer(mock_results, many=True)
return Response(
{
"count": 2,
"next": None,
"previous": None,
"results": serializer.data,
}
)
mock_results = [...]serializer = QuestionListSpecSerializer(mock_results, many=True)return Response({
"count": 2,
"next": None,
"previous": None,
"results": serializer.data,
})
GET /api/v1/qna/questions
├─ QueryParam Serializer (400)
├─ Service (필터 / 검색 / 정렬 / 페이지네이션)
│ └─ 결과 없음 → 404
└─ Response Serializer (카드 리스트)
| 구분 | 역할 | 예 |
|---|---|---|
| 입력용 Serializer | 요청 데이터 검증 | QueryParam, POST body |
| 출력용 Serializer | 응답 데이터 표현 | 카드 UI, 상세 화면 |
클라이언트
↓ GET /api/v1/qna/questions
URL → View
↓
Query Serializer (요청 검증) ──❌→ 400
↓
Service (조회 로직)
├─ 필터 / 검색 / 정렬
├─ pagination
└─ 결과 없음 ──❌→ 404
↓
Response Serializer (카드 변환)
↓
Response (200)
# views.py
query_serializer = QuestionListQuerySerializer(data=request.query_params)
if not query_serializer.is_valid():
raise QuestionListValidationError()
- 1. request.query_params 는 전부 문자열인데 DRF는 이걸 그냥 dict로만 줌 즉, 아무 검증도 안 됨
- ex. ?page=abc&page_size=-100&answered=hello
- 2. 위의 예시처럼 service로 넘기면 ORM 에러 / 500 터짐 / 클라이언트는 왜 실패했는지 모름
# question_list_serializers.py
## 조회 API에서 허용하는 조회 조건
class QuestionListQuerySerializer(serializers.Serializer):
answered = serializers.BooleanField(required=False)
category = serializers.IntegerField(required=False)
search = serializers.CharField(required=False, allow_blank=True)
page = serializers.IntegerField(required=False, min_value=1, default=1)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=50, default=10)
1. 존재 이유 → "Service는 항상 믿을 수 있는 값만 받는다" 라는 보장을 위해서
- 타입 검증 → str → bool / int | 범위 제한 → page ≥ 1
- 기본값 처리 → page, page_size | 잘못된 요청 차단 → 400
if not query_serializer.is_valid():
raise QuestionListValidationError()
- 이유
- 1. 비즈니스 오류가 아님 / “조회할 수 없는 상태”도 아님 / 요청 자체가 이상함
questions, page_info = get_question_list(...)# 카드 UI 요구사항을 만족하기 위한 최소 쿼리
qs = (
Question.objects
.select_related("author", "category") # 작성자/카테고리 N+1 방지
.annotate(answer_count=Count("ai_answers")) # 카드에 답변 수 필요
.order_by("-created_at") # 최신순 요구사항
)
1. answered 탭 클릭 → 서버가 필터링해야 함
2. 프론트에서 처리 ❌ (데이터 많음)
if answered is True:
qs = qs.filter(answer_count__gt=0)
qs = qs.filter(
Q(title__icontains=search) |
Q(content__icontains=search)
)
1. 제목 + 내용 검색 요구사항 / DB에서 처리해야 성능 보장
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk"))
.values("img_url")[:1]
)
1. 썸네일은 DB 조회 결과 / 표현 로직 ❌ / 조회 전략 ⭕
from rest_framework import serializers
class QuestionListQuerySerializer(serializers.Serializer):
answered = serializers.BooleanField(required=False)
category = serializers.IntegerField(required=False)
search = serializers.CharField(required=False, allow_blank=True)
page = serializers.IntegerField(required=False, min_value=1, default=1)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=50, default=10)
default=1 page 파라미터가 없을 때 validated_data['page'] 값은 1from rest_framework import serializers
from apps.qna.models import Question
class QuestionAuthorSerializer(serializers.Serializer):
nickname = serializers.CharField()
profile_image_url = serializers.CharField(allow_null=True)
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.CharField()
author = QuestionAuthorSerializer()
answer_count = serializers.IntegerField()
thumbnail_image_url = serializers.CharField(allow_null=True)
class Meta:
model = Question
fields = [
"id",
"category",
"author",
"title",
"content",
"answer_count",
"view_count",
"created_at",
"thumbnail_image_url",
]
"author": {
"nickname": "dev_kim",
"profile_image_url": "https://..."
}from django.db.models import Count, OuterRef, Subquery, Q
from django.core.paginator import Paginator
from apps.qna.exceptions.question_exceptions import QuestionListEmptyError
from apps.qna.models import Question, QuestionImage
def get_question_list(
*,
answered: bool | None = None,
category: int | None = None,
search: str | None = None,
page: int,
page_size: int,
):
qs = (
Question.objects
.select_related("author", "category")
.annotate(answer_count=Count("ai_answers", distinct=True))
.order_by("-created_at")
)
# 답변 여부 필터
if answered is True:
qs = qs.filter(answer_count__gt=0)
elif answered is False:
qs = qs.filter(answer_count=0)
# 카테고리 필터
if category is not None:
qs = qs.filter(category_id=category)
# 검색 (제목 + 내용)
if search:
qs = qs.filter(
Q(title__icontains=search) |
Q(content__icontains=search)
)
if not qs.exists():
raise QuestionListEmptyError()
# 썸네일 이미지 (첫 번째 이미지)
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk"))
.values("img_url")[:1]
)
qs = qs.annotate(thumbnail_image_url=Subquery(thumbnail_subquery))
paginator = Paginator(qs, page_size)
page_obj = paginator.get_page(page)
return page_obj.object_list, {
"page": page,
"page_size": page_size,
"total_pages": paginator.num_pages,
"total_count": paginator.count,
}
qs = (
Question.objects
.select_related("author", "category")
.annotate(answer_count=Count("ai_answers", distinct=True))
.order_by("-created_at")
) qs = qs.filter(
Q(title__icontains=search) |
Q(content__icontains=search)
)
- 1. 제목 OR 내용 검색 - 2. icontains → 대소문자 무시 부분 검색
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status
from apps.qna.exceptions.question_exceptions import QuestionListValidationError
from apps.qna.serializers.question.question_list_query import (
QuestionListQuerySerializer,
)
from apps.qna.serializers.question.question_list import QuestionListSerializer
from apps.qna.services.question.question_list_service import get_question_list
class QuestionListAPIView(APIView):
permission_classes: list = []
def get(self, request: Request) -> Response:
query_serializer = QuestionListQuerySerializer(data=request.query_params)
if not query_serializer.is_valid():
raise QuestionListValidationError()
questions, page_info = get_question_list(
**query_serializer.validated_data
)
return Response(
{
"results": QuestionListSerializer(questions, many=True).data,
"page": page_info,
},
status=status.HTTP_200_OK,
)
questions, page_info = get_question_list(
**query_serializer.validated_data
)
- 1. validated_data → 타입 변환 완료 / 기본값 적용 완료 / 신뢰 가능한 데이터
- 2. ** 언패킹 → Service 함수의 키워드 인자와 정확히 매칭
- 3. View는: “무엇을 조회할지” 결정 ❌ / 그냥 Service에 위임
2025.12.15
백엔드 (대)
└─ Django (중)
└─ ORM (소)
1. 백엔드 선택 → 백엔드 / Django / ORM 질문 전부 포함
2. Django 선택 → Django / ORM 질문 포함
3. ORM 선택 → ORM 질문만
parent = models.ForeignKey("self", related_name="children", ...)
- 카테고리가 self FK 트리 구조이기 때문에 위와 같은 방식이 가능
- 백엔드에서 대분류인지 중분류인지 이름으로 구분하지 않고 children이 있느냐 / parent가 있느냐로 판단
선택된 category_id
→ 해당 Category 객체 조회
→ 자기 자신 + 모든 descendants id 수집
→ Question.objects.filter(category_id__in=ids)
# 하위 카테고리 id 수집 함수
## 선택한 카테고리 id 포함 / children → grandchildren → … 전부 포함 / 깊이 제한 없음
from apps.qna.models import QuestionCategory
def get_descendant_category_ids(category: QuestionCategory) -> list[int]:
"""
자기 자신 + 모든 하위 카테고리 id를 재귀적으로 수집
"""
ids = [category.id]
for child in category.children.all():
ids.extend(get_descendant_category_ids(child))
return ids
# get_question_list에 적용
## 기존
if category is not None:
qs = qs.filter(category_id=category)
## 수정
from apps.qna.models import QuestionCategory
from apps.qna.exceptions.question_exceptions import CategoryNotFoundError
from apps.qna.services.question.utils import get_descendant_category_ids
if category is not None:
try:
selected_category = QuestionCategory.objects.get(id=category)
except QuestionCategory.DoesNotExist:
raise CategoryNotFoundError()
category_ids = get_descendant_category_ids(selected_category)
qs = qs.filter(category_id__in=category_ids)
# 이전
create_question(
author=user,
title=serializer.validated_data["title"],
content=serializer.validated_data["content"],
category_id=serializer.validated_data["category"],
image_urls=serializer.validated_data.get("image_urls", []),
)
- 1. View에서 데이터 분해 → validated_data를 하나씩 분해 / service 인터페이스가 필드 나열형
- 2. 필드 추가 시 View / Service 둘 다 수정 필요
- 3. View가 데이터 구조를 “알아야” 하는 상태
# 수정 후
question = create_question(
author=user,
category=category,
validated_data=serializer.validated_data,
)
- 1. View에서 데이터 분해 ❌ / Service가 **“검증 완료된 데이터 묶음”**을 받음
- 2. View가 얇아짐 / 인터페이스 안정성 증가
# 이전
try:
category = QuestionCategory.objects.get(...)
except:
raise CategoryNotFoundError()
- 1. 생성 로직과 검증 로직이 섞여 있었음 (create” 라는 이름과 책임 불일치)
# 변경 후
def get_category_or_raise(category_id: int) -> QuestionCategory:
- 1. 카테고리 존재 여부를 별도 함수로 분리 / create_question 은 생성만 담당
- 2. 단일 책임 원칙 (SRP) 충족 | 테스트 / 재사용성 향상
# Before = 필드 단위 함수
create_question(author, title, content, category, image_urls)
# After = 질문 생성 이라는 행위 단위 함수
create_question(author, category, validated_data)
# url을 가져올땐 reverse를 사용해서
## url name을 활용하여 가져오는게 유지보수 측면에서 유용
class QuestionCreateAPITests(APITestCase):
def setUp(self) -> None:
self.url = "/api/v1/qna/questions"
# View단에서 Error Raise에 사용 권장
if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
return response
# ValidationError에 대한 정보 손실
elif isinstance(detail, dict):
response.data = {"error_detail": next(iter(detail.values()))}
path("questions", QuestionCreateAPIView.as_view(), name="question_create"),# reverse 사용 예시
from django.urls import reverse
class QuestionCreateAPITests(APITestCase):
def setUp(self) -> None:
self.url = reverse("question_create")
# 실제 사용 결과
/api/v1/qna/questions
self.url = "/api/v1/qna/questions"
1. 나중에 기획 변경으로 인한 주소 변경시에 아래의 상황 발생
2. 테스트 코드 전부 수정 / 누락되면 CI 깨짐 / 문자열 검색으로 찾다가 실수 가능
from django.urls import reverse
self.url = reverse("question_create")
# apps/core/exception_handler.py
view = context.get("view")
if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
1. exception_handler가 특정 View를 직접 알고 있음
2. exception_handler가
2-1. QnA 도메인 / QuestionCreateAPIView / 특정 API의 비즈니스 정책을 전부 알아버림
2-2. 레이어 침범
# 처음 코드
## exceptions.py
class QuestionCreateValidationError(ValidationError):
default_detail = "유효하지 않은 질문 등록 요청입니다."
## views/question_create.py
from apps.qna.exceptions.question_exceptions import QuestionCreateValidationError
serializer = QuestionCreateSerializer(data=request.data)
if not serializer.is_valid():
raise QuestionCreateValidationError()
1. 기존 serializer의 ValidationError 구조를 버리고 API 전용 ValidationError를 새로 정의
2. 필드별 에러 정보 완전히 소실
# 1차 리뷰 요구 → 기존 ValidationError를 유지해라
## views/question_create.py
serializer = QuestionCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
category = get_category_or_raise(serializer.validated_data["category"])
## exception_handler.py
view = context.get("view")
# 질문 등록 API 전용 400 메시지
if isinstance(exc, ValidationError) and isinstance(view, QuestionCreateAPIView):
response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
return response
1. View에서 ValidationError를 새로 raise 하면 안 된다 그럼 기존 ValidationError를 살리기 위해
2. handler에서 메시지를 바꾸자
3. 기존 ValidationError 유지 / raise_exception=True 유지 / serializer.errors 유지
# 2차 리뷰 요구 → View단에서 Error Raise할때 해야할것 같다
## ExceptionHandler에서 API 의미를 해석하지 마라
# view
class QuestionCreateAPIView(APIView):
validation_error_message = "유효하지 않은 질문 등록 요청입니다."
# ExceptionHandler
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is None:
return None
view = context.get("view")
if isinstance(exc, ValidationError):
message = getattr(view, "validation_error_message", "유효하지 않은 요청입니다.")
response.data = {
"error_detail": message,
"errors": exc.detail,
}
return response
if isinstance(response.data, dict) and "detail" in response.data:
response.data = {"error_detail": str(response.data["detail"])}
return response
- 1. 요청이 들어온다 → title이 없음 → 검증 실패 예정
POST /api/v1/qna/questions
Content-Type: application/json
{
"content": "제목 없음",
"category": 1
}
- 2. View가 Serializer 검증을 호출한다 → DRF 내부적 작동(아래)
- 2-1. ValidationError 발생
- 2-2. 메시지/필드 정보는 serializer가 생성 / View는 여기서 아무 것도 안 함
if not serializer.is_valid():
raise ValidationError(serializer.errors)
- 3. DRF가 ExceptionHandler를 호출
- 3-1. context["view"]에 현재 View 인스턴스가 들어 있음
custom_exception_handler(
exc=ValidationError(...),
context={
"view": QuestionCreateAPIView(...),
"request": request,
...
}
)
- 4. ExceptionHandler가 “의미”를 읽는다
view = context.get("view")
message = getattr(
view,
"validation_error_message",
"유효하지 않은 요청입니다.",
)
- 5. Handler는 포맷만 바꾼다
response.data = {
"error_detail": message,
"errors": exc.detail,
}
- 6. 최종 응답
{
"error_detail": "유효하지 않은 질문 등록 요청입니다.",
"errors": {
"title": ["This field is required."]
}
}
if not serializer.is_valid():
raise ValidationError("유효하지 않은 질문 등록 요청입니다.")
1. DRF는 기본적으로 ValidationError는 Serializer가 책임진다 라는 전제를 깔고 있음.
2. serializer.errors 완전히 버림
3. 어떤 필드가 왜 잘못됐는지 알 수 없음 / 테스트에서 세밀한 검증 불가
# 유용한 경우
1. 내부 관리자용 API
2. 프론트가 에러 상세 안 씀 / ValidationError 의미가 항상 동일 / 빠른 개발이 최우선
detail = exc.detail
if isinstance(detail, list):
response.data = {"error_detail": str(detail[0])}
elif isinstance(detail, dict):
response.data = {"error_detail": next(iter(detail.values()))}
- 1. 손실되는 정보
- 2. 원래 serializer 에러
"title": ["This field is required."],
"content": ["This field may not be blank."]
- 3. handler 결과 → 어떤 필드인지 모름 / 여러 에러 중 하나만 남음 / 프론트에서 필드별 처리 불가
"error_detail": "This field is required."
if isinstance(exc, ValidationError):
detail = exc.detail
if isinstance(detail, list) and detail:
response.data = {"error_detail": str(detail[0])}
elif isinstance(detail, dict):
response.data = {"error_detail": next(iter(detail.values()))}
else:
response.data = {"error_detail": str(detail)}
return response
| 역할 | 담당 |
|---|---|
| 대표 메시지 | View |
| 상세 에러 데이터 | Serializer (exc.detail) |
- 1. 과거
exc.detail = {
"title": ["This field is required."],
"content": ["This field may not be blank."]
}
{
"error_detail": "This field is required."
}
- 2. 현재
exc.detail = {
"title": ["This field is required."],
"content": ["This field may not be blank."]
}
{
"error_detail": "유효하지 않은 질문 등록 요청입니다.",
"errors": {
"title": ["This field is required."],
"content": ["This field may not be blank."]
}
}
2025.12.16 😊
from rest_framework.views import APIView
from apps.qna.views.question.question_create import QuestionCreateAPIView
from apps.qna.views.question.question_list import QuestionListAPIView
class QuestionAPIView(APIView):
def get(self, request, *args, **kwargs):
return QuestionListAPIView.as_view()(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return QuestionCreateAPIView.as_view()(request, *args, **kwargs)
class QuestionListAPIView(APIView):
permission_classes: list = []
validation_error_message = "유효하지 않은 목록 조회 요청입니다."
def get(self, request: Request) -> Response:
query_serializer = QuestionListQuerySerializer(data=request.query_params)
serializer = QuestionListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
questions, page_info = get_question_list(**query_serializer.validated_data)
return Response(
{
"results": QuestionListSerializer(questions, many=True).data,
"page": page_info,
},
status=status.HTTP_200_OK,
)
query_serializer = QuestionListQuerySerializer(data=request.query_params)questions, page_info = get_question_list(**query_serializer.validated_data)- 1. 기본 QuerySet 구성
qs = (
Question.objects
.select_related("author", "category")
.annotate(answer_count=Count("ai_answers", distinct=True))
.order_by("-created_at")
)
.select_related("author", "category")- 2. Question 모델
class Question(models.Model):
author = models.ForeignKey(User, ...)
category = models.ForeignKey(QuestionCategory, ...)
- 3. select_related가 없으면?
questions = Question.objects.all()
for q in questions:
q.author.nickname # ❌ 매번 DB 쿼리
q.category.name # ❌ 매번 DB 쿼리
# 질문 20개 조회 → author 접근 → 20번 쿼리 / category 접근 → 20번 쿼리
# 총 41번 쿼리 → N+1 문제
- 4. select_related 사용
Question.objects.select_related("author", "category")
# SQL적 의미
SELECT ...FROM questions
LEFT OUTER JOIN users ON ...
LEFT OUTER JOIN question_categories ON ...
# 질문 + 작성자 + 카테고리 한 번에 / 이후 serializer에서 접근해도 추가 쿼리 없음
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.CharField()
author = QuestionAuthorSerializer()
answer_count = serializers.IntegerField()
thumbnail_image_url = serializers.CharField(allow_null=True)
카테고리 / 작성자 (닉네임 + 프로필) / 답변 수 / 조회수 / 썸네일 / 생성일
# Response
{
"results": [...],
"page": {
"page": 1,
"page_size": 10,
"total_pages": 3,
"total_count": 24
}
}
# 현재 카테고리 구조 (self FK) id | name | parent_id
백엔드 (id=1) 1 | 백엔드 | NULL
└─ Django (id=2) 2 | Django | 1
└─ ORM (id=3) 3 | ORM | 2
__in 필터# services/category_utils.py (또는 question_list_service 내부)
def get_descendant_category_ids(category_id: int) -> list[int]:
ids: list[int] = []
def collect_descendants(current_category_id: int) -> None:
ids.append(current_category_id)
children = QuestionCategory.objects.filter(
parent_id=current_category_id
)
for child in children:
collect_descendants(child.id)
collect_descendants(category_id)
return ids
ids: list[int] = []from rest_framework import serializers
class QuestionListQuerySerializer(serializers.Serializer):
# 조회 API에서 허용하는 "쿼리 파라미터" 전용 Serializer
# 역할: 요청 값의 유효성 검사만 담당
# DB 접근 ❌ | 비즈니스 로직 ❌ | 모델 의존 ❌
answered = serializers.BooleanField(required=False)
# 답변 여부 필터
# ?answered=true → 답변 있는 질문만
# ?answered=false → 답변 없는 질문만
# 파라미터가 없으면 전체 조회이므로 required=False
category = serializers.IntegerField(required=False)
# 카테고리 필터
# ?category=3 형태로 카테고리 ID만 받는다
# (대/중/소 계층 구조 처리는 Service에서 담당)
# 선택 필터이므로 required=False
search = serializers.CharField(required=False, allow_blank=True)
# 검색어 필터
# 제목(title) + 내용(content)에 대해 검색
# allow_blank=True:
# ?search= 처럼 빈 문자열이 와도 400 에러를 내지 않기 위함
page = serializers.IntegerField(
required=False,
min_value=1,
default=1,
)
# 페이지 번호 → ?page=1, ?page=2 ...
# 페이지는 1부터 시작해야 하므로 min_value=1
# 값이 없으면 첫 페이지 → default=1
page_size = serializers.IntegerField(
required=False,
min_value=1,
max_value=50,
default=10,
)
# 페이지 당 항목 수
# ?page_size=10
# min_value=1 : 최소 1개 이상
# max_value=50 : 한 번에 너무 많은 데이터 조회 방지 (DB 보호)
# 값이 없으면 기본 10개
from django.core.paginator import Paginator
# Django 기본 페이지네이터 (page / page_size 처리 담당)
from django.db.models import Count, OuterRef, Subquery, Case, When, BooleanField
# Count : 답변 개수 집계
# OuterRef : 서브쿼리에서 현재 Question row 참조
# Subquery : 썸네일 이미지 1개 추출
# Case / When : 조건에 따라 is_answered(Boolean) 생성
# BooleanField : is_answered 필드 타입 명시
from django.db.models.functions import Substr
# DB 레벨에서 문자열 일부만 잘라서 가져오기 (content 미리보기)
from apps.qna.exceptions.question_exceptions import QuestionListEmptyError
# 조회 결과가 없을 때 던지는 도메인 예외
from apps.qna.models import Question, QuestionImage
# Question : 질문 모델
# QuestionImage : 질문에 첨부된 이미지 모델
from .filters import (filter_by_answered, filter_by_category, filter_by_search,)
# 조회 조건별 QuerySet 필터 함수들
# - answered : 답변 여부 필터
# - category : 카테고리(대/중/소 포함) 필터
# - search : 제목 + 내용 검색
def get_question_list(
*,
answered: bool | None = None,
category: int | None = None,
search: str | None = None,
page: int,
page_size: int,
):
# 질문 목록 조회의 핵심 서비스 함수
# View는 이 함수에 검증된 파라미터만 넘긴다
qs = (
Question.objects
# 질문 조회의 시작점 QuerySet
.select_related("author", "category")
# author, category는 카드 UI에서 반드시 필요
# N+1 쿼리 방지를 위해 join 처리
.annotate(
answer_count=Count("answers", distinct=True),
# 질문 하나당 답변 개수 집계
# distinct=True: join으로 인한 중복 카운트 방지
is_answered=Case(
When(answers__isnull=False, then=True),
# 답변이 하나라도 존재하면 True
default=False,
# 답변이 없으면 False
output_field=BooleanField(),
# 명시적으로 Boolean 타입 지정
),
)
.order_by("-created_at")
# 최신 질문이 위로 오도록 정렬
)
qs = filter_by_answered(qs, answered)
# answered=true / false 여부에 따라 QuerySet 필터링
qs = filter_by_category(qs, category)
# 선택한 카테고리 + 하위 카테고리 포함 필터링
qs = filter_by_search(qs, search)
# 제목 + 내용에 대해 검색어 필터링
if not qs.exists():
# 모든 필터 적용 후 결과가 없으면
raise QuestionListEmptyError()
# "조회 가능한 질문이 존재하지 않습니다." 예외 발생
qs = qs.annotate(
content_preview=Substr("content", 1, 100)
# 질문 내용을 DB에서 미리 100자까지만 잘라서 가져옴
# 카드 UI용 미리보기 텍스트
)
thumbnail_subquery = (
QuestionImage.objects
# 질문에 첨부된 이미지 중
.filter(question=OuterRef("pk"))
# 현재 Question 행과 연결된 이미지들만 대상으로
.order_by("created_at")
# 가장 먼저 등록된 이미지를 썸네일로 사용
.values("img_url")[:1]
# img_url 필드만 가져오고, 1개만 선택
)
qs = qs.annotate(
thumbnail_image_url=Subquery(thumbnail_subquery)
# 각 Question에 썸네일 이미지 URL을 필드로 추가
)
paginator = Paginator(qs, page_size)
# QuerySet을 page_size 단위로 분할
page_obj = paginator.get_page(page)
# 요청한 page 번호에 해당하는 페이지 객체 반환
# 범위를 벗어나면 자동으로 마지막 페이지 처리
return page_obj.object_list, {
# 현재 페이지에 포함된 질문 목록 반환
"page": page,
# 현재 페이지 번호
"page_size": page_size,
# 페이지 당 항목 수
"total_pages": paginator.num_pages,
# 전체 페이지 수
"total_count": paginator.count,
# 전체 질문 개수
}
from django.db.models import QuerySet, Q
# QuerySet : Django ORM 쿼리 타입 힌트
# Q : OR 조건 검색을 위해 사용
from apps.qna.models import Question
# 필터 대상이 되는 Question 모델
from apps.qna.services.question.question_list.category_utils import get_descendant_category_ids
# 선택한 카테고리의 하위(중/소분류)까지 포함한 ID 목록을 구하는 유틸 함수
def filter_by_answered(qs: QuerySet[Question],answered: bool | None):
# 답변 작성 여부에 따른 필터 함수
# answered 값에 따라 QuerySet을 분기 처리한다
if answered is True:
# answered=true 인 경우 → 답변 개수가 1개 이상인 질문만 조회
return qs.filter(answer_count__gt=0)
if answered is False:
# answered=false 인 경우 → 답변이 하나도 없는 질문만 조회
return qs.filter(answer_count=0)
# answered 파라미터가 없는 경우(None)
# → 필터링 없이 전체 QuerySet 반환
return qs
def filter_by_category(qs: QuerySet[Question],category_id: int | None):
# 카테고리 기준 필터 함수
# 대분류 선택 시 하위 중/소분류까지 포함하는 역할
if category_id is None:
# 카테고리 필터가 없으면 그대로 반환
return qs
category_ids = get_descendant_category_ids(category_id)
# 선택한 카테고리 ID + 모든 하위 카테고리 ID 목록 생성
return qs.filter(category_id__in=category_ids)
# 해당 카테고리들에 속한 질문만 필터링
def filter_by_search(qs: QuerySet[Question],search: str | None):
# 검색어 기준 필터 함수
# 제목(title) + 내용(content)을 대상으로 검색
if not search:
# search가 None 이거나 빈 문자열이면
# 검색 조건을 적용하지 않고 그대로 반환
return qs
return qs.filter(
Q(title__icontains=search) |
Q(content__icontains=search)
# title 또는 content 중 하나라도
# search 문자열을 포함하면 조회
)
qs = (
Question.objects
.select_related(...)
.annotate(...)
.order_by(...)
)
Question.objects → 질문 테이블을 조회하겠다는 선언.select_related("author", "category") .annotate(answer_count=Count("answers", distinct=True),)- 1
qs = Question.objects.all()
- 2
qs = (
Question.objects
.filter(...)
.annotate(...)
)
qs = Question.objects.all()
qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)
# QuerySet evaluation(실행)
qs.exists()
list(qs) # 여기서 실행하는 듯
for q in qs:
paginator = Paginator(qs, ...)
# 내 코드 기준
if not qs.exists():
paginator = Paginator(qs, page_size) # DB 조회 1번
page_obj = paginator.get_page(page) # 여기서 실제 SELECT 실행
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
| 항목 | answer_count | is_answered |
|---|---|---|
| 타입 | Integer | Boolean |
| 의미 | 몇 개 있는지 | 하나라도 있는지 |
| SQL | COUNT() | CASE WHEN |
| 용도 | 숫자 표시 | 탭 필터 / 상태 |
에러 발생 / qs에는 있고 시리얼라이즈에는 없는경우는 에러 발생 X
| 상황 | 결과 |
|---|---|
| qs에 있음 + serializer에 있음 | ✅ 정상 출력 |
| qs에 있음 + serializer에 없음 | ❌ 무시 |
| qs에 없음 + serializer에 있음 | 💥 에러 |
Serializer에 정의된 필드 값은 모델 필드이거나, annotate로 qs에 붙어 있거나,
qs = (
Question.objects
.select_related("author", "category")
.annotate(...)
.order_by("-created_at")
)
qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)
if not qs.exists(): # 여기서 첫번 째 실행
기본 질문 목록에
[프론트]
GET /questions?answered=true&category=3&page=1
↓
[View]
- QuerySerializer로 요청 검증
↓
[Service]
- Question QuerySet 생성
- annotate로 "카드에 필요한 정보" 붙이기
- 필터 적용
- pagination
↓
[Serializer]
- DB에서 나온 데이터를 카드 형태 JSON으로 변환
↓
[Response]
- 프론트가 바로 카드로 그릴 수 있는 데이터
# 프론트 요청 → 프론트는 "조건"만 보냄 / 데이터는 안보냄(GET)
GET /questions?answered=true&category=3&search=django&page=1
# View: 요청 유효성 검사 (입력 단계) → 이 단계에서 이미 값들은 “확정”됨
- answered → True / False / None
- category → int | None
- search → str | None
page / page_size → 기본값 보정
query_serializer = QuestionListQuerySerializer(data=request.query_params)
query_serializer.is_valid(raise_exception=True)
# Service: “조회 설계도(qs)” 만들기
- 등록할 때 없던 값들을 조회할 때 “새로 계산해서 붙인다”
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
#
class Question(models.Model):
view_count = models.BigIntegerField(default=0)# 보통 이런코드 존재
question.view_count += 1
question.save(update_fields=["view_count"])1. 등록 API (POST)
제목 / 카테고리 / 내용 / 이미지 → 사실 데이터만 저장
2. 조회 API (GET)
기존 데이터 + 계산된 데이터 + 집계된 데이터 → 표현을 위한 데이터 생성
| 필드 | 출처 |
| --------------- | --------------------- |
| title | Question |
| content_preview | Question (가공) |
| category path | QuestionCategory (조합) |
| answer_count | Answer (COUNT) |
| is_answered | Answer (존재 여부) |
| view_count | Question |
| thumbnail | QuestionImage |
URL (?answered=true&category=3&foo=123)
↓
request.query_params (모든 쿼리 문자열)
↓
QuerySerializer(data=request.query_params)
↓
.is_valid()
↓
validated_data (정제된 값만)
↓
Service 함수 인자
↓
filter 함수
GET /questions?answered=true&category=3&search=django&page=1&foo=123
request.query_params == {
"answered": "true",
"category": "3",
"search": "django",
"page": "1",
"foo": "123",
}
- 1. “관문” 역할을 하는 게 QuerySerializer
query_serializer = QuestionListQuerySerializer(
data=request.query_params
)
query_serializer.is_valid(raise_exception=True)
- 2. QuerySerializer에 정의된 필드만 살아남음
class QuestionListQuerySerializer(serializers.Serializer):
answered = serializers.BooleanField(required=False)
category = serializers.IntegerField(required=False)
search = serializers.CharField(required=False, allow_blank=True)
page = serializers.IntegerField(default=1)
page_size = serializers.IntegerField(default=10)
data = query_serializer.validated_data
questions, page_info = get_question_list(
answered=data.get("answered"),
category=data.get("category"),
search=data.get("search"),
page=data["page"],
page_size=data["page_size"],
)
| 항목 | 보장 |
|---|---|
| 타입 | bool / int / str |
| 기본값 | page=1, page_size=10 |
| 허용 범위 | min / max |
| 없는 값 | None 처리 |
| 이상한 값 | 400으로 차단 |
2025.12.17
# services.py
qs = (
Question.objects
.select_related("author", "category")
.annotate(
answer_count=Count("answers", distinct=True),
is_answered=Case(
When(answers__isnull=False, then=True),
default=False,
output_field=BooleanField(),
),
)
.order_by("-created_at")
)
# srializers.py
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
author = QuestionAuthorSerializer()
content_preview = serializers.CharField()
answer_count = serializers.IntegerField()
is_answered = serializers.BooleanField()
thumbnail_image_url = serializers.CharField(allow_null=True)
class Meta:
model = Question
fields = [ # Question 모델 필드 (자동 OK)
"id", # id / title / view_count / created_at
"category", # select_related 로 해결되는 것
"author", # author / category
"title", # annotate로 추가된 가짜 컬럼
"content_preview", # answer_count / is_answerd
"answer_count", # annotate로 추가된 가짜 컬럼(서비스에서 처리)
"is_answered", # content_preview / thumbnail_image_url
"view_count",
"created_at",
"thumbnail_image_url",
]
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk")) # question: QuestionImage의 필드명
.order_by("created_at")
.values("img_url")[:1]
)
qs = qs.annotate(thumbnail_image_url=Subquery(thumbnail_subquery))
OuterRef("pk").values("img_url")[:1][:1] : 그 중 딱 1개만 (LIMIT 1)paginator = Paginator(qs, page_size)
page_obj = paginator.get_page(page)
return page_obj.object_list, {
"page": page,
"page_size": page_size,
"total_pages": paginator.num_pages,
"total_count": paginator.count,
}
# page info 메타
"page": page | → 요청한 페이지 번호
"page_size": page_size | → 한 페이지에 몇 개씩
"total_pages": paginator.num_pages | → 전체 페이지 수
(내부적으로 count 기반으로 계산됨)
"total_count": paginator.count | → 전체 질문 개수 (필터 적용 이후 기준)
paginator.count는 보통 DB에 SELECT COUNT(*) ... 같은 쿼리를 한 번 날려서 전체 개수를 알아냄
qs = (
Question.objects # 바깥 쿼리(outer query) ⭐️
.select_related("author", "category")
.annotate(
thumbnail_image_url=Subquery(thumbnail_subquery)
)
)
# QuestionImage 테이블을 조회하는 쿼리
thumbnail_subquery = (
QuestionImage.objects
.filter(question=OuterRef("pk"))
...
)
[바깥] Question ────────────────▶ 한 row씩 처리
│
└── [안쪽] QuestionImage ─▶ 해당 Question의 이미지 1개
바깥 쿼리: Question
-------------------
row 1: pk=10
row 2: pk=11
row 3: pk=12
안쪽 쿼리: QuestionImage
-----------------------
question_id = OuterRef("pk")
# DB처리
row 1 → question_id = 10
row 2 → question_id = 11
row 3 → question_id = 12
각 Question마다 자기 이미지 1개를 찾아서 붙임
조회 시 DB는 Question을 기준으로 한 row씩 처리하면서,
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 QuestionAuthorSerializer(serializers.Serializer):
nickname = serializers.CharField()
profile_image_url = serializers.CharField(allow_null=True)
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
author = QuestionAuthorSerializer()
content_preview = serializers.CharField()
answer_count = serializers.IntegerField()
is_answered = serializers.BooleanField()
thumbnail_image_url = serializers.CharField(allow_null=True)
class Meta:
model = Question
fields = [
"id",
"category",
"author",
"title",
"content_preview",
"answer_count",
"is_answered",
"view_count",
"created_at",
"thumbnail_image_url",
]
def get_category(self, obj: Question) -> dict:
return build_category_path(obj.category)
class QuestionAuthorSerializer(serializers.Serializer):
nickname = serializers.CharField()
profile_image_url = serializers.CharField(allow_null=True)
# 입력 대상
obj.author → User 모델 인스턴스
# 출력 결과
{
"nickname": "졸린개발자",
"profile_image_url": null
}
# 결과 → 카드 UI에 필요 없는 정보까지 전부 노출됨 / 보안 & 설계적으로 안좋음
"author": 3 | author = UserSerializer() | "author": {
"id": 3,
"email": "...",
"phone_number": "...",
"role": "...",
...
}
# serializers.py
class QuestionListSerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
...
def get_category(self, obj: Question) -> dict:
return build_category_path(obj.category)
# obj.category → Question.category = QuestionCategory 객체
category = SerializerMethodField()
value = self.get_category(obj) → obj: 현재 직렬화 중인 Question 객체 하나
# 목록조회
Question 1번 → get_category(Question#1)
Question 2번 → get_category(Question#2)
Question 3번 → get_category(Question#3) → 각 row마다 호출
def build_category_path(category: QuestionCategory) -> dict:
names: list[str] = []
current = category
while current:
names.append(current.name)
current = current.parent
return {
"id": category.id,
"path": " > ".join(reversed(names)),
}
백엔드 (id=1)
└─ Django (id=2)
└─ ORM (id=3)
# 질문이 ORM(id=3)에 속해 있다고 가정
- 1. 초기 상태
names = []
current = category # ORM
- 2. while 루프 (부모로 계속 올라감)
names.append("ORM")
current = current.parent # Django
names.append("Django")
current = current.parent # 백엔드
names.append("백엔드")
current = current.parent # None
- 3. 종료
while current: # False → 탈출
- 4. 이 시점 names
names == ["ORM", "Django", "백엔드"]
- 5. reversed + join
"path": " > ".join(reversed(names))
reversed(names) == ["백엔드", "Django", "ORM"] = 결과: "백엔드 > Django > ORM"
- 6. 최종 반환값
{
"id": 3,
"path": "백엔드 > Django > ORM"
}
_category_path_cache = {}{ category_id: category_path_dict }를 저장하는 임시 저장소 _category_path_cache 1개 생성__init__에 두어야 하는 이유__init__ 에서 인스턴스 변수로 초기화하고,super().__init__(*args, **kwargs)class QuestionAPIView(APIView):
def get_authenticators(self):
if self.request.method == "GET":
return []
return super().get_authenticators()
# “APIView에 설정된 기본 인증 방식을 그대로 사용하겠다”는 뜻(명령어 기준 아래부터 적용)
def get_permissions(self):
if self.request.method == "POST":
return [QuestionCreatePermission()]
return []
# DRF 전체 요청 흐름
1. get_authenticators()
2. authenticate() → request.user 설정
3. get_permissions()
4. permission.has_permission()
5. view method (get / post)
- 1. get_authenticators()
if self.request.method == "GET":
return [] → 인증 클래스 아예 없음
- 2. authenticate 단계
인증기 없음 / JWTAuthentication 호출 ❌ / request.user = AnonymousUser
- 3. get_permissions()
return [] → 권한 검사 없음
- 4. View 로직 실행
def get(self, request): → 누구나 조회 가능
...
POST /questions
Authorization: Bearer <jwt>
- 1. get_authenticators()
return super().get_authenticators() → settings에서 가져옴:
DEFAULT_AUTHENTICATION_CLASSES = [
JWTAuthentication
]
- 2. authenticate 단계
JWTAuthentication.authenticate(request)
Authorization 헤더 파싱 / 토큰 검증 / 유저 조회 / request.user = User(...)
- 3. get_permissions()
return [QuestionCreatePermission()]
- 4. View 로직 실행
def post(self, request): → 질문 등록 성공
...
{
"error_detail": "유효하지 않은 목록 조회 요청입니다.",
"errors": {
"page": ["유효하지 않은 페이지입니다."]
}
}