
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.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
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
if isinstance(exc, ValidationError):
response.data = {"error_detail": "유효하지 않은 질문 등록 요청입니다."}
elif isinstance(exc, NotAuthenticated):
response.data = {"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."}
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)
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"],
"로그인한 수강생만 질문을 등록할 수 있습니다.",
)
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)
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, "정상 질문")
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=[],
)
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