oz_externship - new learning

김기훈·2025년 12월 19일

부트캠프 프로젝트

목록 보기
23/39

EMS 사용법

EMS(ErrorMessages)

  • “에러 응답 메시지를 통일하기 위한 메시지 팩토리”

EMS의 두 가지 사용 방식

  • ① 고정 메시지 (Final) → 변수 없는 에러 메시지

# EMS.E401_LOGIN_REQUIRED → {"error_detail": "로그인이 필요합니다."}

# 사용 예시
from rest_framework.response import Response
from rest_framework import status

return Response(
    EMS.E401_LOGIN_REQUIRED,
    status=status.HTTP_401_UNAUTHORIZED,
)
  • ② 동적 메시지 (Callable / lambda) → 상황에 따라 메시지가 바뀌는 경우

# EMS.E404_NOT_FOUND("질문") → {"error_detail": "질문을(를) 찾을 수 없습니다."}

# 사용 예시
return Response(
    EMS.E404_NOT_FOUND("질문"),
    status=status.HTTP_404_NOT_FOUND,
)

EMS 사용 위치

  • View(API)에서 직접 Response 반환할 때

from rest_framework import status
from rest_framework.response import Response
from apps.core.exceptions import EMS

def get(self, request):
    if not request.user.is_authenticated:
        return Response(
            EMS.E401_LOGIN_REQUIRED,
            status=status.HTTP_401_UNAUTHORIZED,
        )
  • Domain Exception 정의할 때 ⭐ (가장 권장)

    • service / selector에서 raise
    • custom_exception_handler와 잘 맞음
from rest_framework.exceptions import APIException
from apps.core.exceptions import EMS

class QuestionNotFoundError(APIException):
    status_code = 404
    default_detail = EMS.E404_NOT_FOUND("질문")["error_detail"]
  • ValidationError에서 사용

from rest_framework.exceptions import ValidationError
from apps.core.exceptions import EMS

raise ValidationError(EMS.E400_INVALID_REQUEST("질문 상세 조회"))

# 이 경우 custom_exception_handler가 이렇게 변환
{
  "error_detail": "유효하지 않은 질문 상세 조회 요청입니다.",
  "errors": {...}
}

EMS를 쓰면 안 되는 곳 ❌

  • Serializer field error
    • serializer는 필드 단위 에러가 표준 / EMS는 “API 응답용” 메시지
class Serializer(serializers.Serializer):
    title = serializers.CharField(error_messages=EMS.E400_REQUIRED_FIELD)
  • Service 내부에서 Response 반환
    • service는 raise Exception만
def service():
    return Response(EMS.E403_PERMISSION_DENIED("조회"))

테스트코드의 경우

  • Service 테스트에서는 메시지 비교 자체를 하지 않는 게 정답
    • 메시지는 View 책임 / Service는 “예외 발생 여부”만 검증
  • Permission 테스트에서는 request.user만 필요


New Learning 🔴

with

  • “컨텍스트 매니저”를 사용하는 문법

    • 어떤 구간에 들어갈 때 / 어떤 구간을 벗어날 때 자동으로 특정 동작을 해주게 만드는 구조
with self.assertRaises(SomeException):
    실행할_코드()

“이 코드 블록 안에서 SomeException이 반드시 발생해야 한다”

*

def create_question(
    *,
    author: User,
    title: str,
    content: str,
    category: QuestionCategory,
    image_urls: List[str],
) -> Question:
  • * 뒤에 오는 모든 파라미터는 위치 인자(positional)로 받을 수 없고
    • 반드시 키워드 인자(keyword)로만 전달해야 한다
-----------------------------------
def f(a, b, c):
    ...

f(1, 2, 3)          # OK (순서 중요)
f(a=1, b=2, c=3)   # OK
-----------------------------------
def f(*, a, b, c):
    ...

f(a=1, b=2, c=3)   # OK
f(1, 2, 3)         # ❌ 에러
-----------------------------------

  • *는 “순서대로 넣어라”가 아니라 “순서 쓰지 마라, 이름으로만 넣어라” 라는 뜻
상황의미
* 있음순서 무시 / 키워드 필수
* 없음순서 가능 / 키워드도 가능
  • 실수로 인자 순서 바꿀 위험 ❌
    • 리뷰할 때 “뭐가 들어가는지” 즉시 파악 가능 ✅
    • 팀 코드에서 사고 방지용 안전벨트 같은 존재

pre-signed

  • 이미지 업로드는 pre-signed 사용
    • 이미지를 API 서버(Django)로 직접 보내지 않고
    • AWS S3(스토리지)에 바로 업로드하도록 “임시로 허가된 URL”을 발급해주는 방식
      • 백엔드 서버는 이미지를 직접 저장하지 않음
      • 클라이언트(프론트)가 S3로 바로 업로드함
      • 백엔드는 “업로드 허가가 된 URL”만 발급해줌 → 그걸 프론트가 사용
  • 장점

    • 백엔드 서버 부하가 줄어듬 / 업로드 속도가 빠름 / 보안문제 해결 가능 / 서버는 url만 관리
      • 프론트 → S3로 바로 보내면 트래픽 비용과 CPU 리소스를 아낄 수 있음
      • 프론트가 S3에 직접 업로드하므로 중간 단계(백엔드)를 생략
      • S3 버킷을 전체 공개하면 위험함 → pre-signed URL은 몇 분만 유효한 임시 URL이라 안전
      • 이미지 실제 파일은 S3에 있고 Django DB에는 이미지 URL만 저장하면 됨

실무 팁

  • 서비스 메서드는 @staticmethod 또는 @classmethod를 활용해 독립적으로 호출 가능하게 구성
  • 복잡한 로직은 서비스 내부에서 메서드 분리로 가독성을 높이고, 작은 단위의 유닛 테스트가 가능하도록 구성
  • ORM 을 이용하여 데이터를 수정, 삽입, 삭제 시 transaction.atomic으로 데이터 정합성을 관리 가능
  • 예외(Exception)를 명시적으로 raise하고, View에서 핸들링하는 패턴 추천


DRF


serializer

  • serializers.URLField()
    • "http(s)://" 형식인지 검사 | 오타나 잘못된 이미지 경로를 사전에 차단
    • Presigned URL은 항상 완전한 URL 형태
      • 완전한 URL이다 → URLField()도 문제 없이 통과
  • PrimaryKeyRelatedField
    • category는 ForeignKey이기 때문에 검증 필요
      • 해당 ID가 존재하는 카테고리인지
      • 삭제된 카테고리가 아닌지
      • 권한 있는 카테고리인지 (확장 가능)
  • fields
    • “요청에 들어올 수 있는 필드의 범위”를 정하는 것, 그 필드가 ‘필수인지 여부’는 각각의 옵션으로 결정
    • 정의된 필드만 검증하고, 필수 여부는 필드 설정에 따라 다름
      • 요청에 있는 필드가 fields에 포함되어 있으면 → 검증 대상
      • 요청에 fields에 없는 값이 있으면 → 무시 / 에러(ValidationError)
        • 기본적으로는 무시되지 않고 에러
  • class Meta:
    • ModelSerializer의 설정 영역, 어떤 모델을 기준으로 할지, 어떤 필드를 응답에 포함할지 정의
    • model = Question

      • 이 Serializer의 “기본 모델”은 Question
      • id, title, content, view_count, created_at 같은 필드는
        • Question 모델에서 직접 가져온다는 뜻

  • 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 저장이 목적일 때

class QuestionImageCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]
  • QuestionImage 모델을 직렬화/역직렬화하기 위한 Serializer 선언
    • 질문 이미지 목록을 응답에 포함시키기 위해 사용
  • class Meta → ModelSerializer 에서 어떤 모델을 기준으로 할지, 어떤 필드를 쓸지 설정
    • 응답에서 보여줄 필드는 img_url 하나 / id, created_at 같은 것들은 필요 없어서 제외

모델에 없는 필드도 시리얼라이저에서 “추가 선언” 하여

fields 리스트에 넣기 가능 ❗️


class QuestionCreateSerializer(serializers.ModelSerializer):
    category = serializers.PrimaryKeyRelatedField(
        queryset=QuestionCategory.objects.all()
    )

    image_urls = serializers.ListField(
        child=serializers.URLField(),
        write_only=True,
        required=False,
    )

    images = QuestionImageCreateSerializer(many=True, read_only=True)
  • category

    • Question 모델의 category는 FK이므로 숫자(ID)로 들어와야 함
    • PrimaryKeyRelatedField(category는 ForeignKey이기 때문에 검증 필요)
      • DRF가 자동으로
          1. 숫자인지 검증
          1. QuestionCategory 테이블에 실제 존재하는 ID인지 검증을 수행
      • queryset= 을 넣어줘야 FK 검증이 가능
  • image_urls

    • 이미지 URL 문자열들의 리스트를 받기 위한 필드
    • presigned URL 을 받아서 저장할 때 사용 예정
    • child=serializers.URLField() → 리스트 내 각 요소는 URL 형식이어야 함
    • write_only=True → 요청 시에만 받고 응답에서는 제외
    • required=False → 이미지가 없어도 질문 등록 가능
  • images

    • DB에 저장된 QuestionImage 목록을 응답에 표시하기 위한 필드
    • read_only=True → 직접 입력받지 않음
    • related_name="images" 로 연결된 FK 데이터를 자동으로 가져옴

# serializers.py
class QuestionImage(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="images")
  • related_name="images" 때문에 Question.images 로 해당 Question의 이미지 목록을 조회 가능
  • images = ...QuestionSerializerimages 라는 응답 필드를 추가하는 것
  • QuestionImageCreateSerializer → 각 이미지 요소를 이 Serializer로 변환해서 JSON 형태로 보여줌
    • many=True → 여러 개의 이미지가 연결될 수 있으므로 리스트 형태 직렬화.
    • read_only=True → 클라이언트가 요청(body)에서 images를 보내면 안 됨.
      • 이미지 등록은 image_urls 로만 받고, 저장은 create()에서 직접 수행

Serializer.save() 호출 시 실행되는 로직

DB 저장 책임

def create(self, validated_data):
        request = self.context["request"]

        image_urls = validated_data.pop("image_urls", [])

        question = Question.objects.create(
            author=request.user,
            **validated_data
        )

        for url in image_urls:
            QuestionImage.objects.create(
                question=question,
                img_url=url
            )

        return question
  • request

    • 요청 보낸 사용자(request.user)를 가져오기 위함
    • view에서 context={"request": request} 로 전달했기 때문에 사용 가능
  • image_urls

    • Question 생성에는 필요 없으므로 validated_data에서 제거(pop)
    • 나중에 QuestionImage 생성할 때 따로 사용
  • question

    • Question 레코드 생성
    • author는 요청 유저로 자동 설정
    • 제목, 내용, category는 validated_data에서 자동 주입
  • for url in image_urls

    • 전달된 각 이미지 URL마다 QuestionImage 레코드 생성
    • QuestionImage가 Question에 연결되도록 FK 저장

        if serializer.is_valid():
            question = serializer.save()
            return Response(
                {
                    "message": "질문이 성공적으로 등록되었습니다.",
                    "question_id": question.id,
                },
                status=status.HTTP_201_CREATED
            )

        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
  • if serializer.is_valid():

    • 입력값 검증 / title, content, category FK 유효성 검증 수행
  • question = serializer.save()

    • 실제 DB에 저장 / serializer.create() 내부 로직 실행됨
    • .save() 호출 시 → Question 생성 / image_urls 리스트에서 QuestionImage들 생성
  • return Response

    • question.id → auto_increment된 실제 DB ID

test


class QuestionCreateAPITests(APITestCase):
    def setUp(self):
        # 테스트용 카테고리 생성
        self.category = QuestionCategory.objects.create(name="프론트엔드")

        # API URL
        self.url = "/api/v1/qna/questions"
  • 이름이 test_로 시작해야 Django가 테스트로 인식

  • QuestionCreateAPITests

    • 이 클래스 안에 있는 def test_XXX 매서드들이 각각 테스트 1개
  • APITestCase 상속

    • Django의 TestCase + DRF의 API 기능이 더해진 버전
    • 테스트마다 DB를 롤백/초기화해줘서 서로 테스트 사이에 영향이 없음
  • def setUp(self):

    • 각 테스트 메서드가 실행되기 전에 항상 먼저 실행되는 준비 코드
    • test_XXX 하나 실행 → 그 전에 setUp() 한 번 실행
  • self.category = QuestionCategory.objects.create(...)

    • 테스트에서 쓸 카테고리 데이터를 미리 하나 만들어둠
    • 나중에 category 필드에 self.category.id 를 넣어서 질문을 등록할 때 사용
  • self.url = "/api/v1/qna/questions"

    • self.client.post(self.url, ...) 에서 사용

    def test_question_create_success(self):
        # 학생 유저 생성
        student = User.objects.create_user(
            email="student@test.com",
            password="test1234",
            name="학생A",
            role=RoleChoices.ST,
        )

        # 인증 설정
        self.client.force_authenticate(user=student)

        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)
  • self.client
    • APITestCase에서 제공하는 테스트용 HTTP 클라이언트
  • force_authenticate(user=student)
    • DRF의 인증 과정을 생략하고, “이 요청은 이미 이 유저로 인증된 상태다” 라고 강제 설정
  • payload
    • request.data 로 들어가는 값
      • self.category가 아니라 self.category.id 를 보내야하는 이유
      • API 요청에서 ForeignKey는 "객체"가 아니라 "숫자 ID"로 받아야 함
  • response = self.client.post(self.url, payload, format="json")
    • format="json" → DRF가 내부에서 JSON으로 인코딩해서 보내도록 전달
  • self.assertEqual(a, b) → a == b 여야 테스트 통과. 아니면 실패(F).
    • response.status_code 가 201 Created 인지 확인
  • self.assertIn("question_id", response.data)
    • 응답 body에 "question_id" 라는 key가 반드시 있어야 함
    • 실제 api의 views.py 조건
  • response
    • DRF의 Response 객체
    • response.status_code, response.data로 응답 확인 가능

view


class QuestionCreateAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request: Request) -> Response:
        serializer = QuestionCreateSerializer(
            data=request.data,
            context={"request": request},
        )

        if serializer.is_valid():
            question = serializer.save()
            return Response(
                {
                    "message": "질문이 성공적으로 등록되었습니다.",
                    "question_id": question.id,
                },
                status=status.HTTP_201_CREATED,
            )
  • permission_classes = [IsAuthenticated] → 로그인한 사용자만 접근 가능
  • serializer = QuestionCreateSerializer
    • context={"request": request}
      • serializer는 기본적으로 request 객체 접근 불가
      • 하지만 context로 넘겨주면 serializer 내부에서 self.context["request"] 로 접근 가능
      • 그래서 serializer에서 request.user 사용이 가능함
  • if serializer.is_valid():
    • serializer의 validate()가 실행
  • question = serializer.save()
    • serializer.create()

개인적


  • 한개의 url에는 한개의 view를 연결해야 한다

# 오류 예시
urlpatterns = [
    path("questions", QuestionListAPIView.as_view()),   # GET
    path("questions", QuestionCreateAPIView.as_view()), # POST
]

  • 핸들러 작성시 400만 detail구조 통일이 복잡한 이유

    • DRF에서 400만 detail 구조가 일관되지 않기 때문
    • 401 / 403 / 404 는 대부분 APIException 계열이기 때문에 항상 같은 구조로 내려옴
      • response.data == {"detail": "로그인하지 않았습니다."}
    • 400 (ValidationError)은 입력검증 에러이기 때문에 에러 형태가 상황마다 다름

# 필드 하나 누락 = 문자열
raise ValidationError("잘못된 요청") → "detail": "잘못된 요청" 

# 필드 단위 에러 (serializer 기본) = dict + list
{
  "title": ["This field is required."],
  "content": ["This field may not be blank."]
}
exc.detail == {
    "title": ["This field is required."],
    "content": ["This field may not be blank."]
}

# non_field_errors = dict + list
{
  "non_field_errors": ["중복된 값입니다."]
}

# ListSerializer / bulk validation = list of dict
[
  {"title": ["required"]},
  {"content": ["required"]}
]
    if isinstance(exc, ValidationError):
        detail = exc.detail

        if isinstance(detail, list) and detail: # list
            response.data = {"error_detail": str(detail[0])}
        elif isinstance(detail, dict):	# dict
            response.data = {"error_detail": next(iter(detail.values()))}
        else:	# 나머지 
            response.data = {"error_detail": str(detail)}

        return response

  • view = context.get("view")

    • 현재 예외가 어떤 APIView에서 발생했는지 가져옴 DRF가 자동으로 넣어주는 값
    • isinstance(exc, ValidationError)
      • serializer.is_valid() 등에서 발생한 입력값 검증 오류인가 확인
    • isinstance(view, QuestionCreateAPIView)
      • 그 검증 오류가 질문 등록 API에서 발생했는가 확인
    • 주의 ⚠️

      • 공통 ValidationError 처리보다 위에 위치해야 함
      • 순서가 반대면 절대 실행 안됨
        if (
           isinstance(exc, ValidationError)
           and isinstance(view, QuestionCreateAPIView)
       ):
           response.data = {
               "error_detail": "유효하지 않은 질문 등록 요청입니다."
           }
           return response

**validated_data 대체 ?

category = get_category_or_raise(serializer.validated_data["category"])

question = create_question(
    author=user,
    title=serializer.validated_data["title"],
    content=serializer.validated_data["content"],
    category=category,
    image_urls=serializer.validated_data.get("image_urls", []),
)

1. serializer.validated_data에서 title / content / image_urls 를 하나씩 꺼내서 다시 함수 인자로 나열
2. validated_data가 이미 “질문 생성에 필요한 데이터 묶음”인데 View에서 다시 풀어헤치고 있다는 느낌
3., validated_data로 대체 가능 이라는 말은 
  3-1. 이미 검증된 데이터 묶음이 있으니까 그걸 그대로 service로 넘기면 되지 않은가? 라는 말
profile
안녕하세요.

0개의 댓글