oz_externship - 6~ 10 코드 작업

김기훈·2025년 12월 13일

부트캠프 프로젝트

목록 보기
20/39

질문 목록 조회 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


SpecAPI 준비

  • Query Parameter (Spec 기준)

				?page=1 								# 페이지 번호
				?page_size=10							# 페이지당 개수
				?answered=true | false  				# 답변 여부 필터
				?category_id=12							# 카테고리 필터
				?keyword=django							# 제목/내용 검색
  • 최상위 응답 구조 (Pagination 포함)

{
  "count": 123,
  "next": "/api/v1/qna/questions?page=2",
  "previous": null,
  "results": []
}
  • 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
}
  • results 배열 전체 예시 (Mock)

{
  "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


serializers.py

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()

  • QuestionListSpecSerializer

    • id = serializers.IntegerField() → 카드 클릭 → 상세조회로 가야 하니까 식별자 필수
    • category = serializers.DictField → Dict 형태로 간단히 계약만 고정
    • author = serializers.DictField → allow_null=True
      • 최소 정보만 내려주고, 개인정보/내부키(email, id 등)는 숨기는 구조
    • 목록에서 content 전체를 주면 payload가 커지고 UX도 안 좋음
      • “미리보기” 필드를 별도로 둬서 프론트가 그대로 카드에 뿌릴 수 있게 함
        title = serializers.CharField()
        content_preview = serializers.CharField()
    
        answer_count = serializers.IntegerField()
        view_count = serializers.IntegerField() 	# 실제 API에서는 Count() 같은 집계
    • thumbnail_image_url / allow_null=True, required=False
      • 썸네일은 이미지가 없는 질문도 있으니 null 허용
      • required=False는 “키가 아예 없을 수도 있는 상황”까지 허용
    • created_at = serializers.DateTimeField() → 최신순 정렬 가능
    • is_answered = serializers.BooleanField()
      • 프론트에서 answer_count > 0으로 계산할 수도 있지만
      • 탭 필터/표시 기준이 되므로 백엔드가 명확히 내려주는 게 안전

views.py

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,
            }
        )
  • def get(self, request):

    • mock_results = [...]
      • Spec API는 DB 조회 없이 “results 배열 샘플”만 있으면 됨, 그래서 리스트(dict)형태로 만듬
    • serializer = QuestionListSpecSerializer(mock_results, many=True)
      • serializer의 역할은 “DB 저장”이 아닌
        • mock 데이터가 스펙(필드 타입/필수 여부)을 만족하는지 체크 / 응답을 일관된 형태로 직렬화
  • DRF 페이지네이션 기본 형태

return Response({
  "count": 2,
  "next": None,
  "previous": None,
  "results": serializer.data,
})

조회 API

  • 질문 등록과 엔드포인트가 똑같기 때문에 등록의 APIView에 GET를 추가 예정
    • 즉, 같은 URL + HTTP Method로 책임 분리
  • 요구사항
    • 모든 웹사이트 이용자 가능 / 로그인 ❌ 필요 없음 / 권한 ❌ 없음
      • 조회는 Permission을 아예 적용하지 않는다
  • permission_classes 를 메서드별로 다르게 써야 함
  • 같은 path + 같은 View + method 분기

구조 요약

GET /api/v1/qna/questions
├─ QueryParam Serializer (400)
├─ Service (필터 / 검색 / 정렬 / 페이지네이션)
│ └─ 결과 없음 → 404
└─ Response Serializer (카드 리스트)


시리얼라이저를 분리한 이유

  • 역할(입력 검증 vs 출력 표현)이 완전히 다르기 때문
    • 하나의 Serializer로 다 처리 하려고 한다면
      • 의미 없는 필드 혼재 / Validation 책임 혼란
구분역할
입력용 Serializer요청 데이터 검증QueryParam, POST body
출력용 Serializer응답 데이터 표현카드 UI, 상세 화면

실행 흐름

클라이언트
  ↓ GET /api/v1/qna/questions
URL → View
  ↓
Query Serializer (요청 검증) ──❌→ 400
  ↓
Service (조회 로직)
  ├─ 필터 / 검색 / 정렬
  ├─ pagination
  └─ 결과 없음 ──❌→ 404
  ↓
Response Serializer (카드 변환)
  ↓
Response (200)

  • URL → View

    • View부터 시작하는 이유 → GET /api/v1/qna/questions
      • path("questions", QuestionAPIView.as_view()) → DRF 요청 파이프라인이 시작
  • View의 역할 (QuestionListAPIView)

    • 요청 데이터 검증 / Service 호출 / Response 반환
# 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 터짐 / 클라이언트는 왜 실패했는지 모름
  • Query Serializer

    • 위의 예시를 방지 하기 위해 Query Serializer
# 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는 항상 믿을 수 있는 값만 받는다" 라는 보장을 위해서
-  타입 검증 → strbool / int | 범위 제한 → page ≥ 1
- 기본값 처리 → page, page_size | 잘못된 요청 차단 → 400
  • 400 위치

if not query_serializer.is_valid():
    raise QuestionListValidationError()

- 이유
- 1. 비즈니스 오류가 아님 / “조회할 수 없는 상태”도 아님 / 요청 자체가 이상함

  • Service로 이동

    • questions, page_info = get_question_list(...)
    • Service가 받는 요청 → “조건을 만족하는 질문 목록을 만들어서 돌려줘”
    • 기본 QuerySet 생성

# 카드 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에서 처리해야 성능 보장
  • 썸네일 이미지 (Service에서 처리하는 이유)

thumbnail_subquery = (
    QuestionImage.objects
    .filter(question=OuterRef("pk"))
    .values("img_url")[:1]
)

1. 썸네일은 DB 조회 결과 / 표현 로직 ❌ / 조회 전략 ⭕
  • Pagination

    • paginator = Paginator(qs, page_size)
    • pagination은 데이터 접근 전략 / “몇 개를 가져올 것인가”는 비즈니스 판단
    • Serializer/View에 두면 테스트가 어렵고 중복이 발생 함
  • Response Serializer(view)

    • QuestionListSerializer(questions, many=True).data
    • “DB에서 가져온 데이터를 카드 UI에서 바로 쓸 수 있게 변환”

serializer

  • "입력값 검증"과 "출력값 변환"을 담당하는 도구

question_list_query

  • 조회 API에서 허용하는 조회 조건 → 요청 유효성만 책임
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)
  • QuestionListQuerySerializer

    • 이 Serializer는 "응답을 만들어주는" 용도가 아닌 GET /questions?answered=...&page=...
      • 같은 query string 값들이 올바른지 검사하고, 안전한 타입으로 변환해주는 역할
  • ModelSerializer가 아니라 Serializer를 쓰는 이유

    • ModelSerializer는 DB 모델(Question)과 연결된 "필드"를 기반으로 자동 생성하는 용도
    • query parameter는 DB 모델 필드가 아니라 "조회 조건"이므로
      • 순수 Serializer(Serializer)를 써서 원하는 조건만 명확하게 정의
  • answered

    • GET /questions?answered=true
      • required=False → 없으면 미적용
      • BooleanField → "true"/"false", "1"/"0" 내부적으로 → True/False로 변환하려고 시도
      • answered=True → 답변 있는 질문만 / answered=False → 답변 없는 질문만
      • answered 미전달 → 전체
  • category

    • GET /questions?category=3
      • "정수인지"만 검사 (IntegerField는 "3" 같은 문자열을 정수 3으로 변환)
      • 실제로 DB에 존재하는 카테고리인지 여부는 보통 Service에서 처리
    • GET /questions?search=django
      • allow_blank=True: → (빈 문자열)도 "유효한 값"으로 취급 → "검색 조건 없음"(400 출력 방지)
  • page

    • 페이지 번호를 정의 → GET /questions?page=2
      • required=False → 페이지 번호를 안 보내면 기본 1페이지로 처리하고 싶기 때문에 선택
        • default=1 page 파라미터가 없을 때 validated_data['page'] 값은 1
        • View/Service에서 "없으면 1로 처리"를 따로 구현할 필요 없음
      • min_value=1
        • page=0, page=-1 같은 비정상 요청을 막음
        • 이 조건에 걸리면 is_valid()가 False 처리 QuestionListValidationError(400)
  • page_size

    • 한 페이지에 몇 개를 보여줄지(page size)를 정의 → GET /questions?page_size=20
      • max_value=50
        • 너무 큰 값(예: 1000)을 요청해서 서버 부담/성능 문제 생기는 걸 막음

question_list.py

from 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",
        ]
  • QuestionAuthorSerializer

    • 작성자 정보는 Question 모델이 아니라 User 모델에 있고,
      • “카드 UI에 필요한 일부 정보만” 보여주기 위해서 존재
        • User 전체를 그대로 노출 하지 않고 / nickname, profile_image_url만 노출
    • nickname

      • read_only=True를 명시하지 않아도,
        • 이 Serializer는 응답 전용으로만 쓰이기 때문에 실질적으로 read-only 역할
    • profile_image_url

      • allow_null=True → 프로필 이미지가 없는 유저가 있을 수 있음
  • QuestionListSerializer

    • 질문 목록 화면에서 카드 하나를 그리기 위한 응답 전용 Serializer
    • 입력 검증 안 함 / DB 조회 안 함 / 이미 준비된 데이터를 UI 친화적인 JSON 형태로 변환
    • category

      • 카테고리를 문자열로 표현하기 위한 필드
      • Question 모델의 category 필드는 ForeignKey 이지만
        • category = "프론트엔드 > React > 상태관리" 의 가공된 문자열 결과를 기대
      • 필드는 모델 필드와 1:1 매핑 / Service에서 가공된 값을 내려줄 것을 전제로 함
    • author = QuestionAuthorSerializer()

      • 작성자 정보를 중첩 Serializer로 표현 → 아래와 같은 JSON 구조를 만들기 위해
      • User 모델을 그대로 쓰지 않고, 필요한 정보만 묶어서 노출하기 때문에 보안·확장성 면에서도 안전
      "author": {
        "nickname": "dev_kim",
        "profile_image_url": "https://..."
      }
    • answer_count

      • 질문에 달린 답변 수 / Question 모델에 실제로 존재하지 않음
        • Service에서 annotate(Count(...))로 만든 값
      • Serializer가 이 필드를 명시적으로 선언하는 이유:
        • “이 응답에는 answer_count가 반드시 포함된다”는 명시적 계약 (누락시 에러)
      • thumbnail_image_url
        • 질문 내용에 포함된 이미지 중 대표 썸네일
        • 이미지가 없는 질문도 허용하기 위해서 allow_null=True
        • Service에서 첫 번째 이미지를 Subquery로 가져오거나 없으면 null을 내려줌
    • class Meta:

      • ModelSerializer의 설정 영역, 어떤 모델을 기준으로 할지, 어떤 필드를 응답에 포함할지 정의
      • model = Question

        • 이 Serializer의 “기본 모델”은 Question
        • id, title, content, view_count, created_at 같은 필드는
          • Question 모델에서 직접 가져온다는 뜻
    • fields =

      • 최종 응답 JSON에 포함될 필드 목록 / 이 순서 그대로 응답에 나감
    • 요약

      • “질문 목록 화면에서 카드 하나를 만들기 위해서버가 책임져야 할 모든 정보를,
        • 프론트가 바로 쓰기 좋은 형태로 제공하는 응답 전용 Serializer”

question_list_service

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,
    }

  • from django.db.models import Count, OuterRef, Subquery, Q
    • ORM 쿼리를 더 똑똑하게 만들기 위한 도구들
      • Count → 연관된 데이터 개수 집계 (답변 수)
      • OuterRef → 서브쿼리에서 바깥 QuerySet의 필드를 참조
      • Subquery → “질문별 첫 번째 이미지” 같은 값 가져오기
      • Q → OR 조건, 복합 검색 조건 작성
    • 조회 API는 단순 CRUD가 아니라 “가공된 조회”라서 이런 도구들이 필요
  • from django.core.paginator import Paginator
    • Django 기본 페이지네이션 도구
    • QuerySet을 page / page_size 기준으로 나눠주는 역할
    • DRF Pagination을 안 쓴 이유
      • 커스텀 응답 구조 / 서비스 레벨에서 페이지 정보를 통제하기 위함
  • def get_question_list

    • 조회 API 전용 Service 함수 / View에서 직접 ORM을 만지지 않기 위해 분리
    • *

      • 모든 인자를 키워드 인자로만 받게 강제
        • get_question_list(answered=True, page=1, page_size=10)
        • get_question_list(True, 1, 10)
    • answered: bool | None = None

      • 답변 여부 필터 / QuerySerializer에서 이미 타입 검증 완료된 값
      • True → 답변 있는 질문만 / False → 답변 없는 질문만 / None → 필터 적용 안 함
    • category: int | None = None → 카테고리 ID 필터 → None이면 전체 카테고리
      • search: str | None = None → 제목 + 내용 검색에 사용
    • page: int, page_size: int → 페이지네이션 필수 인자
    • QuerySerializer에서 기본값 범위 검증 이미 끝났기 때문에 Service는 믿고 사용
  • 기본 QuerySet

        qs = (
            Question.objects
            .select_related("author", "category")
            .annotate(answer_count=Count("ai_answers", distinct=True))
            .order_by("-created_at")
        )
    • QuerySet 생성 시작 / 아직 DB 쿼리는 실행되지 않음 (Lazy Evaluation)
    • .select_related("author", "category")

      • Question → author(User), category(FK)
      • 사용하지 않을 경우
        • 질문 10개 조회 / 작성자/카테고리 접근 시 N+1 쿼리 발생
    • .annotate(answer_count=Count("ai_answers", distinct=True))

      • 질문마다 답변 수를 계산 (카드 UI에 필요한 핵심 데이터)
        • ai_answers: Question ↔ QuestionAIAnswer related_name
        • distinct=True 이유: join 상황에서 중복 count 방지
    • .order_by("-created_at")

      • 최신순 정렬
  • 검색 필터

    • if search: → 빈 문자열 ""은 False → 즉, search="" → 검색 미적용
        qs = qs.filter(
            Q(title__icontains=search) |
            Q(content__icontains=search)
        )

- 1. 제목 OR 내용 검색 - 2. icontains → 대소문자 무시 부분 검색
  • if not qs.exists()

    • 쿼리를 실제로 한 번 실행 / 결과가 0건이면 404 출력
  • thumbnail_subquery

    • 질문 하나당 이미지 여러 개 가능 / 그중 첫 번째 이미지 하나만 가져오기
    • OuterRef("pk")

      • 바깥 QuerySet의 Question.id 참조
  • qs = qs.annotate(thumbnail_image_url=Subquery(thumbnail_subquery))

    • 위 서브쿼리를 thumbnail_image_url 필드로 추가
      • 이제 QuerySet 각 row는 question.thumbnail_image_url
  • paginator = Paginator(qs, page_size)

    • 전체 QuerySet을 page_size 단위로 분할
  • page_obj = paginator.get_page(page)

    • 요청한 페이지 번호에 해당하는 객체 반환 / 범위를 벗어나면 마지막 페이지 반환 (Django 기본 동작)
  • return page_obj.object_list, {}

    • Service는 데이터 + 메타 정보를 함께 반환 / View에서 응답 구조를 만들기 위함
      • "page": page → 현재 페이지 번호 / "page_size": page_size → 페이지당 항목 수
      • "total_pages": paginator.num_pages → 전체 페이지 수
      • "total_count": paginator.count → 전체 질문 개수
  • 요약

    • “조회 조건을 신뢰할 수 있는 입력으로 받아 필터링·검색·집계·페이지네이션·가공을 모두 수행하고
      • ‘카드 UI에 바로 쓸 수 있는 질문 목록’을 만들어주는 핵심 로직”

views/question_list.py

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,
        )

  • from rest_framework.request import Request
    • Django의 HttpRequest를 감싼 객체
    • request.query_params / request.data / request.user 같은 DRF 전용 속성 사용 가능
  • from rest_framework.response import Response
    • DRF 전용 Response 객체 / Django의 HttpResponse 대신 사용
    • dict를 그대로 넘기면 JSON으로 자동 변환 / DRF Exception Handler와 자연스럽게 연동
  • from ... import (QuestionListQuerySerializer,)
    • 조회 조건(query parameter) 검증 전용 Serializer
    • 이 Serializer는: DB ❌ / 응답 ❌ / 오직 요청 유효성만 책임
  • from ... import QuestionListSerializer
    • 응답 전용 Serializer
    • 카드 UI에서 바로 사용할 수 있는 형태로 데이터를 변환 / 입력 검증 ❌ / 표현만 담당
  • from ... import get_question_list
    • 조회 API의 핵심 비즈니스 로직
    • View에서 ORM을 직접 다루지 않기 위해 분리 / 필터링 / 검색 / 집계 / 페이지네이션 전부 여기서 처리
  • class QuestionListAPIView(APIView)

    • “질문 목록 조회”를 담당하는 API View
    • GET /api/v1/qna/questions 요청을 처리
    • def get(self, request: Request) -> Response:

      • HTTP GET 요청을 처리하는 메서드 / 이 메서드가 호출된다는 건
        • GET /api/v1/qna/questions 요청이 들어왔다는 뜻
    • query_serializer = QuestionListQuerySerializer(data=request.query_params)
      • request.query_params
        • URL의 ?answered=true&page=2 같은 값들
        • 이 데이터를 Query Serializer에 넘김(아직 검증 ❌, 단순히 “검증 준비 상태")
    • if not query_serializer.is_valid():
      • Serializer에 정의된 규칙 타입 / 범위 / 필수 여부 이 규칙 중 하나라도 위반하면 False
        questions, page_info = get_question_list(
            **query_serializer.validated_data
        )

- 1. validated_data → 타입 변환 완료 / 기본값 적용 완료 / 신뢰 가능한 데이터
- 2. ** 언패킹 → Service 함수의 키워드 인자와 정확히 매칭
- 3. View는: “무엇을 조회할지” 결정 ❌ / 그냥 Service에 위임
  • return Response

    • 결과를 클라이언트에 돌려주는 단계
      • Response 객체로 감싸면
        • JSON 변환 / 상태 코드 / Exception Handler 연동 / 자동 처리
    • "results": QuestionListSerializer(questions, many=True).data,

      • questions → Service에서 만든 QuerySet (페이지 단위)
      • many=True → 여러 개의 Question 객체
      • Serializer → 각 Question을 카드 JSON 구조로 변환
    • "page": page_info

      • 페이지 관련 메타 정보
      • 프론트에서: 페이지 버튼 / 전체 개수 / 마지막 페이지 계산
  • 요약

    • “요청 파라미터를 안전하게 검증한 뒤, 조회 로직은 Service에 위임하고,
    • 결과를 카드 UI에 맞게 직렬화해서 반환하는 아주 얇고 명확한 컨트롤러”

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)


코드 개선

  • 코치님의 리뷰 반영

views.py

# 이전
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가 얇아짐 / 인터페이스 안정성 증가

services.py

# 이전
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)
  • 비교

    • 리뷰 전
      • View에서 serializer 데이터를 분해하여 service에 전달하고,
      • 생성 서비스 내부에서 검증 로직까지 함께 처리
    • 리뷰 후
      • 검증된 데이터는 그대로 service에 전달하고,생성 로직과 도메인 검증 로직을 분리하여
      • service의 책임을 명확히 함

코드개선 2

# 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()))}

1. reverse 사용

  • “테스트 코드에서 URL을 문자열로 하드코딩하지 말고, reverse() + url name으로 가져와라”
    • 현재 self.url = "/api/v1/qna/questions"
      • 테스트는 통과할 수 있음 하지만 URL 구조가 바뀌는 순간 테스트가 전부 깨짐
  • URL을 이름(name)으로 역참조하는 공식 방법
    • path("questions", QuestionCreateAPIView.as_view(), name="question_create"),
      • name="question_create" 이 이름을 기준으로 URL을 가져오라는 뜻
  • 개선

# 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")

2. 400 메시지 포맷팅

  • ValidationError를 View에서 직접 발생시키는 방식도 검토했으나,
    • Serializer가 제공하는 검증 책임과 필드별 오류 정보를 유지하기 위해
    • raise_exception=True를 사용하고 View에서는 에러 메시지 정책만 선언하는 방식이
      • 구조적으로 더 적절하다고 판단
# 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. 레이어 침범
  • 안좋은 이유

    • 이건 View 책임인데 왜 전역 핸들러에서 분기 ?
      | 레이어 | 책임 |
      | ----------------- | -------------------------- |
      | View | “이 API에서 어떤 에러 메시지를 쓸 것인가” |
      | Serializer | “무엇이 잘못되었는가” |
      | Exception handler | “응답 포맷을 어떻게 통일할 것인가” |
  • 해결

# 처음 코드 
## 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

  • 요약

    • 검증 실패는 Serializer가 발생시키고 에러 의미(메시지 정책)는 View가 선언하고
      • 응답 포맷은 ExceptionHandler가 통일한다
- 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 의미가 항상 동일 / 빠른 개발이 최우선

정보 손실

  • ValidationError detail을 가공하는 과정에서 정보 손실이 생김
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."
  • 형태별 분기로직 불 필요

    • 기존 전제 (형태 분기 필요)
      • ValidationError를 → “사용자에게 보여줄 최종 메시지”로 바로 변환해야 한다
    • 지금 전제 (형태 분기 불필요)
      • ValidationError의 detail
        • 보존해야 할 ‘원본 데이터’ → 최종 메시지는 View가 이미 결정했다
    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
  • 필요했던 이유

    • 이전 목표 → "error_detail": "어떤 한 문장"
      • 하지만 detail의 실제 타입은 제각각임
    • 지금의 전제
      • ValidationError의 detail은 “보여줄 문장”이 아니라 “보존해야 할 원본 데이터”다
      • 사용자에게 보여줄 데이터 : error_detail = view.validation_error_message
역할담당
대표 메시지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 😊


question_api.py

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)
  • 등록 / 조회 공용 진입점 → QuestionAPIView → method 분기
    • 실제 운영용 조회 API → GET /api/v1/qna/questions
      • 즉, GET 요청 → QuestionListAPIView.get() 로 위임

question_list.py(view)

  • 요청 검증 & 서비스 호출

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로 요청 파라미터 검증

    • query_serializer = QuestionListQuerySerializer(data=request.query_params)
      • 검증 대상 → answered (bool) / category (int) / search (str) / page, page_size
        • 조회 조건만 검증, 모델/DB 접근 ❌
  • Service 호출

    • questions, page_info = get_question_list(**query_serializer.validated_data)
    • View는 비즈니스 로직 없음 → 필터링 / 페이징 / ORM 최적화 → 전부 Service 책임

Service 단계 – 핵심 비즈니스 로직

  • get_question_list() 전체 흐름

- 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")

    • ForeignKey로 연결된 객체를 JOIN으로 한 번에 가져오기 (N+1 방지)
    • question.author / question.category 는 DB에서 별도 조회가 필요한 FK 관계
- 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에서 접근해도 추가 쿼리 없음

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
  }
}

category 하위 포함 필터 확장

# 현재 카테고리 구조 (self FK)		 id | name    | parent_id
백엔드 (id=1)					   1  | 백엔드   | NULL
 └─ Django (id=2)				2  | Django | 1
     └─ ORM (id=3)				3  | ORM    | 2
  • Python 재귀로 하위 카테고리 전부 가져오기

    • 선택한 category → category의 모든 자식 → 그 자식의 자식들 → id 리스트로 만들어서 __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
  • def get_descendant_category_ids(category_id: int)

    • category_id: 기준이 되는 선택된 카테고리 id
      • 대분류/중분류/소분류 전부 가능
    • 반환값: 자기자신+모든 하위 카테고리 id 리스트
  • ids: list[int] = []

    • DFS를 돌면서 발견한 모든 카테고리 id를 누적(DFS(깊이 우선 탐색))
  • def dfs(cat_id: int)

    • 재귀 호출용 내부 함수 / 반환값은 없음 → 결과는 ids에 계속 쌓음

serializers.py(query)

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개

services.py

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,
        # 전체 질문 개수
    }

filters.py

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(QuerySet)

  • 아직 DB에 실행되지 않은 질문 목록 설계도(QuerySet)

    • Django에서 QuerySet은
      • 실제 데이터 아님 / 리스트 아님 / DB에 보낼 SQL 쿼리를 Python 객체로 표현한 것
      • 즉, "이런 조건으로 questions 테이블에서 가져와라" 라는 요청서를 만들어 두는 단계
qs = (
    Question.objects
    .select_related(...)
    .annotate(...)
    .order_by(...)
)

  • Question.objects → 질문 테이블을 조회하겠다는 선언
  • .select_related("author", "category")
    • 질문 + 작성자 + 카테고리를 JOIN 해서 한 번에 가져오겠다
  • .annotate(answer_count=Count("answers", distinct=True),)
    • 각 질문마다 → answer_count라는 가짜 컬럼을 붙이겠다
    • DB에 컬럼 생기는 거 아님 / 조회 결과에만 존재

  • 아래 예시 두개 다 QuerySet

    • QuerySet은 계속 “체이닝”하면서 완성해나가는 것
- 1
qs = Question.objects.all()

- 2
qs = (
    Question.objects
    .filter(...)
    .annotate(...)
)

나눠서 쓰는 이유

  • QuerySet은 불변(immutable)에 가까움
    • filter()는 기존 qs를 바꾸지 않음 → 새 QuerySet을 반환
      • 그래서 qs = qs.filter(...) 이런 형태가 됨
qs = Question.objects.all()

qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)

진짜 DB를 조회 시기

# 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 실행

내 코드 기반 해설

  • 질문 조회 시, answers 관계가 존재하는지를 기준으로
    • SELECT 결과에 is_answered라는 Boolean 값을 임시로 붙인다
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의 차이

    항목answer_countis_answered
    타입IntegerBoolean
    의미몇 개 있는지하나라도 있는지
    SQLCOUNT()CASE WHEN
    용도숫자 표시탭 필터 / 상태

  • .annotate를 붙인 가짜 컬럼이 Serializer에 없으면 그냥 “무시되고 응답에 안 나온다”

  • 반대로 시리얼라이즈에는 있는데 qs에 없는경우

    • 에러 발생 / qs에는 있고 시리얼라이즈에는 없는경우는 에러 발생 X

      상황결과
      qs에 있음 + serializer에 있음✅ 정상 출력
      qs에 있음 + serializer에 없음❌ 무시
      qs에 없음 + serializer에 있음💥 에러
  • 즉, Serializer는 “출력하려는 값이 qs(QuerySet)에 존재해야만” 정상적으로 동작한다.

  • Serializer에 정의된 필드 값은 모델 필드이거나, annotate로 qs에 붙어 있거나,

    • Serializer가 직접 계산할 수 있어야 한다.

흐름

  • 1. 어떤 데이터를, 어떤 조건으로, 어떤 형태로 가져올지 정의한 QuerySet

qs = (
    Question.objects
    .select_related("author", "category")
    .annotate(...)
    .order_by("-created_at")
)
    1. qs는 계속 바뀌는 게 아니라 “누적”된다
qs = filter_by_answered(qs, answered)
qs = filter_by_category(qs, category)
qs = filter_by_search(qs, search)

if not qs.exists(): # 여기서 첫번 째 실행
  • 기본 질문 목록에

    • 답변 여부 조건을 붙이고 → 카테고리 조건을 붙이고 → 검색 조건을 붙여라”
    1. 진짜 데이터 가져오는 곳
    • page_obj = paginator.get_page(page)
      • LIMIT / OFFSET 이 적용된 SQL 실행 | 실제 질문 row들이 메모리로 로딩됨
        • return page_obj.object_list → 실질적으로 조회된 데이터
  1. Question.objects ... ← QuerySet 생성 (아직 안 읽음)
  2. filterby* 적용 ← 조건만 추가
  3. qs.exists() ← DB 조회 1회 (존재 여부)
  4. Paginator.get_page() ← DB 조회 1회 (실제 데이터)
  5. page_obj.object_list 반환 ← 조회 결과

오늘의 최종 흐름

[프론트]
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")
)

# 

조회수 / 답변수는 어디서?

  • 등록할 때 입력한 적 없음
    • 그렇다면 카드에 나오는 이유가 뭘까?
      • qs의 answer_count = Count("answers")이 답변 테이블을 세어서 계산
      • Question 테이블에 저장된 값 ❌ / Answer 테이블에 있는 row 개수 ⭕ / 조회 시점에 계산
      • 이것을 “파생 데이터”라고 부름
  • 조회수 (view_count)는 어디서?

    class Question(models.Model):
        view_count = models.BigIntegerField(default=0)
    • 질문 등록시에 입력하지 않았음(입력칸 자체가 없었음)
      • 언제 늘어나는가? → “질문 상세 조회 API”에서 증가시킴
    # 보통 이런코드 존재
    question.view_count += 1
    question.save(update_fields=["view_count"])
  • “등록 API”와 “조회 API”의 역할 차이

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         |

카드 형태로 표현 가능한 이유

  • Service에서 카드에 필요한 모든 정보가 이미 준비됨
    • view의 QuestionListSerializer(questions, many=True).data로 마무리

쿼리스트링이 필터로 들어가는 이유

  • ?뒤에 오는 값이 쿼리스트링 값인데 ?뒤에 오는 값중에 QuerySerializer에 정의된 것만 필터로 넘어간다
    • 그 외의 값은 아예 무시되거나, 에러로 차단된다.
URL (?answered=true&category=3&foo=123)
   ↓
request.query_params (모든 쿼리 문자열)
   ↓
QuerySerializer(data=request.query_params)
   ↓
.is_valid()
   ↓
validated_data (정제된 값만)
   ↓
Service 함수 인자
   ↓
filter 함수

  • request.query_params

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)
  • Service로 넘어갈 때

    • .get() → 없으면 None
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"],
)
  • 쿼리시리얼라이저가 없을 경우

    • 기술적으로는 필터를 구현 가능하지만 설계적으로는 안좋음 / DB조회도 가능하기는 함
    • 단점

      • 타입이 전부 문자열이다 → 경우의 수 폭발
      • 유효성 검증이 없다 → ?page=-999도 통과 → 방어 로직이 뷰에 퍼짐
      • View가 더러워진다 → View가 로직 + 검증 + 변환 다 함 + 테스트 지옥
    • 즉, QuerySerializer는 필터를 “안전하게, 예측 가능하게” 만드는 것
항목보장
타입bool / int / str
기본값page=1, page_size=10
허용 범위min / max
없는 값None 처리
이상한 값400으로 차단

2025.12.17


qs 점검

# 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",
        ]

  • 정리

    • ORM → Service → Serializer → Response
      • Question.objects를 기준으로 Question 모델의 필드를 조회하고
      • select_related로 FK로 연결된 다른 모델의 해당 row를 JOIN해서 미리 로딩하고
      • annotate로 카드 UI에 필요한 가짜 컬럼들을 조회 결과에 추가한 뒤
      • 최종적으로는 Serializer에 선언된 필드들만 Response에 포함되어 출력된다.

코드 리뷰

  • 썸네일 1장을 “질문마다” 붙이는 Subquery

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")

    • 바깥 쿼리(=Question 목록 쿼리)의 현재 row의 pk를 의미
      • “지금 처리 중인 Question의 pk”를 QuestionImage 쿼리 안에서 참조
      • “현재 질문(바깥 QuerySet)의 pk를 가진 질문에 연결된 이미지들만 골라라”
  • created_at

    • 오름차순이면 → 가장 먼저 등록된 이미지 1장
    • “가장 최신 이미지”를 썸네일 → -created_at
  • .values("img_url")[:1]

    • values("img_url") : 이미지 row 전체가 아니라 img_url 컬럼만 뽑아오겠다
      • [:1] : 그 중 딱 1개만 (LIMIT 1)
    • “현재 질문에 달린 이미지들 중, 정렬 기준으로 1개 img_url만 가져와라”
  • Subquery(...)로 “가짜 컬럼”을 질문에 붙인다

    • qs의 각 Question 객체에는 DB에 없는 속성이 생김
      • question.thumbnail_image_url

  • 페이지네이션 처리

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,
    }
  • Paginator(qs, page_size)

    • Django의 기본 페이지네이터 / qs를 page_size 단위로 나눌 준비를 한다.
    • page_size=10이면 / 1페이지: 1~10 / 2페이지: 11~20
  • page_obj = paginator.get_page(page)

    • page가 정상이면 해당 페이지를 줌 / page가 이상해도(문자열/범위초과) 에러 대신 안전하게 처리
    • 너무 큰 페이지면 마지막 페이지로 / 잘못된 값이면 1페이지로 (이게 page()와의 차이점 중 하나)
  • page_obj.object_list (응답 데이터 만들기)

    • 현재 페이지에 해당하는 Question 목록(QuerySet/리스트 성격)
    • serializer에 넣을 “results”가 되는 애들
  • 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(*) ... 같은 쿼리를 한 번 날려서 전체 개수를 알아냄

  • 바깥 쿼리(outer query)

    • 지금 DB가 한 줄씩 만들고 있는 대상 = Question 한 row = 바깥 쿼리의 현재 row
qs = (
    Question.objects		# 바깥 쿼리(outer query) ⭐️
    .select_related("author", "category")
    .annotate(
        thumbnail_image_url=Subquery(thumbnail_subquery)
    )
)
  • 안쪽 쿼리(subquery)

# QuestionImage 테이블을 조회하는 쿼리
thumbnail_subquery = (
    QuestionImage.objects
    .filter(question=OuterRef("pk"))
    ...
)
  • 구조

[바깥] Question  ────────────────▶ 한 row씩 처리
        │
        └── [안쪽] QuestionImage ─▶ 해당 Question의 이미지 1
  • OuterRef("pk")

    • 안쪽쿼리에서 바깥 쿼리의 현재 row의 pk 값을 참조
      • 기본설정일 경우 obj.id 도 가능은 하지만 obj.pk 이렇게 하면 항상 동작 함(안전)
바깥 쿼리: 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개를 찾아서 붙임
  • 정리

    • 바깥쿼리의 각 row(Question 1개)를 기준으로 서브쿼리가 “해당 Question의 pk를 조건으로”
      • 실행되어 그 결과값(img_url 1개)을 컬럼 값으로 붙인다.
  • 조회 시 DB는 Question을 기준으로 한 row씩 처리하면서,

    • 각 row의 pk를 조건으로 사용하는 서브쿼리를 통해 해당 Question에 연결된 이미지 중
      • 하나의 img_url을 계산하고, 그 값을 thumbnail_image_url이라는 가짜 컬럼으로 붙인다.
    • Question | id = 1 → 이미지 있음 / id = 5 → 이미지 없음
    • QuestionImage | question_id = 1 → row 존재 / question_id = 5 → row 없음

중첩 serializer

  • 역할

    • 범위 제한: 원하는 모델에서 필요한 필드만 사용 가능
    • 출력 구조 통제: 프론트의 요구 형태로 고정
    • 보안: user모델의 경우 email같은 개인정보 차단
    • 재사용: 다른 목록/상세에서 재사용 가능
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)
  • QuestionAuthorSerializer

    • 이미 로딩된 User 객체를 “표현만 바꾸는 역할
    • Question.author(User 객체)를 “카드 UI에 필요한 최소 정보 형태로 변환하는 역할"
      • User 모델 전체를 다루는 게 아님 / 저장(write) 로직 없음 / 출력 전용 + 필드 일부만 사용
        • 때문에 ModelSerializer가 아닌 serializers.Serializer 사용
      • author = QuestionAuthorSerializer()
        • Question.author 필드를 그대로 쓰지 않고
        • “User 객체를 어떻게 JSON으로 바꿀지”를 별도의 규칙으로 정의
class QuestionAuthorSerializer(serializers.Serializer):
    nickname = serializers.CharField()
    profile_image_url = serializers.CharField(allow_null=True)

# 입력 대상
obj.author → User 모델 인스턴스

# 출력 결과
{
  "nickname": "졸린개발자",
  "profile_image_url": null
}
  • 중첩 serializer 미사용

    • author = serializers.PrimaryKeyRelatedField(read_only=True)
# 결과 → 카드 UI에 필요 없는 정보까지 전부 노출됨 / 보안 & 설계적으로 안좋음
            "author": 3	 |	author = UserSerializer()	|	"author": {
                                                            "id": 3,
                                                            "email": "...",
                                                            "phone_number": "...",
                                                            "role": "...",
                                                                 ...
                                                            }

SerializerMethodField()

# 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 객체
  • SerializerMethodField
    • 직렬화(serialization) 시점에 → get_<필드명> 메서드를 → 객체 하나당 한 번씩 자동 호출
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마다 호출
  • build_category_path

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"
}

카테고리 개선

  • 현재 구조
    • 질문 10개가 모두 같은 카테고리를 사용한다면 build_category_path(ORM)를 10번 함
    • 같은 계산을 10번하는것은 낭비
  • 개선 방법
    • 카테고리는 ID 기준으로 path가 항상 동일하기 때문에
      • 한 번 계산한 결과를 메모리에 저장해두고 재사용하자(in-memory 캐싱)
        • Serializer 객체 안에 dict 하나 두고 저장
        • 인스턴스 변수에 캐시를 두면 질문 전체에서 공유 가능
  • 시도

    • 캐시 공간 준비 → _category_path_cache = {}
    • { category_id: category_path_dict }를 저장하는 임시 저장소
  • 시작

    • 요청 1회에 대해
      • serializer = QuestionListSerializer(questions, many=True)
        • Serializer 인스턴스 1개 생성 / _category_path_cache 1개 생성
    • __init__에 두어야 하는 이유
      • class QuestionListSerializer 여기에 두면 안됨
        • 요청 A의 캐시가 요청 B에 섞임 / 멀티스레드 환경에서 위험 / 테스트 간 간섭 가능
        • 인스턴스 변수의 장점
          • 요청 단위로 자동 초기화 / thread-safe / side effect 없음
  • 요약
    • “이 캐시는 출력 Serializer 내부에서만 사용하는 요청 단위 in-memory 캐시
      • 동일한 category path 계산의 중복을 제거하기 위해서 사용
    • in-memory 캐싱은 출력 전용 Serializer의 __init__ 에서 인스턴스 변수로 초기화하고,
      • SerializerMethodField 내부에서 재사용하는 방식이 가장 일반적
  • super().__init__(*args, **kwargs)
    • DRF의 Serializer가 내부적으로 해야 할 모든 초기화 작업을 먼저 수행하게 해주는 호출

코드 변화

인증 / 권한 처리

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)

  • GET 요청이 들어왔을 때

- 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 요청이 들어왔을 때

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): → 질문 등록 성공
    ...

Django Paginator

  • Django Paginator가 던지는 예외 2가지
    • PageNotAnInteger → 페이지 번호가 숫자가 아님
      • ?page=abc / ?page=
    • EmptyPage → 존재하지 않는 페이지
      • ?page=999 # 실제 페이지는 5까지만 존재
  • 이걸 하지 않으면 Django가 처리 못 함 / 500 Internal Server Error
    • 사용자 잘못인데 500 서버 에러가 되어 버림
{
  "error_detail": "유효하지 않은 목록 조회 요청입니다.",
  "errors": {
    "page": ["유효하지 않은 페이지입니다."]
  }
}

profile
안녕하세요.

0개의 댓글