2025/12/14 MainProject - 7

김기훈·2025년 12월 13일

TIL

목록 보기
83/194

오늘 학습 내용 ✅

view = context.get("view")
# DRF가 예외를 처리할 때 예외가 발생한 View 인스턴스를 넣어줌

is_question_create_api = isinstance(view, QuestionCreateAPIView)
# view가 QuestionCreateAPIView의 인스턴스면 → True 아니면 → False

if isinstance(response.data, dict) and "detail" in response.data:
	response.data = {"error_detail": response.data["detail"]}
  • view = context.get("view")
    • custom_exception_handler(exc, context)
      • exc : 발생한 예외(ValidationError, NotAuthenticated 등)
      • context: DRF가 “예외가 어디서 났는지” 정보를 담아서 넘겨주는 딕셔너리
    • DRF는 예외 처리할 때 context에 보통 이런 걸 넣어줌
      • "view" : 현재 요청을 처리하던 View 인스턴스
      • "request" : 요청 객체
    • 즉, view = context.get("view") → “예외가 발생한 APIView 객체를 꺼내자”
    • get를 사용하는 이유
      • context에 "view"가 없을 가능성도 있으니
      • 없으면 KeyError 대신 None이 나오게 안전하게 처리하려는 것
  • is_question_create_api
    • isinstance(a, B)는 파이썬 기본 문법
      • a가 B 타입(또는 그 자식 클래스)이면 True, 아니면 False
    • 즉, 지금 예외가 발생한 View가 QuestionCreateAPIView인가 (맞으면 True, 아니면 False)
    • ex.

      • 질문 등록 API에서 예외 발생 → view는 QuestionCreateAPIView() → True
      • 질문 목록 조회 API에서 예외 발생 → view는 QuestionListAPIView() → False
  • if isinstance(response.data, dict) and "detail" in response.data:
    • response = exception_handler(exc, context)
      • DRF 기본 예외 처리 함수가 만들어준 Response 객체
      • DRF가 알아서 status code 설정하고 DRF가 알아서 body 만들고 그 결과물이 response
    • DRF는 많은 예외에서 기본적으로 "detail": "에러 메시지" 이런 형태로 응답을 만듬
      • "error_detail": "에러 메시지" 이 형태로 통일
    • isinstance(response.data, dict) → response.data가 딕셔너리인지 확인
    • "detail" in response.data → 딕셔너리에 "detail" 키가 존재하는지 확인
      • { "detail": "..." } 형태면 True { "title": ["required"] } 형태면 False

등록 == 조회

  • 같은 엔드포인트를 사용하는 이유는?

      1. 리소스 중심 설계 (REST의 핵심) → REST에서는 URL = 리소스(명사) 를 의미
      • /questions → 질문이라는 "리소스 집합"
      • 행위는 Method로 표현
Method의미
GET조회
POST생성
PUT/PATCH수정
DELETE삭제

SpecAPI

serializers.Serializer

  • ModelSerializer가 아닌 serializers.Serializer를 쓴 이유
    • ModelSerializer는 기본적으로 Django 모델(ORM) 인스턴스를 직렬화/역직렬화하는 데 최적화
  • ModelSerializer를 쓰면 생기는 문제/불편

    • 모델 필드 기반으로 자동 매핑되는 특성 때문에, 응답 구조를 모델에 끌려가게 됨
    • answer_count / is_answered / content_preview 같은 건 모델 필드가 아니라
      • 가공/집계 값이라 ModelSerializer에서 오히려 더 억지로 처리
    • Spec API는 “지금 DB 구조가 아직 바뀔 수도 있는 단계”인데,
      • ModelSerializer로 묶어버리면 스펙이 모델에 종속돼서 변경에 취약

확장 전 고민

  • 지금 질문등록 예외처리 메세지를 핸들러로 처리하고 있는데 등록 / 조회 api를 하나의 뷰에서 처리해야 함
  • 즉, apiview의 이름이 똑같아지고 그리하여 조회의 400에서도 등록의 400메세지가 출력됨

401/403

  • View에서 401 처리 → DRF 구조 위반
    • IsAuthenticated 못 쓰는 상황 → 정상
      • Permission에서 401은 raise
    • Permission에서 403은 return False
    • View는 비즈니스 로직만
  • Permission에 들어가면 안 되는 것들

    • DB 생성 / 수정 / 삭제
    • 객체 상태 변경
    • 서비스 호출
    • 트랜잭션
    • 질문 중복 검사
    • 카테고리 유효성 검사
    • 포인트 차감
      • 이건 Service / Domain의 영역
  • Permission에 들어가도 되는 로직

    • 인증 여부 확인
    • 역할(role) 확인
    • 요청자 vs 리소스 소유자 확인
    • request.user / request.method 기반 분기
    • API 성격에 따른 접근 제한
  • Permission에서 raise해도 되는 예외

    • NotAuthenticated
    • PermissionDenied
    • APIException (401 / 403 계열)
  • Permission에서 raise하면 안 되는 예외
    • ValidationError (400)
    • CategoryNotFoundError (404)
    • DuplicateQuestionTitleError (409)
      • 권한 문제가 아니라 요청/도메인 문제 라서 불가
  • 요약
    • 이 코드는 “접근을 막기 위한 판단”인가? / 아니면 “기능을 수행하기 위한 판단”인가?
    • 접근 제어 → Permission / 기능 수행 → Service / Serializer

Permission

  • has_permission()은 bool을 반환하도록 설계됨
    • 하지만 APIException을 raise하는 것도 허용 / DRF 내부에서도 실제로 사용함

ValidationError

  • ValidationError.detail 은 string이 아님
    • list[ErrorDetail] / dict[str, list[ErrorDetail]]

새롭게 알게된 내용 ✅

  • 한개의 url에는 한개의 view를 연결해야 한다
# 오류 예시
urlpatterns = [
    path("questions", QuestionListAPIView.as_view()),   # GET
    path("questions", QuestionCreateAPIView.as_view()), # POST
]
  • fn + Shift + F6

    • 변경하고 싶은 명령어 전체 변경

  • ModelSerializer가 아니라 Serializer를 사용

    • Serializer를 쓰면 모델에 정의되지 않은 필드를 자유롭게 정의해서 사용할 수 있다.

    • ModelSerializer는 모델 필드 중심이지만, 제한적으로만 추가 필드를 쓸 수 있다.

      구분SerializerModelSerializer
      모델 의존성❌ 없음✅ 있음
      모델에 없는 필드✅ 자유롭게 가능⚠️ 가능하지만 제한적
      자동 필드 생성❌ 없음✅ 있음
      save()❌ 직접 구현✅ 자동
      목적요청/응답 스펙 정의모델 CRUD
  • Serializer는 모델과 무관하다

from rest_framework import serializers

class ExampleSerializer(serializers.Serializer):
    title = serializers.CharField()
    page = serializers.IntegerField()
    answered = serializers.BooleanField(required=False)

- 위의 필드들은 모델이 없어도 사용 가능 / DB 저장과 무관 / 요청 파라미터 검증에 최적
- 조회 조건(Query Param), 복합 입력, 계산 전용 필드에 자주 사용
  • ModelSerializer에서도 "모델에 없는 필드"는 가능하기는 함

class QuestionCreateSerializer(serializers.ModelSerializer):
    image_urls = serializers.ListField(
        child=serializers.URLField(),
        write_only=True,
        required=False,
    )

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

- 가능은 함 하지만, image_urls는 DB 필드가 아님, save()에서 직접 처리해야 함
-, 입력 보조용 필드 / 모델 저장을 돕는 용도

Serializer vs ModelSerializer

  • Serializer를 써야 할 때

    • 조회 조건 (filter, search, page)
    • request.query_params 검증
    • 모델에 없는 가상 필드
    • 여러 모델을 섞는 입력
    • “요청 스펙 정의”가 목적일 때
  • ModelSerializer를 써야 할 때

    • 모델 CRUD
    • create / update
    • DB 저장이 목적일 때

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

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

  • 문제: 질문 조회 API의 SpecAPI를 테스트하다가 갑자기 405 뜸
  • 원인
        path("spec/questions", QuestionCreateSpecAPIView.as_view()),
        path("spec/questions", QuestionListSpecAPIView.as_view()),
    • Django가 같은 URL에 서로 다른 View를 두 개 등록했다 로 해석함
    • Django는 “GET이면 이 View, POST면 저 View” 이런 판단은 절대 안 함
      • 그래서 Django는 spec/questions에 대해 마지막에 선언된 View 하나만 연결
      • 그 View에 요청 메소드가 없으면 405 Method Not Allowed
  • 해결
# 실제 API → View가 하나 → Method는 View 내부에서 분기
class QuestionAPIView(APIView):
    def get(self): ...
    def post(self): ...
  • specapi에서도 이렇게 사용하면 같은 url로 처리 가능하지만 specapi는
    • 각 기능마다 요구사항이 다르기 때문에 한 view에 섞으면
    • 가독성 ↓ / Swagger 문서 혼란 / 이후 유지보수 지옥 → Spec API는 URL을 분리하는 게 정석
/spec/questions          # GET  (목록 스펙)
/spec/questions/create   # POST (등록 스펙)

  • 문제2. 질문등록 예외처리 메세지가 조회에서도 동일하게 나옴

    • 원인: 등록하고 조회를 같은 APIView에서 정의해야 하기 때문에
      • isinstance(view, QuestionCreateAPIView)로는 더 이상 구분이 안 됨
    • 해결: 예외처리 메세지의 기준을 View 클래스 이름이나 url이 아닌
      • HTTP Method (request.method) / Exception 타입 으로 한다.
    • 정석 해결책
      • request.method를 기준으로 분기
# 기존
def custom_exception_handler(
    exc: Exception,
    context: dict[str, Any],
) -> Optional[Response]:
    response = exception_handler(exc, context)
    if response is None:
        return None

    view = context.get("view")
    is_question_create_api = isinstance(view, QuestionCreateAPIView)

    # "포맷 통일" detail -> error_detail(메시지 내용은 바꾸지 않음)
    ## 403도 따로 설정하지 않아도 포맷되서 error_detail로 나옴
    if isinstance(response.data, dict) and "detail" in response.data:
        response.data = {"error_detail": response.data["detail"]}

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

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

    # 3) 404: 카테고리 없음
    elif isinstance(exc, CategoryNotFoundError) and is_question_create_api:
        response.data = {"error_detail": str(exc.detail)}

    # 4) 409: 중복제목
    elif isinstance(exc, DuplicateQuestionTitleError) and is_question_create_api:
        response.data = {"error_detail": str(exc.detail)}

    return response

# 변화
def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:
        return None

    request = context.get("request")
    method = request.method if request else None

    # 공통 포맷 통일 -> 403도 알아서 처리 
    if isinstance(response.data, dict) and "detail" in response.data:
        response.data = {"error_detail": response.data["detail"]}

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

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

        elif isinstance(exc, CategoryNotFoundError):
            response.data = {"error_detail": str(exc.detail)}

        elif isinstance(exc, DuplicateQuestionTitleError):
            response.data = {"error_detail": str(exc.detail)}

    # GET /questions (조회)
    elif method == "GET":
        if isinstance(exc, ValidationError):
            response.data = {"error_detail": "유효하지 않은 질문 목록 조회 요청입니다."}

    return response
  • 단점: 다른 api의 GET 기능이랑 메세지 겹칠 수 있음

profile
안녕하세요.

0개의 댓글