Django - SpecAPI / Test

김기훈·2026년 1월 3일

Django

목록 보기
15/17

SpecAPI 🟥

  • 실기능은 하지 않지만 요청 / 응답 스키마를 구성하고 있는 깡통API

  • 백엔드 서버가 어떤 기능을 제공하는지

    • URL/Method/Request/Response 등을 표준 형식으로 정의한 문서

    • 서버와 클라이언트(프론트/앱/협업 개발자)들이 약속하는 규칙서

    • Spec API로 먼저 명세 작성 → 그걸 기반으로 실제 기능 개발

      기능URLMethodRequestResponse
      로그인/api/users/login/POST{id, pw}{access, refresh}

  • SpecAPI 목적

      1. 프론트엔드가 먼저 API 모양을 확인할 수 있게 하고
      1. 팀원들이 Request / Response 형태를 합의하고
      1. 실제 기능 개발 전에 오류를 줄이기 위해서
      1. Swagger / OpenAPI 기반 문서 자동화에도 사용됨
  • SpecAPI의 기본 구성 요소

    • Info : API 전체 설명
    • Paths : URL들
    • Methods : GET / POST / PUT / DELETE
    • Parameters : URL, Query, Header
    • Request Body : 입력 데이터
    • Responses : 응답 형식
    • Schema : 데이터 구조 정의 (DTO 같은 구조)
  • SpecAPI 과정

    • SpecAPI 단계
      • “명세서(API Contract) 작성하기”
    • 실제 기능 개발 단계
      • SpecAP를 기반으로 구현하기
        • Request 데이터 Validation 추가 (Serializer에서 처리)
        • 비즈니스 로직 수행 (DB 저장, 권한 체크 등)
        • Spec과 동일한 Response 형태로 응답
    • Test Code 작성
      • Spec API는 테스트 필요 없음 (Mock이니까)
      • 실제 기능 API는 반드시 테스트 필요
        • 정상 케이스 / 유효성 실패 케이스 / 권한 실패 케이스
  • SpecAPI로 할 수 있는 것

    • API 문서 자동 생성
      • 백엔드 코드를 기반으로 자동으로 API 문서 생성 가능
      • (Django → drf-yasg / Spectacular, FastAPI → 자동 생성)
    • 클라이언트 코드 자동 생성
      • OpenAPI 스펙 파일만 있으면 아래를 자동 생성 가능:
        • React/Next.js API 호출 코드
        • Android/iOS API 코드
        • Python/Typescript SDK
    • 테스트 자동화
      • 스펙 기반으로 API 테스트도 자동 가능

SpecAPI 확장

      [요구사항 분석] → [Spec API 작성] → [Spec API를 팀에서 검토 · 확정] → [실제 기능 개발] →
                                                                   - Validation 
     [테스트 코드 작성] → [리뷰 → Merge]                                - 비즈니스 로직 
                                                                   - DB 처리

예시

POST /api/users/register/
Request:
{
  "username": "kihoon",
  "password": "1234",
  "email": "test@example.com"
}

Response:
201 Created
{
  "id": 1,
  "username": "kihoon",
  "email": "test@example.com"
}

Mock API

  • 스펙 기반 가짜 API 서버

    • 실제 백엔드 없이 API 문서(Spec)만 보고 실제처럼 응답을 돌려주는 서버

      특징설명
      DB 필요 없음가짜 객체를 만들어 serializer로 응답
      프론트엔드 테스트 가능실제로 서버 있는 것처럼 요청 보내서 응답 확인 가능
      OpenAPI 문서 자동 생성drf-spectacular 이용
      응답 구조는 실제 서버와 동일하게 시뮬레이션나중에 실제 백엔드로 자연스럽게 교체 가능

SpecAPI 예시 🟥

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

스웨거


swagger 🟥


drf-spectacular

  • View / url / serializer / permission
    • drf의 기능 파일을 쭉 읽어와서 swagger 문서 스키마를 동적으로 생성
  • 익스턴십에서는 Swagger 문서 자동화를 위한 라이브러리로 drf-spectacular 를 사용
    • 이 라이브러리는 기본적으로 djangorestframework 의 APIView 클래스의
    • 인스턴스 변수인 serializer_class 에 할당된 시리얼라이저를 읽어와서 스키마를 구성
  • 따라서 DRF 의 APIView, GenericAPIView를 적극적으로 활용하되
    • serializer_class 인스턴스 변수에 해당 뷰 클래스에서 사용되는 시리얼라이저를 필수적으로 명시
  • 단, 예외적으로 각 APIView 클래스 또는 메소드 함수(get, post, put, patch, delete 등) 마다
    • extend_schema 데코레이터를 사용하여 스키마를 구성하고
    • 각 API 별 태깅, API 요약, 구체적인 설명, 파라미터 등을 지정 가능

@extend_schema

  • 익스턴십에서는 extend_schema를 사용하여 swagger 문서 자동화 양식을 지정
    • 규칙을 따라 extend_schema 데코레이터를 적절하게 활용하여 swagger 문서를 구성해야 함
  • 규칙

    • Tag
      • 해당 API가 해당되는 요구사항 정의서의 카테고리 명을 사용
      • ex. 쪽지시험 관리, 회원 관리 등
    • Summary
      • 해당 API의 요약 설명을 기재
      • ex. 일반 회원가입 API, 쪽지시험 목록 API 등
    • Description
      • 해당 API의 구체적인 동작 설명을 기재

  • 사용법

@extend_schema(
  tags=["User"],
  summary="회원 정보 업데이트 API",
  description="""
  오즈 코딩 스쿨 이용자들 중 로그인 한 회원이 자기 자신의 정보를 수정할 때 사용하는 API 입니다.
  요청 본문으로 업데이트할 필드를 선택적으로 포함하여 요청을 보내면, 
  서버에서 해당 유저의 유저정보 필드들 중 요청 본문에 포함된 필드를 업데이트 합니다.
  """,
  request=UserUpdateRequestSerializer,
  responses=UserUpdateResponseSerializer
)
class UserUpdateAPIView(APIView):
	def patch(self, request: Request) -> Response:
		serailizer = UserUpdateRequestSerializer(data=request.data, partial=True)
		serializer.is_valid(raise_exception=True)
		instance = serailizer.save()
		return Response(data=UserUpdateResponseSerializer(instance).data, status=status.HTTP_200_OK)

URL 매핑 규칙

    1. Trailing Slash는 추가 X ( url 맨뒤에 / 는 사용 X )
    1. 모든 API는 Rest API 원칙에 따라 url을 구성
    1. url 매핑 시 어드민 페이지에 해당되는 API의 경우 prefix에 admin을 추가합니다.
      1. /admin/users | 2. /admin/exams | 3. /admin/qna-questions

Django test 🟥

from django.test import TestCase, TransactionTestCase, SimpleTestCase, LiveServerTestCase
from rest_framework.test import APITestCase, APITransactionTestCase, APILiveServerTestCase

Question.objects

  • Model Manager → Question 테이블에 대해 DB 작업을 할 수 있게 해주는 객체
  • .create() / .get() / .filter() / .annotate()

    • Manager / QuerySet → ORM 메서드(API)
# create → 데이터 생성 (INSERT)
## 즉시 DB에 저장 / 반환값: 생성된 Question 객체
Question.objects.create(
    title="질문",
    content="내용",
    author=user,
    category=category,
)

# annotate → 조회 결과에 계산된 필드 추가 (SELECT)
## DB에 저장 ❌ / 조회 결과에만 가짜 컬럼 추가 / 반환값: QuerySet
Question.objects.annotate(
    answer_count=Count("answers")
)

Test Code 🟥

  • 각 기능 개발시 통합 테스트 코드를 반드시 작성 필요
    • 내가 작성한 기능이 올바르게 동작하는 지
      • 로컬에서는 swagger, postman 등의 툴로 수작업으로 테스트 가능 하지만
        • CI 환경에서는 수작업으로 테스트 불가
          • 작성된 코드가 올바르게 동작하는 지를 검증하는 통합 테스트 코드가 필요
  • 통합 테스트

    • 통합 테스트(Integration Test)는 서로 다른 모듈들 간의 상호작용을 테스트하는 과정
      • 예를 들어, 신규로 개발한 API 서버 내의 DB 호출 함수가
      • 데이터베이스의 데이터를 잘 호출하고 있는지, 올바른 응답을 반환하는 지 등을 테스트하는 과정
  • Test Code 의 중요성

    • 테스트 코드를 작성하면 개발자는 소프트웨어가 어떻게 작동하는지를 이해하고,
      • 소프트웨어를 수정할 때 예상치 못한 부작용을 방지 가능
    • 또한 테스트 코드는 개발자 간의 협업을 원활하게 하고,
      • 소프트웨어를 유지 보수하는 데 필요한 문서화 작업을 줄일 수 있음
  • 테스트 코드 작성 시 pytest 등의 별도의 테스트 라이브러리를 사용하는 대신
    • Django 내부에 포함된 TestClient를 활용하여 테스트코드를 작성
      • CI 가 Django Test Module을 기준으로 설정되어 있어 Pytest로 Testcode 작성 시
      • Github Actions 의 CI 스크립트를 이용한 테스트 통과여부 확인이 불가능
  • 예시

from rest_framework.test import APITestCase
from django.urls import reverse
from rest_framework import status
from django.contrib.auth import get_user_model

User = get_user_model()

class UserSignupTest(APITestCase):
    def setUp(self):
		    # ex: path('signup/', SignupView.as_view(), name='user-signup')
        self.signup_url = reverse('user-signup')  
        self.valid_payload = {
            "username": "testuser",
            "email": "test@example.com",
            "password": "securepassword123"
        }`

    def test_signup_success(self):
        response = self.client.post(self.signup_url, data=self.valid_payload)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertTrue(User.objects.filter(username="testuser").exists())
        self.assertEqual(response.data["username"], "testuser")
        self.assertNotIn("password", response.data)  # 보안상 비밀번호는 응답에 없어야 함

    def test_signup_missing_fields(self):
        response = self.client.post(self.signup_url, data={})
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("username", response.data)
        self.assertIn("email", response.data)
        self.assertIn("password", response.data)

    def test_signup_duplicate_username(self):
        # 이미 같은 username의 사용자 생성
        User.objects.create_user(username="testuser", email="test1@example.com", password="password123")
        response = self.client.post(self.signup_url, data=self.valid_payload)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("username", response.data)

    def test_signup_weak_password(self):
        weak_payload = self.valid_payload.copy()
        weak_payload["password"] = "123"
        response = self.client.post(self.signup_url, data=weak_payload)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("password", response.data)

profile
안녕하세요.

0개의 댓글