oz_externship - 1~5일차 코드 작업

김기훈·2025년 12월 11일

부트캠프 프로젝트

목록 보기
17/39

요약 ❗️

[2025.12.08] 모델 확정 및 프로젝트 설정 / 기본 서류 작성

[2025.12.09] SpecAPI 학습 및 질문등록 SpecAPI 작성

[2025.12.10 ~ 2025.12.11] 질문 등록 API 구현 및 테스트 코드 작성

[2025.12.12] 질문 답변 각각의 앱을 qna로 통합하여 구조 확정 및 질문등록 API 전체 리셋


2025.12.09

specapi

apps/questions/
  ├── spec/
  │     ├── serializers.py
  │     └── views.py
  ├── serializers.py
  ├── views.py
  ├── urls.py
  • 이 구조의 장점

    • spec 파일은 나중에 지워질 수 있음 → 폴더로 묶여 있어야 함
    • Real API와 Spec API가 절대 섞이지 않는다 → 충돌 위험 Zero
  • url을 분리하지 않은 이유

    • 나중에 Real API로 전환할 때 작업을 최소화하기 위해서
    • 프론트는 실제 API와 동일한 URL을 사용해 미리 개발 가능
    • 실서버 개발 후 URL을 변경할 필요 없음
    • API 명세서와 Swagger가 항상 동일하게 유지됨
    • 스펙 확정 후 Real API만 교체하면 끝

spec/serializers.py

from rest_framework import serializers
from apps.questions.models import Question, QuestionImage

class QuestionImageSpecSerializer(serializers.ModelSerializer):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]

class QuestionCreateSpecSerializer(serializers.ModelSerializer):
    category = serializers.IntegerField() # specapi에서만 잠시 변경

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

    images = QuestionImageSpecSerializer(many=True, read_only=True)

    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",  # 요청
            "images",  # 응답
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
  • class QuestionImageSpecSerializer

    • 질문에 첨부된 이미지 정보를 나타낼 하나의 이미지용 Serializer
    • 실제 QuestionImage 모델을 기반으로 만듦
  • class Meta

    • 이 Serializer가 어떤 모델을 기준으로 하는지 지정: QuestionImage
    • 스펙상 필요한 필드인 img_url만 응답에 포함
  • class QuestionCreateSpecSerializer

    • 질문 등록 요청 + 응답 모두를 담당하는 Spec용 Serializer
    • Question 모델을 기반으로 하되, Spec에 맞게 일부 필드를 오버라이드
    • category = serializers.IntegerField()
      • Question.category는 ForeignKey(QuestionCategory)
      • Spec API에서는 DB를 타지 않게 하기 위해 그냥 정수 ID로 받도록 오버라이드
    • image_urls = serializers.ListField
      • 요청에서만 받는 필드: 내용에 첨부된 이미지 URL 리스트
      • ListField 로 리스트를 받고, 각 원소는 CharField 로 받음 → URL 형식이 아니어도 통과
      • write_only=True → 요청(body)에서만 사용, 응답에는 안 나옴
      • required=False → 안 보내도 유효성 검사를 통과(이미지 없는 질문도 허용)
    • images = QuestionImageSpecSerializer
      • 응답에서 보여줄 이미지들의 목록
      • 실제 DB 조회가 아니라, mock에서 만들어 넣는 리스트를 직렬화할 때 사용
      • many=True → 이미지 여러 개
      • read_only=True → 클라이언트가 직접 입력할 수 없고, 서버에서만
  • class Meta
    • image_urls: 요청에서만 받는 이미지 URL 리스트
    • images: 응답으로 보여줄 이미지 목록 (QuestionImageSpecSerializer)
    • read_only_fields: 서버에서 채우는 값이므로 입력 불가능, 응답 전용

spec/views.py

from datetime import datetime
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema

from .serializers import QuestionCreateSpecSerializer


class QuestionCreateSpecAPIView(APIView):
    permission_classes = [AllowAny]
    serializer_class = QuestionCreateSpecSerializer

    @extend_schema(
        tags=["Questions"],
        summary="질문 등록 API (Spec)",
        description="실제 저장 없이 mock 데이터로 동작하는 질문 등록 Spec API입니다.",
        request=QuestionCreateSpecSerializer,
        responses={
            201: QuestionCreateSpecSerializer,
            400: {
                "object": "object",
                "example": {"error": "Bad Request"}
            },
        }
    )
    def post(self, request: Request) -> Response:

        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        validated = serializer.validated_data

        mock_response = {
            "id": 1,
            "title": validated["title"],
            "content": validated["content"],
            "category": validated["category"],
            "image_urls": validated.get("image_urls", []),
            "images": [{"img_url": url} for url in validated.get("image_urls", [])],
            "created_at": datetime.now().isoformat(),
        }

        response_serializer = self.serializer_class(mock_response)

        return Response(response_serializer.data, status=status.HTTP_201_CREATED)
  • status → HTTP 상태 코드 상수(예: status.HTTP_201_CREATED)

  • AllowAny → 누구나 접근 가능하게 하는 권한 클래스

  • @extend_schema → drf-spectacular에서 Swagger 문서를 꾸밀 때 사용하는 decorator

  • class QuestionCreateSpecAPIView

    • 질문 등록 Spec API를 처리하는 View / 실제 DB 저장 X / mock 응답만 반환하는 목적
    • permission_classes = [AllowAny]
      • 이 API는 로그인 여부와 상관없이 아무나 호출 가능 → Spec/mock용이라 인증 강제할 이유X
      • 401 방지
    • serializer_class = QuestionCreateSpecSerializer
      • 이 View에서 사용할 기본 serializer를 지정
      • 아래에서 self.serializer_class(...)로 재사용할 수 있게 함
  • @extend_schema

    • request=QuestionCreateSpecSerializer
      • 요청 body의 스키마를 이 Serializer 기반으로 생성
    • responses
      • 201: 성공 시 응답 스키마 → QuestionCreateSpecSerializer
      • 400: 실패 예시를 object로 직접 정의(간단한 example 형태)
  • def post(self, request: Request) -> Response:

    • serializer = self.serializer_class(data=request.data)
      • 요청으로 들어온 JSON body를 QuestionCreateSpecSerializer로 감싼다
    • serializer.is_valid(raise_exception=True)
      • 유효성 검사 실행 / 필수 필드/타입이 맞는지 검사하고, 문제가 있으면 바로 400 에러를 raise
    • validated = serializer.validated_data
      • 검증을 통과한 데이터를 validated_data로 받아옴(딕셔너리 형태)
    • mock_response
      • image_urls": validated.get("image_urls", [])
        • 요청에 들어있으면 그대로, 없으면 빈 리스트
      • images": [{"img_url": url} for url in validated.get("image_urls", [])]
        • 응답 스키마에 맞추기 위해 [{"img_url": "..."}, ...] 형태로 변환
        • 실제로는 DB에서 QuestionImage를 불러오는 대신 요청값을 그대로 감싼 것
      • created_at
        • 지금 시각을 ISO 문자열로 만들어서 넣음
        • 나중에 Real API에서는 실제 모델의 created_at 값 사용
    • response_serializer = self.serializer_class(mock_response)
      • Serializer로 직렬화하기 위해
  • return Response(response_serializer.data, status=status.HTTP_201_CREATED)

    • 최종 응답으로 Serializer가 만든 데이터를 JSON으로 리턴
    • HTTP 상태 코드는 201 Created

2025.12.10

serializers.py

  • 요청(request) → title, content, category, image_urls
  • 응답(response) → id, created_at, images(이미지 객체 리스트)
from rest_framework import serializers
from apps.questions.models import Question, QuestionImage, QuestionCategory

class QuestionImageCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]

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)

    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",  # 요청 
            "images",      # 응답 
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]

    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
  • child=serializers.URLField()
    • "http(s)://" 형식인지 검사 | 오타나 잘못된 이미지 경로를 사전에 차단
    • Presigned URL은 항상 완전한 URL 형태
      • 완전한 URL이다 → URLField()도 문제 없이 통과
  • PrimaryKeyRelatedField
    • category는 ForeignKey이기 때문에 검증 필요
      • 해당 ID가 존재하는 카테고리인지
      • 삭제된 카테고리가 아닌지
      • 권한 있는 카테고리인지 (확장 가능)

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)
  • 질문 생성(Create) 요청을 처리하는 Serializer
    • Question 모델을 기반으로 실제 API에서 검증을 하고 DB에 저장하는 역할
  • 모델에는 category FK가 원래 존재
    • 그러나 DRF에게 “이 필드에 무엇을 받아야 하는지” 알려줘야 함
    • 따라서, 시리얼라이저에서 다시 명시적으로 선언
  • 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()에서 직접 수행

    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",  # 요청 전용
            "images",      # 응답 전용
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
  • Serializer가 처리할 필드 목록
  • id, created_at은 자동 생성 → 사용자 입력 불가

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 저장

views.py

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.questions.serializers import QuestionCreateSerializer
from apps.user.models import RoleChoices

class QuestionCreateAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        if request.user.role != RoleChoices.ST:
            return Response(
                {"detail": "수강생만 질문을 등록할 수 있습니다."},
                status=status.HTTP_403_FORBIDDEN,
            )

        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
            )

        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

class QuestionCreateAPIView(APIView):
    permission_classes = [IsAuthenticated]
  • APIView 기반으로 질문 생성 전용 API 정의
  • 로그인한 사용자만 접근 가능
    • 로그인 안 하면 자동으로 401 Unauthorized 응답 반환

# apps/user/models.py
class RoleChoices(models.TextChoices):
    ...
    ST = "ST", "Student"

# apps/questions/views.py
from apps.users.models import RoleChoices

if request.user.role != RoleChoices.ST:
    return Response(
        {"detail": "수강생만 질문을 등록할 수 있습니다."},
        status=status.HTTP_403_FORBIDDEN,
    )
  • POST 요청을 처리하는 메서드
  • if request.user.role != "ST":
    • 문자열 오타 위험 제거 / RoleChoices에서 수정 시 자동 반영

        serializer = QuestionCreateSerializer(
            data=request.data,
            context={"request": request}
        )
  • request.data를 Serializer로 전달
  • context에 request를 넣는 이유 → serializer.create에서 author 설정하기 위함

        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

정리

class QuestionCreateSerializer(serializers.ModelSerializer):
		.
        .
    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",  # 요청
            "images",      # 응답
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
  • serializer.is_valid()

    • Serializer의 Meta.fields에 명시된 필드만 검증
    • 입력으로 받는(write 가능) 필드
      • title | content | category | image_urls
    • read_only_fields = ["id", "created_at"]
      • 유저가 입력하면 안 되고 DRF도 입력값으로 받지 않으며 검증 대상이 아님
    • Serializer는 Meta.fields 안에서 “입력 가능한 필드만” 검증


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
  • question = serializer.save()

    • .save() 호출 → 내부에서 Serializer의 .create() 메서드를 실행
    • 뷰에서 POST 요청을 받았기 때문이 아닌 DRF Serializer 내부 설계 때문
  • create() → POST 요청 시 실행 / update() → PUT/PATCH 요청 시 실행

  • validated_data.pop("image_urls")

    • image_urls 는 Question 모델에 없는 필드이기 때문에 create() 전에 빼야 함
    • pop()하는 이유: image_urls를 Question.objects.create()에 넘기면 에러 발생
    • 그래서 image_urls만 따로 보관해둠.
      • pop
        • Question 모델에는 image_urls라는 필드가 없어서,
        • Question.objects.create(**validated_data)에 보내면 에러남
        • 그래서 image_urls만 먼저 빼고, 나머지만 모델 생성에 사용
        • "image_urls"가 안 들어온 경우(이미지가 없는 경우)에는 기본값으로 빈 리스트 [] 사용
  • question = Question.objects.create(...)

    • title, content, category 등의 필드를 사용해 Question 생성
    • author는 request.user로 설정

최종 결론

  • POST 요청이 들어오면 → View에서 serializer.save()를 호출함
  • Serializer.save() 메서드가 내부적으로 create()를 호출함
  • 그리고 create() 안에 우리가 작성한 Question 생성 로직이 실행됨
  • DRF Serializer.save() 내부 동작 원리

    • instance가 없으면 → create() 호출
    • instance가 있으면 → update() 호출
def save(self, **kwargs):
    if self.instance is None:
        # Create new object
        return self.create(validated_data)
    else:
        # Update existing object
        return self.update(self.instance, validated_data)

TestCode

APITestCase

from rest_framework.test import APITestCase
from rest_framework import status

from apps.user.models import User, RoleChoices
from apps.questions.models import QuestionCategory
  • APITestCase
    • DRF가 제공하는 테스트용 베이스 클래스
    • django.test.TestCase 를 상속받아서 HTTP 요청/응답을 테스트하기 편하게 해줌
  • status
    • 상태코드를 숫자 대신 의미 있는 상수 이름으로 쓰기 위해 사용
    • status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST

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

        # API URL
        self.url = "/api/v1/qna/questions"
  • 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, ...) 에서 사용

이름이 test_로 시작해야 Django가 테스트로 인식


    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로 응답 확인 가능

유효성 실패 케이스

  • 입력값이 잘못되었을 때 400 BAD REQUEST 가 나오는지 검증

    def test_question_create_validation_fail(self):
        student = User.objects.create_user(
            email="student2@test.com",
            password="test1234",
            name="학생B",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        payload = {
            "title": "",  # 빈 값 → 유효성 실패
            "content": "",
            "category": None,  # 존재하지 않는 카테고리
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

권한 실패 케이스

    def test_question_create_permission_fail(self):
        normal_user = User.objects.create_user(
            email="normal@test.com",
            password="test1234",
            name="일반유저",
            role=RoleChoices.USER,  # 학생 아님
        )
        self.client.force_authenticate(user=normal_user)

        payload = {
            "title": "권한 실패 테스트",
            "content": "내용",
            "category": self.category.id,
        }

        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

코드 테스트


2025.12.11


serializers.py

from typing import Any, Dict
from rest_framework import serializers
from apps.questions.models import Question, QuestionCategory, QuestionImage
from apps.user.models import RoleChoices, User

class QuestionImageCreateSerializer(serializers.ModelSerializer[QuestionImage]):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]

class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    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)

    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",  # 요청 전용
            "images",  # 응답 전용
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
        request = self.context["request"]
        assert isinstance(request.user, User)

        # 403 - 수강생 권한
        if request.user.role != RoleChoices.ST:
            raise serializers.ValidationError({"type": "permission_denied"})

        # 409 - 중복 제목
        title = attrs.get("title")
        if title and Question.objects.filter(title=title).exists():
            raise serializers.ValidationError({"type": "title_conflict"})

        # category 존재 여부는 PrimaryKeyRelatedField 가 검증
        return attrs

    def create(self, validated_data: Dict[str, Any]) -> Question:
        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

class QuestionImageCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]
  • 이미지를 생성할 때 사용할 간단한 ModelSerializer
    • QuestionImage 모델은 img_url만 있으면 생성 가능하므로 필드는 하나만 지정

class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    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)
  • image_urls
    • 클라이언트가 이미지 URL 목록을 보내기 위한 필드
      • 즉, “이미지 URL 리스트를 서버에 보낼 때 사용되는 Request 필드”
    • write_only=True → 요청으로만 받고 응답에는 포함되지 않음
  • images
    • Question 이미지들의 리스트를 응답으로 보여주기 위한 필드
    • read_only=True → DB에서 읽는 용도만.

  class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",   # 요청 데이터
            "images",       # 응답 데이터
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
  • 어떤 필드를 serializer에서 사용할지 지정
  • id와 created_at은 자동 생성되므로 읽기 전용

Serializer의 validate()

serializer.is_valid() 호출되면 이 validate()가 실행됨 🔥

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
        request = self.context["request"]
        assert isinstance(request.user, User)
  • request = self.context["request"]
    • Serializer는 원래 request 객체에 접근 불가능함
      • 하지만 View에서 serializer를 만들 때 context={"request": request} 를 넣어주면
      • serializer 안에서 self.context["request"] 로 접근 가능함
    • 즉, Serializer 안에서 request.user 를 사용할 수 있게 해주는 DRF 기능
  • assert isinstance(request.user, User)
    • 타입 안정성을 위해 유저가 User 모델인지 확인

        # 403 - 수강생 권한
        if request.user.role != RoleChoices.ST:
            raise serializers.ValidationError({"type": "permission_denied"})

        # 409 - 중복 제목
        title = attrs.get("title")
        if title and Question.objects.filter(title=title).exists():
            raise serializers.ValidationError({"type": "title_conflict"})

        # category 존재 여부는 PrimaryKeyRelatedField 가 검증
        return attrs
  • if request.user.role != RoleChoices.ST
    • 학생(ST)일 때만 질문 작성 허용
    • 에러를 단순 문자열로 던지는 대신 {"type": "permission_denied"} 형태로 던지면
      • View에서 이 키(type)를 읽고 403 status code를 결정할 수 있다.
  • title = attrs.get("title")
    • attrs
      • Serializer의 검증(validation) 과정에서 유효성 검사를 통과한 입력 데이터(dictionary)

serializer.save() 시 실행 🔥

    def create(self, validated_data: Dict[str, Any]) -> Question:
        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
  • validated_data 는 이미 검증된 안전한 데이터
  • image_urls
    • image_urls는 Question 모델에 없는 필드이므로 pop해서 따로 저장
    • request.user은 게시물 작성자의 author가 된다
  • question → Question 인스턴스 생성
  • for url in image_urls → 이미지가 있다면 반복문으로 QuestionImage 객체 생성
  • return question → View에서 사용

views.py

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.questions.serializers import QuestionCreateSerializer

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,
            )

        # 에러 매핑
        error_type = serializer.errors.get("type")

        if error_type == ["permission_denied"]:
            return Response(
                {"error_detail": "질문 등록 권한이 없습니다."},
                status=status.HTTP_403_FORBIDDEN,
            )

        if error_type == ["title_conflict"]:
            return Response(
                {"error_detail": "중복된 질문 제목이 이미 존재합니다."},
                status=status.HTTP_409_CONFLICT,
            )

        if "category" in serializer.errors:
            return Response(
                {"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            {"error_detail": "유효하지 않은 질문 등록 요청입니다."},
            status=status.HTTP_400_BAD_REQUEST,
        )

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()

“에러 타입”에 따른 분기 처리

        error_type = serializer.errors.get("type")

        if error_type == ["permission_denied"]:
            return Response(
                {"error_detail": "질문 등록 권한이 없습니다."},
                status=status.HTTP_403_FORBIDDEN,
            )

        if error_type == ["title_conflict"]:
            return Response(
                {"error_detail": "중복된 질문 제목이 이미 존재합니다."},
                status=status.HTTP_409_CONFLICT,
            )
  • error_type = serializer.errors.get("type")
    • serializer.validate()에서 발생한 ValidationError가
    • {"type": "permission_denied"} 같은 형태로 들어있음
      • error_type을 읽어서 상황 판단 가능

        if "category" in serializer.errors:
            return Response(
                {"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            {"error_detail": "유효하지 않은 질문 등록 요청입니다."},
            status=status.HTTP_400_BAD_REQUEST,
        )
  • if "category" in serializer.errors
    • category 필드 에러는 PrimaryKeyRelatedField가 자동 반환
  • return Response
    • 나머지는 기본 400 처리

test_question_create.py

from typing import Any

from rest_framework import status
from rest_framework.test import APITestCase

from apps.questions.models import Question, QuestionCategory, QuestionImage
from apps.user.models import RoleChoices, User


class QuestionCreateAPITests(APITestCase):
    def setUp(self) -> None:
        self.category = QuestionCategory.objects.create(name="프론트엔드")
        self.url = "/api/v1/qna/questions"

    def create_test_user(self, **kwargs: Any) -> User:
        default = {
            "phone_number": "010-0000-0000",
            "gender": "M",
            "birthday": "2000-01-12",
        }
        default.update(kwargs)
        return User.objects.create_user(**default)

    # 1) 정상 생성
    def test_question_create_success(self) -> None:
        student = self.create_test_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.assertTrue(QuestionImage.objects.filter(question_id=response.data["question_id"]).exists())

    # 2) 400 - title / content / category 없는 경우
    def test_question_create_validation_fail(self) -> None:
        student = self.create_test_user(
            email="student2@test.com",
            password="test1234",
            name="학생B",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        payload = {"title": "", "content": "", "category": None}
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

    # 3) 403 - 학생(ST) 아님
    def test_question_create_permission_fail(self) -> None:
        user = self.create_test_user(
            email="normal@test.com",
            password="test1234",
            name="일반유저",
            role=RoleChoices.USER,
        )
        self.client.force_authenticate(user=user)

        payload = {"title": "권한 실패", "content": "내용", "category": self.category.id}
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
        self.assertEqual(response.data["error_detail"], "질문 등록 권한이 없습니다.")

    # 4) 409 - 제목 중복
    def test_question_create_title_conflict(self) -> None:
        student = self.create_test_user(
            email="student3@test.com",
            password="test1234",
            name="학생C",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        Question.objects.create(
            author=student,
            category=self.category,
            title="중복 제목",
            content="내용",
        )

        payload = {"title": "중복 제목", "content": "다른", "category": self.category.id}
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
        self.assertEqual(response.data["error_detail"], "중복된 질문 제목이 이미 존재합니다.")

    # 5) 404 - 존재하지 않는 카테고리
    def test_question_create_invalid_category(self) -> None:
        student = self.create_test_user(
            email="student4@test.com",
            password="test1234",
            name="학생D",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        payload = {"title": "카테고리 실패", "content": "내용", "category": 9999}
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(response.data["error_detail"], "선택한 카테고리를 찾을 수 없습니다.")

    # 6) image_urls 없어도 정상
    def test_question_create_without_image_urls(self) -> None:
        student = self.create_test_user(
            email="student5@test.com",
            password="test1234",
            name="학생E",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        payload = {"title": "이미지 없음", "content": "내용", "category": self.category.id}
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

    # 7) image_urls = [] 여도 정상
    def test_question_create_empty_image_urls(self) -> None:
        student = self.create_test_user(
            email="student6@test.com",
            password="test1234",
            name="학생F",
            role=RoleChoices.ST,
        )
        self.client.force_authenticate(user=student)

        payload = {
            "title": "빈 이미지 리스트",
            "content": "내용",
            "category": self.category.id,
            "image_urls": [],
        }
        response = self.client.post(self.url, payload, format="json")

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

class QuestionCreateAPITests(APITestCase):
    def setUp(self) -> None:
        self.category = QuestionCategory.objects.create(name="프론트엔드")
        self.url = "/api/v1/qna/questions"

    def create_test_user(self, **kwargs: Any) -> User:
        default = {
            "phone_number": "010-0000-0000",
            "gender": "M",
            "birthday": "2000-01-12",
        }
        default.update(kwargs)
        return User.objects.create_user(**default)
  • def setUp(self)
    • 각 테스트마다 초기화되는 부분
  • def create_test_user(self, **kwargs: Any)
    • 매번 user 생성 로직 반복하므로 함수로 분리

  • self.client.force_authenticate(user=student)
    • 인증 없이 APIView는 접근 불가 → 강제로 인증 시킴
  • response = self.client.post(self.url, payload, format="json")
    • 실제 API 요청
  • self.assertEqual(response.status_code, status.HTTP_201_CREATED)
    • 성공 코드 검증
  • image_urls가 실제로 DB에 저장되었는지 검증.
    self.assertTrue
    (QuestionImage.objects.filter(question_id=response.data["question_id"]
    ).exists())


주요 기능

image_urls 와 images 역할

  • Question 모델에는 이미지가 직접 저장되지 않고
    • QuestionImage 모델이 Question과 1:N 관계로 이미지 URL을 가지고 있음
    • 따라서 QuestionSerializer에서 이미지 URL만 받아서 QuestionImage 를 생성해야 함

  • 클라이언트 요청 JSON

{
  "title": "질문 제목",
  "content": "내용",
  "category": 1,
  "image_urls": [
    "https://cdn.com/img1.png",
    "https://cdn.com/img2.png"
  ]
}

  • Serializer validation

    • image_urls는 ListField이므로 각 item이 URL 형식인지 검증

  • create() 내부에서 처리

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

question = Question.objects.create(**validated_data)

for url in image_urls:
    QuestionImage.objects.create(question=question, img_url=url)
  • 즉, image_urls는 입력용, DB에는 저장되지 않고 QuestionImage 생성의 재료로만 사용


attrs

  • attrs는 serializer가 기본 필드 검증을 통과시킨 ‘정제된 입력 데이터 딕셔너리’
  • 클라이언트가 요청을 보냄

{
  "title": "좋은 질문",
  "content": "내용입니다",
  "category": 1,
  "image_urls": ["https://cdn.com/a.png"]
}
  • Serializer가 각 필드별 기본 검증을 수행

    • title: CharField 검증
    • content: CharField/TextField 검증
    • category: PrimaryKeyRelatedField → 카테고리 존재 여부 검증
    • image_urls: ListField / URLField 검증
      • "문제 없는" 값만 dictionary 형태로 정리
  • 정리된 데이터를 validate(self, attrs) 로 넘김

    • 기본 검증이 끝난 데이터 모음(dict)
    • validate() 메서드는 이 dict를 추가 검증하거나 변형할 수 있음
{
    "title": "좋은 질문",
    "content": "내용입니다",
    "category": <QuestionCategory: 프론트엔드>,
    "image_urls": ["https://cdn.com/a.png"]
}

2025.12.12

serializers.py

from typing import Any, Dict

from rest_framework import serializers

from apps.qna.models import Question, QuestionCategory, QuestionImage
from apps.qna.permissions.question.question_create_permission import (
    validate_question_create_permission,
    validate_question_title_unique, validate_question_category,
)
from apps.qna.services.question.question_create_service import create_question
from apps.user.models import User


class QuestionImageCreateSerializer(serializers.ModelSerializer[QuestionImage]):
    class Meta:
        model = QuestionImage
        fields = ["img_url"]


class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
    category = serializers.IntegerField()

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

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

    class Meta:
        model = Question
        fields = [
            "id",
            "title",
            "content",
            "category",
            "image_urls",
            "images",
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
        request = self.context["request"]
        user: User = request.user

        validate_question_create_permission(user)

        validate_question_title_unique(attrs.get("title"))

        category_id = attrs.get("category")
        validate_question_category(category_id)

        attrs["category"] = QuestionCategory.objects.get(id=category_id)

        return attrs

    def create(self, validated_data: Dict[str, Any]) -> Question:
        request = self.context["request"]

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

        return create_question(
            author=request.user,
            image_urls=image_urls,
            **validated_data,
        )
  • images 필드

    • 질문 등록 API에서 직접 응답에 사용되지는 않지만, Question이라는 리소스의 완전한
    • 응답 구조를 정의하고 이후 상세/목록 조회 API 및 스펙 문서에서 재사용하기 위해 포함된 응답 전용 필드
  • 카테고리

    • 원래 검증이 따로 없었지만 검증이 없는 상태로 각각의 분기별 테스트를 진행했는데
    • 카테고리가 없을 경우 400을 반환해야하지만 카테고리 관련은 전부 404반환
    • 404의 범위가 너무 넓기 때문으로 판단하여
      • 카테고리가 존재하지 않으면 400 , 정해지지 않은 카테고리면 404로 확정
    • 클라이언트한테 입력값은 int로 받지만 마지막 내보낼때 인스턴스로 변환

views.py

from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.qna.serializers.question.question_create import QuestionCreateSerializer


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,
            )

        error_type = serializer.errors.get("type")

        if error_type == ["permission_denied"]:
            return Response(
                {"error_detail": "질문 등록 권한이 없습니다."},
                status=status.HTTP_403_FORBIDDEN,
            )

        if error_type == ["title_conflict"]:
            return Response(
                {"error_detail": "중복된 질문 제목이 이미 존재합니다."},
                status=status.HTTP_409_CONFLICT,
            )

        if error_type == ["category_not_found"]:
            return Response(
                {"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            {"error_detail": "유효하지 않은 질문 등록 요청입니다."},
            status=status.HTTP_400_BAD_REQUEST,
        )

permissions.py

from apps.qna.models import Question
from apps.user.models import RoleChoices, User
from rest_framework import serializers
from apps.qna.models import QuestionCategory


def validate_question_create_permission(user: User) -> None:
    if user.role != RoleChoices.ST:
        raise serializers.ValidationError({"type": "permission_denied"})


def validate_question_title_unique(title: str) -> None:
    if Question.objects.filter(title=title).exists():
        raise serializers.ValidationError({"type": "title_conflict"})

def validate_question_category(category_id: int | None) -> None:
    """
    카테고리 검증
    - None / 누락 → invalid_request 400
    - 존재하지 않는 PK → category_not_found 404
    """
    if category_id is None:
        raise serializers.ValidationError({"type": "invalid_request"})

    if not QuestionCategory.objects.filter(id=category_id).exists():
        raise serializers.ValidationError({"type": "category_not_found"})

services.py

from typing import List

from apps.qna.models import Question, QuestionImage, QuestionCategory
from apps.user.models import User


def create_question(
    *,
    author: User,
    title: str,
    content: str,
    category: QuestionCategory,
    image_urls: List[str],
) -> Question:

    question = Question.objects.create(
        author=author,
        title=title,
        content=content,
        category=category,
    )

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

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

진행 과정

  • 0) 사용자가 보내는 요청 예시

  "title": "정상 등록 테스트",
  "content": "내용입니다.",
  "category": 3,
  "image_urls": ["https://test.com/a.png", "https://test.com/b.png"]

  • 1) URL → View로 들어옴

    • api/v1/qna/questions 접속시 QuestionCreateAPIView로 연결
      • 최종적으로 QuestionCreateAPIView.post()가 실행
  path("questions", QuestionCreateAPIView.as_view(), name="question_create"),

  • 2) View에서 Serializer 생성

# (1) 인증 확인 (DRF가 먼저 처리)
permission_classes = [IsAuthenticated] → 로그인 안 됨 → DRF가 post() 들어오기 전에 401로 끊음 

# (2) Serializer에 “요청 데이터”와 “request”를 넣어줌
serializer = QuestionCreateSerializer(
    data=request.data,				→ 사용자가 보낸 JSON
    context={"request": request},	→ Serializer 내부에서 request.user 쓰려고 넣음
)

# (3) serializer.is_valid() → 호출 DRF가 “검증 파이프라인”을 자동으로 타기 시작
if serializer.is_valid():
    question = serializer.save()

  • 3) is_valid()가 내부에서 하는 일 (중요)

    • serializer.is_valid()가 호출되면 내부적으로 다음 순서가 진행
  • 3-1) 필드 단위 검증 (타입/형식 체크)

    • title → (ModelSerializer라서 Question 모델의 CharField 기준 검증)
    • content → (TextField 기준 검증)
    • category = IntegerField() → 정수인지 확인
    • image_urls = ListField(child=URLField()) :
      • 리스트인지 확인
      • 각 원소가 URL 형식인지 확인
      • 없으면(required=False) 그냥 통과
    • 즉, category: "abc" → 바로 400 / image_urls: ["not-a-url"] → 바로 400
      • 형삭에러를 1차로 걸러냄
  • 3-2) 객체 단위 검증: validate(self, attrs) 호출

    • 필드 검증을 통과하면 이제 serializer의 validate()가 실행
      • 내 코드 기준: 권한체크 / 제목 중복 체크 / category존재 체크
    • attrs["category"] = QuestionCategory.objects.get(id=category_id)
      • Integer → FK 객체로 바꿔치기
      • 요청에서는 category: 3 (int)
      • 로직에서는 category: <QuestionCategory 객체> 로 변환
  • 여기까지 완료 시

    • is_valid()는 True가 되고, validated_data가 만들어짐

  • 4) save()가 내부에서 하는 일

    • serializer.save()를 호출하면 DRF는 보통 아래처럼 판단
      • instance가 없다(새로 만드는 요청) → create(validated_data) 실행
      • 그래서 create()가 호출
  • 4-1) image_urls를 validated_data에서 분리(pop)

    • Question 모델에는 image_urls 필드가 없어서
    • 그대로 Question.objects.create(**validated_data) 하면 에러 남
    • 그래서 따로 빼서 QuestionImage를 만들 때 쓰는 구조
  • 4-2) Service로 위임해서 DB 생성

return create_question(
    author=request.user,
    image_urls=image_urls,
    **validated_data,
)
  • validated_data
    • title: str / content: str / category: QuestionCategory 객체(FK)
    • image_urls는 이미 빠짐

  • 5) Service에서 실제 DB 저장

    • create_question()가 실행
# 5-1) Question 생성
question = Question.objects.create(
    author=author,
    title=title,
    content=content,
    category=category,
)

# 5-2) QuestionImage 여러 개 생성
for url in image_urls:
    QuestionImage.objects.create(
        question=question,
        img_url=url,
    )

5-3) question 반환
return question

  • 6) View에서 최종 응답 만들기

    • Service에서 반환된 question을 받고
return Response(
    {
        "message": "질문이 성공적으로 등록되었습니다.",
        "question_id": question.id,
    },
    status=201,
)

  • “images”는 이 과정에서 언제 쓰일까

    • 이 등록 API 흐름에서는 images가 쓰이지 않는 이유는
      • View가 serializer.data를 반환하지 않기 때문
profile
안녕하세요.

0개의 댓글