
내가 구현해야 할 파트
시작 전 인지해야할 이론
[제목 + 본문(이미지 URL 포함) + 카테고리] 를 서버에 전송 (최종 저장 요청)# presigned_url_view.py
class PresignedUploadAPIView(APIView):
...
def post(self, request: Request) -> Response:
# 1. 파일 확장자 검증 (보안)
ext = original_name.split(".")[-1].lower()
if ext not in self.ALLOWED_EXTENSIONS:
return Response(...) # 에러 처리
# 2. 파일명 난수화 (충돌 방지)
new_filename = f"{uuid.uuid4()}.{ext}"
key = f"{path_prefix}{new_filename}"
# 3. AWS S3 SDK를 이용해 임시 업로드 URL 생성
s3_client = S3Client()
presigned_url = s3_client.generate_presigned_url(key=key)
# 4. 프론트엔드에 '업로드할 주소(presigned)'와 '보여줄 주소(img_url)' 반환
return Response(
{"presigned_url": presigned_url, "img_url": full_url, "key": key}, ...
)
# question_api.py
def post(self, request: Request) -> Response:
# 1. 데이터 유효성 검증
serializer = QuestionCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 2. 검증된 데이터와 유저 정보를 Service Layer로 전달 (역할 분리)
question = create_question(
author=cast(User, request.user),
category=serializer.validated_data["category"],
validated_data=serializer.validated_data,
)
return Response(..., status=status.HTTP_201_CREATED)
# question_create_service.py
def create_question(*, author: User, category: QuestionCategory, validated_data: dict[str, Any]) -> Question:
# 1. 질문(Question) 본체 DB 저장
question = Question.objects.create(
author=author,
title=validated_data["title"],
content=validated_data["content"],
category=category,
)
# 2. 본문 내 이미지 처리 로직 호출 (이미지 서비스 위임)
sync_question_images(question, validated_data["content"])
return question
# question_image_service.py
def sync_question_images(question: Question, content: str) -> None:
# 1. 본문 파싱: 정규표현식 등을 이용해 이미지 URL 추출
raw_urls_in_content = set(extract_image_urls_from_content(content))
# 2. 유효성 검사: 우리 버킷의 S3 URL인지 확인하고 Key만 추출
current_keys_in_content = set()
for url in raw_urls_in_content:
if is_valid_s3_url(url):
key = extract_key_from_url(url)
if key:
current_keys_in_content.add(key)
# 3. Create 시점에는 '추가'만 존재 (기존 이미지가 없으므로)
new_images = []
for key in current_keys_in_content:
new_images.append(QuestionImage(question=question, img_url=key))
# 4. Bulk Insert로 성능 최적화 (한 번의 쿼리로 여러 이미지 매핑 저장)
if new_images:
QuestionImage.objects.bulk_create(new_images)
# question_api.py
def get(self, request: Request) -> Response:
# 1. Query Params 검증 (엄격한 타입 체크)
query_serializer = QuestionListQuerySerializer(data=request.query_params)
query_serializer.is_valid(raise_exception=True)
# 2. Service Layer 호출 (검증된 데이터만 전달)
queryset = get_question_list(**query_serializer.validated_data)
# 3. 페이지네이션 (대량 데이터 분할)
paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)
# ... 응답 반환
# question_list/service.py
def get_question_list(*, answer_status=None, category_id=None, search_keyword=None, sort="latest") -> QuerySet[Question]:
# 1. N+1 문제 해결 (Eager Loading)
# 작성자(author)와 카테고리(category) 정보를 조인(Join)하여 한 번에 가져옴
base_qs = Question.objects.select_related("author", "category").annotate(
answer_count=Count("answers", distinct=True)
)
...
# 2. 썸네일 최적화 (Subquery 활용)
# 질문에 포함된 이미지 중 '가장 먼저 생성된 1장'만 서브쿼리로 가져옴
return qs.annotate(
thumbnail_image_url=Subquery(
QuestionImage.objects.filter(question=OuterRef("pk"))
.order_by("created_at", "id")
.values("img_url")[:1]
),
)
# question_api.py
# DRF Pagination 적용
paginator = QuestionPageNumberPagination()
page = paginator.paginate_queryset(queryset, request)
# Serializer를 통해 결과 변환
serializer = QuestionListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
# question_list.py
class QuestionListSerializer(serializers.ModelSerializer[Question]):
# ...
def get_content_preview(self, obj: Question) -> str:
# 1. 본문 데이터 가져오기
content = obj.content or ""
# 2. HTML 태그 제거 (strip_tags) 및 특수문자 복원
# 예: "<p>안녕하세요</p>" -> "안녕하세요"
text = strip_tags(content)
text = html.unescape(text)
# 3. 불필요한 공백 제거 및 100자 커팅
text = re.sub(r"\s+", " ", text).strip()
return text[:100]
| 항목 | 보장 |
|---|---|
| 타입 | bool / int / str |
| 기본값 | page=1, page_size=10 |
| 허용 범위 | min / max |
| 없는 값 | None 처리 |
| 이상한 값 | 400으로 차단 |
# selectors.py
def get_question_detail_queryset(question_id: int) -> Question | None:
return (
Question.objects.select_related("author", "category") # 1:1 관계 (Join)
.prefetch_related(
"images", # 1:N 관계 (역참조)
Prefetch(
"answers", # 1:N 관계 (답변)
queryset=(
Answer.objects.select_related("author")
.prefetch_related("comments__author") # 대댓글 작성자까지
.order_by("-created_at") # ★ 핵심: 답변을 최신순으로 정렬해서 가져옴
),
),
)
.filter(id=question_id)
.first()
)
update_fields=['view_count']를 명시하여 오직 조회수 컬럼만 수정하는 # question_detail/service.py
def get_question_detail(*, question_id: int) -> Question:
question = get_question_detail_queryset(question_id)
if question is None:
raise QuestionNotFoundError()
# 메모리 상에서만 증가
question.view_count += 1
# update_fields를 사용하여 'view_count' 컬럼만 딱 집어서 업데이트
question.save(update_fields=["view_count"])
return question
# question_detail.py
# 답변(Answer) 시리얼라이저 (댓글 포함)
class AnswerSerializer(serializers.ModelSerializer[Answer]):
author = AuthorSerializer(read_only=True)
comments = AnswerCommentSerializer(many=True, read_only=True) # 중첩
# ...
# 질문 상세(Question Detail) 시리얼라이저 (답변 포함)
class QuestionDetailSerializer(serializers.ModelSerializer[Question]):
# ...
images = QuestionImageSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True, read_only=True) # 중첩
class Meta:
model = Question
fields = [
"id", "title", "content", "category",
"images", # 질문의 이미지들
"answers", # 질문에 달린 답변들 (댓글 포함)
...
]
# question_detail.py
def patch(self, request: Request, question_id: int) -> Response:
# 1. 대상 객체 조회 (존재 여부 확인)
question = get_question_for_update(question_id=question_id)
# 2. 권한 체크 (작성자 본인인가?)
self.check_object_permissions(request, question)
# 3. 데이터 검증 (Partial Update 허용)
serializer = QuestionUpdateSerializer(
instance=question,
data=request.data,
partial=True, # 일부 필드만 수정 가능
)
serializer.is_valid(raise_exception=True)
# 4. 서비스 레이어로 위임
question = update_question(
question=question,
validated_data=serializer.validated_data,
)
...
# question_update/service.py
@transaction.atomic # 함수 전체를 하나의 트랜잭션으로 묶음
def update_question(*, question: Question, validated_data: dict[str, Any]) -> Question:
update_fields = []
new_content = validated_data.get("content")
# 1. 변경된 필드만 감지하여 업데이트 (Dirty Checking 방식 흉내)
for field in ("title", "content", "category"):
if field in validated_data:
setattr(question, field, validated_data[field])
update_fields.append(field)
# 2. 실제 변경사항이 있을 때만 DB 저장
if update_fields:
question.save(update_fields=update_fields)
# 3. 본문(Content)이 변경되었다면 이미지 동기화 로직 수행
if new_content is not None:
sync_question_images(question, new_content)
return question
# question_image_service.py
def sync_question_images(question: Question, content: str) -> None:
# 1. 파싱: 현재 본문에 남아있는 이미지 URL 추출
current_keys_in_content = set(...)
# 2. DB 조회: 기존에 저장되어 있던 이미지 URL 추출
existing_keys = set(...)
# 3. 집합 연산(Set Operation)으로 차분 계산
keys_to_delete = existing_keys - current_keys_in_content # 삭제할 것
keys_to_add = current_keys_in_content - existing_keys # 추가할 것
# 4. 삭제 로직 (S3 삭제 지연 처리)
if keys_to_delete:
# DB에서는 즉시 삭제 (트랜잭션 안이라 롤백 가능)
existing_images_qs.filter(img_url__in=keys_to_delete).delete()
# S3 파일 삭제는 'DB 트랜잭션이 성공적으로 커밋된 후'에 실행
def delete_s3_files() -> None:
for key in keys_to_delete:
s3_client.delete(key)
transaction.on_commit(delete_s3_files) # Hook 등록
# 5. 추가 로직
if keys_to_add:
QuestionImage.objects.bulk_create(...)
self.check_object_permissions(request, question)get_permissions 메서드에서 PATCH 요청일 경우 QuestionUpdatePermission을 사용하도록 설정check_object_permissions를 # permissions.py
class QuestionUpdatePermission(BasePermission):
...
def has_object_permission(self, request: Request, view: APIView, obj: Question) -> bool:
user = request.user
if obj.author_id != user.id:
raise PermissionDenied(detail=EMS.E403_OWNER_ONLY_EDIT("질문")["error_detail"])
return True
# views.py
class QuestionDetailAPIView(APIView):
...
def get_permissions(self) -> list[BasePermission]:
if self.request.method == "PATCH":
return [QuestionUpdatePermission()] # 이 클래스가 검사 대상이 됩니다.
return []
...
def patch(self, request: Request, question_id: int) -> Response:
self.validation_error_message = EMS.E400_INVALID_REQUEST("질문 수정")["error_detail"]
question = get_question_for_update(question_id=question_id) # 존재 확인
self.check_object_permissions(request, question) # 작성자와 사용자가 같은 사람인가 확인
...
어드민
class QuestionCategory(TimeStampedModel):
CATEGORY_TYPES = (
("large", "대분류"),
("medium", "중분류"),
("small", "소분류"),
)
name = models.CharField(max_length=50, verbose_name="카테고리 이름")
type = models.CharField(max_length=10, choices=CATEGORY_TYPES, default="large", verbose_name="카테고리 종류")
parent = models.ForeignKey(
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children", verbose_name="부모 카테고리"
)
...
def clean(self) -> None:
"""계층 구조 유효성 검사"""
if self.type == "large" and self.parent is not None:
raise ValidationError({"parent": "대분류는 부모 카테고리를 가질 수 없습니다."})
if self.type == "medium":
if self.parent is None or self.parent.type != "large":
raise ValidationError({"parent": "중분류의 부모는 [대분류]여야 합니다."})
if self.type == "small":
if self.parent is None or self.parent.type != "medium":
raise ValidationError({"parent": "소분류의 부모는 [중분류]여야 합니다."})
def get_queryset(self, request: HttpRequest) -> QuerySet[QuestionCategory]:
queryset = super().get_queryset(request)
# 부모(One)는 Join으로, 자식(Many)은 Prefetch로 한 번에 가져옴
return queryset.select_related("parent").prefetch_related("children")
def delete_view(
self, request: HttpRequest, object_id: str, extra_context: Optional[dict[str, Any]] = None
) -> HttpResponse:
obj = self.get_object(request, object_id)
extra_context = extra_context or {}
if obj:
warning_msg = ""
base_msg = " 해당 카테고리의 질의응답은 '일반질문'으로 자동 전환되며, 삭제된 카테고리는 복구할 수 없습니다."
if obj.type == "large":
warning_msg = f"⚠️ [대분류 삭제 경고] 하위 '중분류' 및 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
elif obj.type == "medium":
warning_msg = f"⚠️ [중분류 삭제 경고] 하위 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
else: # small
warning_msg = f"⚠️ [소분류 삭제 경고]{base_msg}"
extra_context["title"] = warning_msg
return super().delete_view(request, object_id, extra_context=extra_context)
def get_user_display_info(user: Any) -> SafeString:
"""
[답변 목록 표시용] 유저 Role에 따라 (썸네일 + 닉네임 + 과정/직함) 정보를 HTML로 반환
"""
if not user:
return mark_safe("-")
...
role = getattr(user, "role", "U")
...
info_text = ""
# [A] 수강생 (ST) & 조교 (TA) -> 과정 및 기수 정보 필요
if role in ["ST", "TA"]:
target_obj = None
try:
if role == "ST":
# 수강생: 현재 진행 중인 수강 정보 가져오기
target_obj = getattr(user, "in_progress_cohortstudent", None)
elif role == "TA":
# 조교: 담당하고 있는 기수 정보 가져오기
if hasattr(user, "trainingassistant_set"):
target_obj = user.trainingassistant_set.select_related("cohort__course").first()
except Exception:
target_obj = None
# 기수/과정명 파싱
course_name = ""
generation = ""
if target_obj and hasattr(target_obj, "cohort"):
cohort = target_obj.cohort
course_name = getattr(cohort.course, "name", "")
if hasattr(cohort, "number"):
generation = f"{cohort.number}기"
course_info = f"{course_name} {generation}".strip()
if role == "ST":
info_text = course_info
else:
# 과정 정보가 없으면 그냥 "조교"만 출력
info_text = f"{course_info} 조교".strip() if course_info else "조교"
else:
if role == "LC":
info_text = "러닝 코치"
elif role == "OM":
info_text = "교육 운영 매니저"
elif role == "AD":
info_text = "관리자"
else:
info_text = "일반 회원"
# 만약 정보가 비어있다면 시스템 기본 역할명으로 대체
if not info_text:
info_text = user.get_role_display()
@admin.display(description="카테고리 경로")
def get_category_hierarchy(self, obj: Question) -> str:
"""대분류 > 중분류 > 소분류 형태로 표시"""
category: Optional[QuestionCategory] = obj.category
path: List[str] = []
# 현재 카테고리부터 부모를 타고 올라가며 경로 수집
current = category
while current:
path.append(current.name)
current = current.parent
# [소, 중, 대] -> [대, 중, 소] 순서로 뒤집고 화살표로 연결
full_path = " > ".join(reversed(path))
return full_path
@admin.display(description="답변 여부", ordering="answers_count")
def get_is_answered(self, obj: Question) -> str:
"""
답변 개수(answers_count)를 기반으로 Y/N 표시
"""
has_answer = getattr(obj, "answers_count", 0) > 0
if has_answer:
# 초록색 Y 뱃지
return format_html(
'<span style="color: white; background-color: #28a745; padding: 4px 8px; border-radius: 50%;">Y</span>'
)
else:
# 회색 N 뱃지
return format_html(
'<span style="color: white; background-color: #dc3545; padding: 4px 8px; border-radius: 50%;">N</span>'
)
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
"""
답변 개수를 미리 계산(annotate)
"""
queryset = super().get_queryset(request)
return queryset.select_related("author", "category", "category__parent", "category__parent__parent").annotate(
answers_count=Count("answers")
)
class AnswerInline(_AnswerInlineBase):
model = Answer
extra = 0
verbose_name = "등록된 답변"
verbose_name_plural = "답변 목록"
readonly_fields = ("get_answerer_info", "created_at", "updated_at")
fieldsets = ((None, {"fields": ("get_answerer_info", "content", "is_adopted", "created_at", "updated_at")}),)
@admin.display(description="답변 작성자")
def get_answerer_info(self, obj: Answer) -> SafeString:
return get_user_display_info(obj.author)
@admin.register(Question)
class QuestionAdmin(_QuestionBaseAdmin):
...
inlines = [AnswerInline]
```
------------------------------- [ 처음 작성 방식 ] -------------------------------
raise serializers.ValidationError({"type": "title_conflict"})
# view에서
error_type = e.detail.get("type")
if error_type == "title_conflict":
...
------------------------------- [ DRF가 기대하는 구조 ] -------------------------------
ValidationError({
"field_name": ["error message"]
})
ValidationError({
"non_field_errors": ["error message"]
})
------------------------------ [ ValidationError 구조로 변경 ] ------------------------------
# 기존
raise serializers.ValidationError({"type": "title_conflict"})
# 변경
raise serializers.ValidationError({
"title": ["중복된 질문 제목이 이미 존재합니다."]
})
## 의미
title 필드와 관련된 에러 / View는 타입 몰라도 됨 / DRF 표준 에러 응답 가능
----------------------------- [ ValidationError 구조로 변경 2 ] -----------------------------
# 기존
raise serializers.ValidationError({"type": "category_not_found"})
# 변경
raise serializers.ValidationError({
"category": ["선택한 카테고리를 찾을 수 없습니다."]
})
--------------------------------- [ handler 적용 ] ---------------------------------
except serializers.ValidationError as e:
if "title" in e.detail:
return Response(
{"error_detail": e.detail["title"][0]},
status=status.HTTP_409_CONFLICT,
)
if "category" in e.detail:
return Response(
{"error_detail": e.detail["category"][0]},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"error_detail": "유효하지 않은 질문 등록 요청입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
# 이전
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가 얇아짐 / 인터페이스 안정성 증가
# 변경 전
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)
# 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()))}
self.url = "/api/v1/qna/questions"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")
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. 필드별 에러 정보 완전히 소실
## 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 유지
## 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
- 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 의미가 항상 동일 / 빠른 개발이 최우선
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."
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": "어떤 한 문장"
지금의 전제
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."]
}
}