oz_externship - 14~ 16 코드 작업

김기훈·2025년 12월 20일

부트캠프 프로젝트

목록 보기
26/39

[2025.12.20] 질문 수정 api SpecAPI 작성

[2025.12.22] 질문 수정 PR 리뷰로 인한 전체적으로 갈아엎기

[2025.12.23] 질문 수정 PR 리뷰로 인한 대대적인 충돌 해결


SpecAPI

Spec Serializer (요청 / 응답)

from rest_framework import serializers


class QuestionUpdateRequestSpecSerializer(serializers.Serializer):
    title = serializers.CharField()
    content = serializers.CharField()
    category_id = serializers.IntegerField()
    image_urls = serializers.ListField(
        child=serializers.URLField(),
        required=False,
    )


class QuestionUpdateResponseSpecSerializer(serializers.Serializer):
    question_id = serializers.IntegerField()
    updated_at = serializers.DateTimeField()

Spec API View (Mock)

from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
    extend_schema,
    OpenApiParameter,
)
from rest_framework import status
from rest_framework.exceptions import ValidationError
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_update.serializers import (
    QuestionUpdateRequestSpecSerializer,
    QuestionUpdateResponseSpecSerializer,
)


class QuestionUpdateSpecAPIView(APIView):
    """
    질문 수정 API (Spec)
    - 실제 DB 반영 없이 mock 응답만 반환
    """

    authentication_classes = []  # Spec API는 인증 처리 X
    permission_classes = []

    @extend_schema(
        tags=["Questions"],
        summary="질문 수정 API (Spec)",
        description="질문 ID를 기준으로 질문을 수정합니다. (Mock API)",
        parameters=[
            OpenApiParameter(
                name="question_id",
                type=OpenApiTypes.INT,
                location=OpenApiParameter.PATH,
                description="수정할 질문 ID",
                required=True,
            ),
        ],
        request=QuestionUpdateRequestSpecSerializer,
        responses={
            200: QuestionUpdateResponseSpecSerializer,
            400: OpenApiTypes.OBJECT,
            401: OpenApiTypes.OBJECT,
            403: OpenApiTypes.OBJECT,
            404: OpenApiTypes.OBJECT,
        },
    )
    def put(self, request: Request, question_id: int) -> Response:
        # 400 mock
        if question_id <= 0:
            raise ValidationError("유효하지 않은 질문 수정 요청입니다.")

        serializer = QuestionUpdateRequestSpecSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # mock success response
        return Response(
            {
                "question_id": question_id,
                "updated_at": timezone.now(),
            },
            status=status.HTTP_200_OK,
        )

테스트코드

permission

class QuestionUpdatePermissionTests(TestCase):

    def setUp(self) -> None:
        self.permission = QuestionUpdatePermission()

        self.student = User.objects.create_user(
            email="student@test.com",
            password="test1234",
            name="수강생",
            role=RoleChoices.ST,
            phone_number="010-0000-0000",
            gender="M",
            birthday="2000-01-01",
        )

        self.admin = User.objects.create_user(
            email="admin@test.com",
            password="test1234",
            name="관리자",
            role=RoleChoices.AD,
            phone_number="010-1111-1111",
            gender="M",
            birthday="2000-01-01",
        )

    def test_permission_unauthenticated(self):
        request = type("Request", (), {"user": AnonymousUser()})()

        with self.assertRaises(QuestionUpdateNotAuthenticated):
            self.permission.has_permission(request, None)

    def test_permission_student_allowed(self):
        request = type("Request", (), {"user": self.student})()
        self.assertTrue(self.permission.has_permission(request, None))

    def test_permission_not_student(self):
        request = type("Request", (), {"user": self.admin})()
        self.assertFalse(self.permission.has_permission(request, None))

  • 테스트 목적

    • has_permission의 반환값과 예외만 단위 테스트로 검증

    • QuestionUpdatePermission 클래스 하나만
      • “올바른 True / False / Exception을 반환하는지” 검증하는 테스트
  • APITestCase 안쓰는 이유

    • Permission은 HTTP 테스트가 아니라 로직 테스트이기 때문에 TestCase 사용
  • setUp()

    • self.permission = QuestionUpdatePermission() → 테스트 대상 객체
    • self.student = User.objects.create_user(...) → 허용 대상
    • self.admin = User.objects.create_user(...) → 거부 대상
  • test 시작

    • test_permission_unauthenticated
      • 가짜 Request 객체

        • request = type("Request", (), {"user": AnonymousUser()})()
        • 목적: type()으로 즉석 클래스를 만들고,.user 속성만 가진 객체 생성
# 비로그인 사용자
# 검증: 로그인이 안 된 상태에서 has_permission() 호출 시 401 예외가 발생해야 한다

def test_permission_unauthenticated(self):
    request = type("Request", (), {"user": AnonymousUser()})()

    with self.assertRaises(QuestionUpdateNotAuthenticated):
        self.permission.has_permission(request, None)
# 수강생(ST)
# 로그인 O / 역할 = ST / True 반환

def test_permission_student_allowed(self):
    request = type("Request", (), {"user": self.student})()
    self.assertTrue(self.permission.has_permission(request, None))
    
# 수강생이 아닌 사용자
# 로그인 O / 역할 = AD / False 반환 (403)

def test_permission_not_student(self):
    request = type("Request", (), {"user": self.admin})()
    self.assertFalse(self.permission.has_permission(request, None))

2025.12.22


페이지네이션 진행 과정

# QuestionAPIView.get
queryset = get_question_list(**query_serializer.validated_data)

paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)

serializer = QuestionListSerializer(page, many=True)

return paginator.get_paginated_response(serializer.data)
  • get_question_list() → QuerySet 반환

  • paginate_queryset() → 빈 QuerySet이어도 예외 없음

  • serializer(page, many=True) → page == [] 이면 []

  • get_paginated_response([]) → 정상 응답

  • 필터가 전부 적용됐는데 “결과가 0건”일 때

    • 어느 단계에서도 예외 안 남
    • 그냥 QuerySet(count=0) 상태로 내려옴
base_qs = Question.objects.select_related(...).annotate(...)
qs = filter_by_answered(base_qs, answered)
qs = filter_by_category(qs, category_id)
qs = filter_by_search(qs, search_keyword)
qs = filter_by_sort(qs, sort)
  • Pagination이 “빈 QuerySet”을 어떻게 처리하는가

    • PageNumberPagination.paginate_queryset
      • 내부적으로 Django Paginator 사용
      • queryset 길이가 0이면
        • ❌ EmptyPage 예외 ❌ / ❌ PageNotAnInteger ❌
        • 빈 리스트 반환
  • 최종 응답 형태

    • get_paginated_response([]) 결과는 항상 이 형태
{
  "count": 0,
  "next": null,
  "previous": null,
  "results": []
}

PageNumberPagination

  • 빈 QuerySet을 안전하게 처리하는 역할은 전부
    • DRF 내장 PageNumberPagination이 자동으로 해주는 기능
  • 내가 따로 “빈 리스트 처리 코드”를 쓴 적은 없어도 알아서 처리해줌
역할담당 주체
빈 QuerySet 처리PageNumberPagination
count 계산PageNumberPagination
next / previous 계산PageNumberPagination
results 배열 생성PageNumberPagination
View❌ 관여 안 함
Serializer❌ 관여 안 함
  • 실제 내부 동작 흐름 (중요)


path converter


2025.12.23


profile
안녕하세요.

0개의 댓글