백엔드 서버가 어떤 기능을 제공하는지
URL/Method/Request/Response 등을 표준 형식으로 정의한 문서
서버와 클라이언트(프론트/앱/협업 개발자)들이 약속하는 규칙서
Spec API로 먼저 명세 작성 → 그걸 기반으로 실제 기능 개발
| 기능 | URL | Method | Request | Response |
|---|---|---|---|---|
| 로그인 | /api/users/login/ | POST | {id, pw} | {access, refresh} |
Request / Response 형태를 합의하고Swagger / OpenAPI 기반 문서 자동화에도 사용됨Info : API 전체 설명Paths : URL들Methods : GET / POST / PUT / DELETEParameters : URL, Query, HeaderRequest Body : 입력 데이터Responses : 응답 형식Schema : 데이터 구조 정의 (DTO 같은 구조)Request 데이터 Validation 추가 (Serializer에서 처리)Response 형태로 응답 [요구사항 분석] → [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"
}
스펙 기반 가짜 API 서버
실제 백엔드 없이 API 문서(Spec)만 보고 실제처럼 응답을 돌려주는 서버
| 특징 | 설명 |
|---|---|
| DB 필요 없음 | 가짜 객체를 만들어 serializer로 응답 |
| 프론트엔드 테스트 가능 | 실제로 서버 있는 것처럼 요청 보내서 응답 확인 가능 |
| OpenAPI 문서 자동 생성 | drf-spectacular 이용 |
| 응답 구조는 실제 서버와 동일하게 시뮬레이션 | 나중에 실제 백엔드로 자연스럽게 교체 가능 |
apps/questions/
├── spec/
│ ├── serializers.py
│ └── views.py
├── serializers.py
├── views.py
├── urls.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 QuestionImageSpecSerializerclass Metaclass QuestionCreateSpecSerializercategory = serializers.IntegerField()image_urls = serializers.ListFieldListField 로 리스트를 받고, 각 원소는 CharField 로 받음 → URL 형식이 아니어도 통과write_only=True → 요청(body)에서만 사용, 응답에는 안 나옴required=False → 안 보내도 유효성 검사를 통과(이미지 없는 질문도 허용)images = QuestionImageSpecSerializermany=True → 이미지 여러 개 read_only=True → 클라이언트가 직접 입력할 수 없고, 서버에서만class Metaimage_urls: 요청에서만 받는 이미지 URL 리스트images: 응답으로 보여줄 이미지 목록 (QuestionImageSpecSerializer)read_only_fields: 서버에서 채우는 값이므로 입력 불가능, 응답 전용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 QuestionCreateSpecAPIViewpermission_classes = [AllowAny] serializer_class = QuestionCreateSpecSerializer@extend_schemarequest=QuestionCreateSpecSerializerresponsesdef post(self, request: Request) -> Response:serializer = self.serializer_class(data=request.data)serializer.is_valid(raise_exception=True)validated = serializer.validated_dataimage_urls": validated.get("image_urls", []) images": [{"img_url": url} for url in validated.get("image_urls", [])][{"img_url": "..."}, ...] 형태로 변환created_atresponse_serializer = self.serializer_class(mock_response)return Response(response_serializer.data, status=status.HTTP_201_CREATED)
스웨거
View / url / serializer / permission 등drf-spectacular 를 사용djangorestframework 의 APIView 클래스의 serializer_class 에 할당된 시리얼라이저를 읽어와서 스키마를 구성APIView, GenericAPIView를 적극적으로 활용하되 serializer_class 인스턴스 변수에 해당 뷰 클래스에서 사용되는 시리얼라이저를 필수적으로 명시extend_schema 데코레이터를 사용하여 스키마를 구성하고 TagSummaryDescription@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)
/admin/users | 2. /admin/exams | 3. /admin/qna-questionsfrom django.test import TestCase, TransactionTestCase, SimpleTestCase, LiveServerTestCase
from rest_framework.test import APITestCase, APITransactionTestCase, APILiveServerTestCase
.create() / .get() / .filter() / .annotate()# create → 데이터 생성 (INSERT)
## 즉시 DB에 저장 / 반환값: 생성된 Question 객체
Question.objects.create(
title="질문",
content="내용",
author=user,
category=category,
)
# annotate → 조회 결과에 계산된 필드 추가 (SELECT)
## DB에 저장 ❌ / 조회 결과에만 가짜 컬럼 추가 / 반환값: QuerySet
Question.objects.annotate(
answer_count=Count("answers")
)
Django 내부에 포함된 TestClient를 활용하여 테스트코드를 작성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)