oz_externship - create

김기훈·2025년 12월 13일

부트캠프 프로젝트

목록 보기
19/39

serializers.py

from rest_framework import serializers

from apps.qna.models import Question


class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    category = serializers.IntegerField()

    image_urls = serializers.ListField(
        child=serializers.URLField(),
        write_only=True,
        required=False,
    )

    class Meta:
        model = Question
        fields = [
            "title",
            "content",
            "category",
            "image_urls",
        ]

views.py

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.qna.permissions.question.question_create_permission import (
    QuestionCreatePermission,
)
from apps.qna.serializers.question.question_create import QuestionCreateSerializer
from apps.qna.services.question.question_create_service import create_question
from apps.user.models import User


class QuestionCreateAPIView(APIView):
    permission_classes = [IsAuthenticated, QuestionCreatePermission]

    def post(self, request: Request) -> Response:
        serializer = QuestionCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = request.user
        assert isinstance(user, User)

        question = 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", []),
        )

        return Response(
            {
                "message": "질문이 성공적으로 등록되었습니다.",
                "question_id": question.id,
            },
            status=status.HTTP_201_CREATED,
        )

services.py

from typing import List

from apps.qna.exceptions.question_exceptions import (
    CategoryNotFoundError,
    DuplicateQuestionTitleError,
)
from apps.qna.models import Question, QuestionCategory, QuestionImage
from apps.user.models import User


def create_question(
    *,
    author: User,
    title: str,
    content: str,
    category_id: int,
    image_urls: List[str],
) -> Question:

    # 제목 중복 검사 (도메인 규칙)
    if Question.objects.filter(title=title).exists():
        raise DuplicateQuestionTitleError()

    # 카테고리 존재 여부 검사 (도메인 규칙)
    try:
        category = QuestionCategory.objects.get(id=category_id)
    except QuestionCategory.DoesNotExist:
        raise CategoryNotFoundError()

    # Question 생성
    question = Question.objects.create(
        author=author,
        title=title,
        content=content,
        category=category,
    )

    # 이미지 생성
    for url in image_urls:
        QuestionImage.objects.create(
            question=question,
            img_url=url,
        )

    return question

permissions.py

from django.contrib.auth.models import AnonymousUser
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView

from apps.user.models import RoleChoices


class QuestionCreatePermission(BasePermission):
    message = "질문 등록 권한이 없습니다."

    def has_permission(self, request: Request, view: APIView) -> bool:
        user = request.user

        if isinstance(user, AnonymousUser):
            return False

        # 여기서부터 mypy는 user를 User로 확정
        return user.role == RoleChoices.ST

exceptions.py

from rest_framework import status
from rest_framework.exceptions import APIException


class DuplicateQuestionTitleError(APIException):
    status_code = status.HTTP_409_CONFLICT
    default_detail = "중복된 질문 제목이 이미 존재합니다."


class CategoryNotFoundError(APIException):
    status_code = status.HTTP_404_NOT_FOUND
    default_detail = "선택한 카테고리를 찾을 수 없습니다."

apps/core/exceptions.py

from typing import Any, Optional

from rest_framework.exceptions import NotAuthenticated, ValidationError
from rest_framework.response import Response
from rest_framework.views import exception_handler

from apps.qna.exceptions.question_exceptions import CategoryNotFoundError, DuplicateQuestionTitleError


def custom_exception_handler(
    exc: Exception,
    context: dict[str, Any],
) -> Optional[Response]:
    response = exception_handler(exc, context)
    if response is None:
        return None

    # 400 - serializer validation
    if isinstance(exc, ValidationError):
        response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}

    # 401 - 인증 안 됨
    elif isinstance(exc, NotAuthenticated):
        response.data = {"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."}

    # 404 / 409 - 도메인 예외 - 에러 응답 형식을 통일하기 위하여 사용
    elif isinstance(exc, (CategoryNotFoundError, DuplicateQuestionTitleError)):
        response.data = {"error_detail": exc.detail}

    return response


config/settings/base.py

REST_FRAMEWORK = {
	"EXCEPTION_HANDLER": "apps.qna.exceptions.question_exception_handler.custom_exception_handler",
}

테스트

API

from rest_framework import status
from rest_framework.test import APITestCase

from apps.qna.models import QuestionCategory
from apps.user.models import RoleChoices, User


class QuestionCreateAPITests(APITestCase):
    def setUp(self) -> None:
        self.url = "/api/v1/qna/questions"
        self.category = QuestionCategory.objects.create(name="백엔드")

    def create_user(self, role: RoleChoices) -> User:
        return User.objects.create_user(
            email="apitest@test.com",
            password="test1234",
            name="유저",
            role=role,
            phone_number="010-0000-0000",
            gender="M",
            birthday="2000-01-01",
        )

    #
    def test_question_create_success(self) -> None:
        user = self.create_user(RoleChoices.ST)
        self.client.force_authenticate(user=user)

        payload = {
            "title": "질문 등록",
            "content": "내용입니다",
            "category": self.category.id,
            "image_urls": ["https://test.com/img.png"],
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertIn("question_id", response.data)

    # 401 로그인 체크
    def test_unauthenticated_user_gets_401(self) -> None:
        payload = {
            "title": "질문",
            "content": "내용",
            "category": self.category.id,
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
        self.assertEqual(
            response.data["error_detail"],
            "로그인한 수강생만 질문을 등록할 수 있습니다.",
        )

    # 403 Role가 학생이 아닌 경우
    def test_non_student_user_gets_403(self) -> None:
        user = self.create_user(RoleChoices.USER)
        self.client.force_authenticate(user=user)

        payload = {
            "title": "질문",
            "content": "내용",
            "category": self.category.id,
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    # 400 제목이 없음
    def test_invalid_payload_gets_400(self) -> None:
        user = self.create_user(RoleChoices.ST)
        self.client.force_authenticate(user=user)

        payload = {
            "content": "제목 없음",
            "category": self.category.id,
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(
            response.data["error_detail"],
            "유효하지 않은 질문 등록 요청입니다.",
        )

Permission

from django.test import TestCase
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView

from apps.qna.permissions.question.question_create_permission import (
    QuestionCreatePermission,
)
from apps.user.models import RoleChoices, User


class QuestionCreatePermissionTests(TestCase):
    def setUp(self) -> None:
        self.factory = APIRequestFactory()
        self.permission = QuestionCreatePermission()

    def create_user(self, role: RoleChoices) -> User:
        return User.objects.create_user(
            email="permissiontest@test.com",
            password="test1234",
            name="테스트유저",
            role=role,
            phone_number="010-0000-0000",
            gender="M",
            birthday="2000-01-01",
        )

    # 성공: 학생 권한
    def test_student_has_permission(self) -> None:
        user = self.create_user(RoleChoices.ST)
        request = self.factory.post("/api/v1/qna/questions")
        request.user = user

        view = APIView()
        self.assertTrue(self.permission.has_permission(request, view))

    # 실패: 학생이 아닌 권한
    def test_non_student_has_no_permission(self) -> None:
        user = self.create_user(RoleChoices.USER)
        request = self.factory.post("/api/v1/qna/questions")
        request.user = user

        view = APIView()
        self.assertFalse(self.permission.has_permission(request, view))

Service

from django.test import TestCase

from apps.qna.exceptions.question_exceptions import (
    CategoryNotFoundError,
    DuplicateQuestionTitleError,
)
from apps.qna.models import Question, QuestionCategory, QuestionImage
from apps.qna.services.question.question_create_service import create_question
from apps.user.models import RoleChoices, User


class QuestionCreateServiceTests(TestCase):
    def setUp(self) -> None:
        self.user = 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.category = QuestionCategory.objects.create(name="백엔드")

    # 질문 생성 성공
    def test_create_question_success(self) -> None:
        question = create_question(
            author=self.user,
            title="정상 질문",
            content="질문 내용",
            category_id=self.category.id,
            image_urls=["https://test.com/img1.png"],
        )

        self.assertEqual(Question.objects.count(), 1)
        self.assertEqual(QuestionImage.objects.count(), 1)
        self.assertEqual(question.title, "정상 질문")

    # 409 제목 중복
    def test_duplicate_title_raises_409_error(self) -> None:
        Question.objects.create(
            author=self.user,
            title="중복 질문",
            content="내용",
            category=self.category,
        )

        with self.assertRaises(DuplicateQuestionTitleError):
            create_question(
                author=self.user,
                title="중복 질문",
                content="다른 내용",
                category_id=self.category.id,
                image_urls=[],
            )

    # 404 존재하지 않는 카테고리 ID
    def test_category_not_found_raises_404_error(self) -> None:
        with self.assertRaises(CategoryNotFoundError):
            create_question(
                author=self.user,
                title="카테고리 없음",
                content="내용",
                category_id=9999,
                image_urls=[],
            )

코드 리뷰

질문 등록 API 전체 흐름

  • 1. URL 진입

    • POST /api/v1/qna/questions
    • URL 라우팅은 apps.qna.urls.question_urls 에서 연결됨
  • 2. APIView 진입

    • QuestionCreateAPIView.post() 실행
  • 3. Permission 검사

    • IsAuthenticated → 로그인 여부 (401)
    • QuestionCreatePermission → 학생(ST) 권한 여부 (403)
  • 4. Serializer 검증

    • 필수 필드, 타입 검증
    • 실패 시 ValidationError → 400
  • 5. Service 호출

    • create_question() 에서 도메인 규칙 검증 + DB 처리
  • 6. Exception 처리

    • 도메인 예외 → 404 / 409
    • 공통 예외 → custom_exception_handler 에서 메시지 통일
  • 7. 성공 응답

    • 201 Created + question_id 반환

Serializer

  • fields
    • “요청에 들어올 수 있는 필드의 범위”를 정하는 것, 그 필드가 ‘필수인지 여부’는 각각의 옵션으로 결정
    • 정의된 필드만 검증하고, 필수 여부는 필드 설정에 따라 다름
      • 요청에 있는 필드가 fields에 포함되어 있으면 → 검증 대상
      • 요청에 fields에 없는 값이 있으면 → 무시 / 에러(ValidationError)
        • 기본적으로는 무시되지 않고 에러

전체 흐름

  • 요약

UR`L → View(APIView) → Permission(401/403) → Serializer(400) 
→ Service(404/409/DB 생성) → Response(201)
  • 1. 요청이 어디로 들어오는가?

    • POST /api/v1/qna/questions 여기로 요청이 들어오면
      • 이 요청은 QuestionCreateAPIView.post()로 들어감
  • 2. View 진입: APIView가 “조립”을 담당

    • View에는 DB 로직(중복검사/카테고리조회/이미지생성)이 없음 → Service로 위임
class QuestionCreateAPIView(APIView):
	[1. View 진입 전에 DRF가 permission 체크를 수행(401/403)]
    permission_classes = [IsAuthenticated, QuestionCreatePermission]

    def post(self, request: Request) -> Response:
        serializer = QuestionCreateSerializer(data=request.data)
        
        [2. Serializer로 요청 데이터 검증(400)]
        serializer.is_valid(raise_exception=True)

        user = request.user
        assert isinstance(user, User)
		
        [3. Service 호출]
        question = 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", []),
        )

        return Response(
            {"message": "질문이 성공적으로 등록되었습니다.", "question_id": question.id},
            status=status.HTTP_201_CREATED,
        )
  • 3. Service 단계: 실제 비즈니스 로직/DB 작업

    • 서비스에서 raise DuplicateQuestionTitleError() 하면
      • DRF가 이 예외를 보고 status_code=409로 응답함
def create_question(*, author: User, title: str, content: str, category_id: int, image_urls: List[str]) -> Question:

    if Question.objects.filter(title=title).exists():
        raise DuplicateQuestionTitleError()

    try:
        category = QuestionCategory.objects.get(id=category_id)
    except QuestionCategory.DoesNotExist:
        raise CategoryNotFoundError()

    question = Question.objects.create(
        author=author,
        title=title,
        content=content,
        category=category,
    )

    for url in image_urls:
        QuestionImage.objects.create(question=question, img_url=url)

    return question
profile
안녕하세요.

0개의 댓글