raise serializers.ValidationError({"title": ["..."]}){"title": ["..."]}"error_detail": "중복된 질문 제목이 이미 존재합니다.""title": ["중복된 질문 제목이 이미 존재합니다."]class QuestionDomainError(Exception):
"""질문 도메인 공통 예외"""
pass
class DuplicateQuestionTitleError(QuestionDomainError):
"""중복된 질문 제목"""
pass
class CategoryNotFoundError(QuestionDomainError):
"""존재하지 않는 카테고리"""
pass
class DuplicateQuestionTitleError(QuestionDomainError):
"""중복된 질문 제목"""
pass
raise Exception("중복된 질문 제목") / raise ValueError("duplicate title")
# 존재하지 않는 경우 위의 코드를 사용하면 View 에서는
except Exception as e:
if "중복" in str(e):
...
# 존재하는 경우 위의 코드를 사용하면
except DuplicateQuestionTitleError:
...
def custom_exception_handler(exc, context):
if isinstance(exc, DuplicateQuestionTitleError):
return Response(
{"title": ["중복된 질문 제목이 이미 존재합니다."]},
status=409,
)
# 각각에서 HTTP와 전혀 상관없는 맥락에서도 의미가 살아 있음
try:
create_question(...)
except DuplicateQuestionTitleError:
logger.warning("중복 질문 생성 시도")
# 의미: 이 예외는 ‘질문 도메인’에서 발생한 것이다
class QuestionDomainError(Exception):
pass
# 질문 도메인 에러 중에서도 ‘제목 중복’이라는 사건이다
class DuplicateQuestionTitleError(QuestionDomainError):
pass
raise DuplicateQuestionTitleError() 이 한줄만 봐도 except DuplicateQuestionTitleError:# 이게 없을 경우 문자열 의존 / 실수에 취약 / 의미 불명확
except Exception as e:
if "title" in str(e):
...
# 이전 service
if Question.objects.filter(title=title).exists():
raise serializers.ValidationError({
"title": ["중복된 질문 제목이 이미 존재합니다."]
})
try:
category = QuestionCategory.objects.get(id=category_id)
except QuestionCategory.DoesNotExist:
raise serializers.ValidationError({
"category": ["선택한 카테고리를 찾을 수 없습니다."]
})
# 변화
# 제목 중복 검사 (도메인 규칙)
if Question.objects.filter(title=title).exists():
raise DuplicateQuestionTitleError()
# 카테고리 존재 여부 검사 (도메인 규칙)
try:
category = QuestionCategory.objects.get(id=category_id)
except QuestionCategory.DoesNotExist:
raise CategoryNotFoundError()
# 이전 views.py
except serializers.ValidationError as e:
detail = e.detail
# 제목 중복 → 409
if "title" in detail:
return Response(
{"error_detail": detail["title"][0]},
status=status.HTTP_409_CONFLICT,
)
# 카테고리 없음 → 404
if "category" in detail:
return Response(
{"error_detail": detail["category"][0]},
status=status.HTTP_404_NOT_FOUND,
)
# 그 외 도메인 / 요청 오류
return Response(
{"error_detail": "유효하지 않은 질문 등록 요청입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED,
)
# 변화
except DuplicateQuestionTitleError:
# ValidationError 구조 유지
raise ValidationError({
"title": ["중복된 질문 제목이 이미 존재합니다."]
})
except CategoryNotFoundError:
raise ValidationError({
"category": ["선택한 카테고리를 찾을 수 없습니다."]
})
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED,
)
View는 HTTP 책임만
Service는 비즈니스 규칙만
Serializer는 입력 검증만
Permission은 역할 검증만
Domain Error → View에서 HTTP Error 변환
{
"400": {
"error_detail": "유효하지 않은 질문 등록 요청입니다."
},
"401": {
"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."
},
"403": {
"error_detail": "질문 등록 권한이 없습니다."
},
"404": {
"error_detail": "선택한 카테고리를 찾을 수 없습니다."
},
"409": {
"error_detail": "중복된 질문 제목이 이미 존재합니다."
}
}
class DuplicateQuestionTitleError(QuestionDomainError):
pass
except DuplicateQuestionTitleError:
raise ValidationError({
"title": ["중복된 질문 제목이 이미 존재합니다."]
})
# 결과: 발생 예외: ValidationError / DRF 판단: 400 Bad Request
전역 ValidationError를 그대로 변경하면 다른 기능에 영향을 주기 때문에,
QnA 질문 등록 API 요청에 대해서만 예외 메시지를 요구사항에 맞게 변환했습니다.
요청 →
1️⃣ Authentication (인증)
2️⃣ Permission (권한)
3️⃣ Serializer validation
4️⃣ View 로직
5️⃣ Service (도메인 규칙)
# 401/403 → 1~2 단계에서 결정
# 400/404/409 → 3~5단계에서 결정
permission_classes = [IsAuthenticated, QuestionCreatePermission]permission_classes = [IsAuthenticated, QuestionCreatePermission]class QuestionCreatePermission(BasePermission):
message = "질문 등록 권한이 없습니다."
def has_permission(self, request, view) -> bool:
return request.user.role == RoleChoices.ST
# 결과
403 Forbidden
{
"detail": "질문 등록 권한이 없습니다."
}
if isinstance(exc, NotAuthenticated):
response.data = {
"error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."
}
permission_classes = [] 또는 AllowAny # answered=true → 답변이 1개 이상 존재하는 질문
/api/v1/qna/questions?answered=true
# answered=false → 답변이 0개인 질문
/api/v1/qna/questions?answered=false
Question.objects.annotate(
answer_count=Count("answers")
).filter(answer_count__gt=0)
/api/v1/qna/questions?category_id=15
or
?main_category=1&sub_category=3
or
Question.category -> QuestionCategory (self FK)
?keyword=django
Question.objects.filter(
Q(title__icontains=keyword) |
Q(content__icontains=keyword)
)
# 최신순
.order_by("-created_at")
# 페이지네이션
{
"count": 120,
"next": "...",
"previous": null,
"results": [...]
}
"category_path": "프론트엔드 > 웹 > Django".annotate(answer_count=Count("answers"))