2025/12/13 MainProject - 6

김기훈·2025년 12월 13일

TIL

목록 보기
82/194

오늘 학습 내용 ✅

  • 오늘의 코드 작업
  • Serializer 역할

    • 요청 데이터가 “형식적으로 올바른가?”
      • 필수 필드 존재 여부
      • 타입 검증
      • 길이 / URL 형식 등
      • ❌ DB 조회 기반 규칙은 하지 않음
  • Service 역할

    • “이 요청이 도메인 규칙상 허용되는가?”
      • 제목 중복 여부
      • 카테고리 존재 여부
      • 실제 Question / Image 생성
  • 문제인 이유

    1. Service가 DRF(serializers.ValidationError)에 의존하는 게 왜 문제
    • 1-1) create_question() 안에서 DRF 예외를 직접 던지고 있음
    • raise serializers.ValidationError({"title": ["..."]})
      • A. Service가 “웹(API)” 프레임워크에 묶여버림
        • Service는 원래 “도메인 로직(질문을 만든다)”를 담당하는 계층인데,
        • DRF 예외를 쓰는 순간 서비스가 “DRF 환경에서만 돌아가는 코드”가 돼.
        • 즉 “질문 생성 로직”이 “DRF의 ValidationError 표현 방식”에 종속됨
    • 1-2) 도메인 오류 vs 입력 오류가 섞여버림
      • ValidationError는 보통 “입력 데이터가 잘못됨”의 의미로 많이 쓰이는데,
      • 지금 service에서 던지는 건 사실상 이런 도메인 규칙 위반
    • 1-3) 테스트가 이상해짐
      • service 단위 테스트에서 “DRF가 설치/로드되어야” 하고,
      • 예외 타입도 “DRF ValidationError”로 검증해야 함
      • 원래 service 테스트는 프레임워크 없이도 돌 수 있는 게 이상적임
    1. View가 Service 예외의 “내부 구조(detail 키)”를 해석하는 게 왜 문제
    • 현상태: View에서 service가 던진 ValidationError의 detail을 보고
      • "title" 키가 있으면 409, "category" 키가 있으면 404로 바꾸고 있음
    • 2-1) View ↔ Service가 “문자열 키 계약”으로 강결합됨
      • iew는 service가 반드시 이런 형태로 예외를 던진다고 믿어야 함 = {"title": ["..."]}
      • 이건 명시된 계약(인터페이스)가 아니라 “암묵적 규칙”
      • service 코드에서 키 이름을 살짝만 바꿔도 View가 조용히 오동작하거나 전부 400처리
    • 2-2) 원래 HTTP 상태코드 결정은 view(또는 global handler)의 책임인데,
      • 지금은 service가 던진 detail의 구조가 view의 상태코드를 사실상 결정 중

ValidationError 구조로 하라

  • 에러 응답 형태를 ValidationError와 동일하게 맞춰라

    • "error_detail": "중복된 질문 제목이 이미 존재합니다."
    • "title": ["중복된 질문 제목이 이미 존재합니다."]
  • ValidationError 사용 이유

    • DRF가 자동으로 처리해주니까 (편해서)
      • 400 응답 / JSON 직렬화 / 에러 포맷 통일 를 DRF가 다 해줌

exception

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:
    ...
  • 이 코드가 가장 유용한 경우

      1. 글로벌 exception handler로 이동할 때
      • 지금은 View에서 처리하지만, 나중에 이렇게 바뀔수 있음 → View 코드 수정 0줄
def custom_exception_handler(exc, context):
    if isinstance(exc, DuplicateQuestionTitleError):
        return Response(
            {"title": ["중복된 질문 제목이 이미 존재합니다."]},
            status=409,
        )
    1. 같은 Service를 다른 곳에서 쓸 때
    • ex. Admin에서 질문 생성 / AI 자동 질문 생성 / Batch 스크립트 / Celery Task
# 각각에서 HTTP와 전혀 상관없는 맥락에서도 의미가 살아 있음
try:
    create_question(...)
except DuplicateQuestionTitleError:
    logger.warning("중복 질문 생성 시도")
  • 요약

    • 이 클래스들은 ‘지금 기능을 위해서’가 아니라 ‘설계 의도를 코드로 남기기 위해’ 존재한다
    • “도메인 예외는 현재 동작보다는 규칙의 의미를 코드 레벨에서 명확히 드러내고
      • 이후 예외 처리 확장을 안전하게 하기 위한 장치

기능이 없는데 왜 상속을 받나?

  • 보통 상속의 목적은 코드 재사용 / 매서드 재사용 / 공통 로직 공유 이다.
  • 예외 상속은 목적이 다르다

    • 예외 상속의 진짜 목적 = “분류 체계 만들기”
# 의미: 이 예외는 ‘질문 도메인’에서 발생한 것이다
class QuestionDomainError(Exception):
    pass
    
# 질문 도메인 에러 중에서도 ‘제목 중복’이라는 사건이다
class DuplicateQuestionTitleError(QuestionDomainError):
    pass
  • import 해서 사용하는 이유

    • 1. 타입으로 의미를 전달하기 위해서

    • service코드에서 raise DuplicateQuestionTitleError() 이 한줄만 봐도
      • 그냥 Exception 아님 / DRF ValidationError 아님
      • “질문 도메인에서 제목 중복 규칙이 깨졌다”
    • 2. View에서 “의미 단위로 처리”하기 위해서

      • View에서 이걸 사용하는 이유는 기능 때문이 아니라 분기 기준 때문
      • 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
  • 변화

    • exceptions에서 404/409 처리
    • 403은 permissions에서 처리
      • View의 post() 아예 실행 안 함
        • Service 아예 안 탐
        • Serializer 아예 안 탐
        • DRF가 자동으로 403 판단
    • 400은 views.py에서 처리

exceptions.py

전역 ValidationError를 그대로 변경하면 다른 기능에 영향을 주기 때문에,
QnA 질문 등록 API 요청에 대해서만 예외 메시지를 요구사항에 맞게 변환했습니다.


요청흐름

요청 →
1️⃣ Authentication (인증)
2️⃣ Permission (권한)
3️⃣ Serializer validation
4️⃣ View 로직
5️⃣ Service (도메인 규칙)

# 401/403 → 1~2 단계에서 결정
# 400/404/409 → 3~5단계에서 결정

  • 401 Unauthorized (인증 실패)

  • permission_classes = [IsAuthenticated, QuestionCreatePermission]
    • DRF가 가장 먼저 하는 일
    • request.user가 토큰 없음 / 세션 없음 / 인증 정보 없음 → IsAuthenticated 통과 못함
      • 즉시 종료 → View 코드(post)는 아예 실행도 안 됨
      • 조회의 조건이 모든 웹사이트 이용자 이기 때문에 exception_handler를 사용하여 맨트 변경
  • 403 Forbidden (권한 실패)

  • permission_classes = [IsAuthenticated, QuestionCreatePermission]
    • ① IsAuthenticated 통과 → QuestionCreatePermission 실행
class QuestionCreatePermission(BasePermission):
    message = "질문 등록 권한이 없습니다."

    def has_permission(self, request, view) -> bool:
        return request.user.role == RoleChoices.ST

# 결과
403 Forbidden
{
  "detail": "질문 등록 권한이 없습니다."
}

exception_handler

if isinstance(exc, NotAuthenticated):
    response.data = {
        "error_detail": "로그인한 수강생만 질문을 등록할 수 있습니다."
    }
  • 이 코드가 하는 일은 딱 하나
    • ❌ 인증 여부 판단 안 함
    • ❌ 권한 판단 안 함
    • ❌ DB 조회 안 함
    • ❌ 분기 조건 바꿈 안 함
    • ❌ 상태 코드 변경 안 함
      • 이미 결정된 결과를 “보기 좋게” 바꿔주는 것뿐

질문 목록 조회 api 시작

시작 전 고민

  • 여기 적힌 모든 조건을 백엔드 기능으로만 구현이 가능한가?

    1. 접근 권한: 모든 이용자 → 백엔드 100%
    • permission_classes = [] 또는 AllowAny
    1. 답변 작성 여부에 따른 탭 조회 → 백엔드 → 쿼리 파라미터 기반
# 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)
    1. 카테고리별 필터링 → 백엔드
/api/v1/qna/questions?category_id=15
or 
?main_category=1&sub_category=3
or 
Question.category -> QuestionCategory (self FK)
    1. 검색 기능 (제목 / 내용) → 백엔드
    • 기본 검색: icontains / 고급 검색(추후): PostgreSQL Full-Text Search
?keyword=django

Question.objects.filter(
    Q(title__icontains=keyword) |
    Q(content__icontains=keyword)
)
    1. 최신순 정렬 + 페이지네이션 → 백엔드 핵심(100%)
    • 페이지네이션 → DRF PageNumberPagination / LimitOffsetPagination
# 최신순
.order_by("-created_at")

# 페이지네이션
{
  "count": 120,
  "next": "...",
  "previous": null,
  "results": [...]
}
    1. 카드 형태 노출 → 프론트 영역
    • 카드에 필요한 “데이터 구조”를 주는 건 백엔드
    1. 카드에 포함되어야 하는 항목별 가능 여부
    • 질의응답 카테고리 (대 > 중 > 소) → 가능
      • Question → Category → parent → parent
      • "category_path": "프론트엔드 > 웹 > Django"
    • 작성자 정보 → 가능
      • User 모델에서 끌어오기 profile_image_url / nickname
    • 질의응답 제목 → 가능
    • 질문내용 → 가능 하지만 기회/프론트와 협의 필요
      • 보통 전체내용이 나오지는 않고 미리보기 (100~200)
    • 답변수 → 가능 → .annotate(answer_count=Count("answers"))
    • 조회수 → 가능 → view_count
    • 질문 작성일시 → 가능 → created_at
    • 질문 내용에 포함된 이미지 썸네일 → 기술적 가능 / 정책 결정 필요

새롭게 알게된 내용 ✅

어려운 내용(추가 학습 필요) ✅

오늘 발생한 문제(발생 했다면) ✅

profile
안녕하세요.

0개의 댓글