장고 실전 #5 OpenAPI 문서 — drf-spectacular
#1~#4에서 만든 API가 풍부해졌습니다. 이제 프론트와 약속을 어떻게 맞출 것인가가 다음 주제입니다. 답은 OpenAPI 자동 문서.
자동 문서가 왜 가치 있는가 #
프론트엔드/모바일과의 협업에서 “이 엔드포인트의 응답 구조 알려주세요” 가 매번 반복되는 일이 자동 문서로 사라집니다.
- 계약 (contract) — Swagger UI가 진실. 슬랙/노션 문서가 진실이 아님
- 클라이언트 SDK 자동 생성 — TypeScript / Swift / Kotlin SDK를 OpenAPI 스펙으로 생성
- 테스트 — schema diff로 breaking change 감지
- 외부 공개 — 외부 개발자에게 노출하기 좋은 형태
- 온보딩 — 신규 팀원이 API 전체를 한눈에
코드와 문서가 어긋날 일이 없어집니다 — 함수 시그니처가 문서가 되기 때문입니다.
drf-spectacular vs drf-yasg #
DRF 진영의 두 갈래.
| drf-yasg | drf-spectacular | |
|---|---|---|
| OpenAPI 버전 | 2.0 (Swagger) | 3.x |
| 활성도 | 유지보수 모드 | 활발 |
| 타입 힌트 추론 | 보통 | 강함 |
| 권장 (현재) | — | 신규는 이쪽 |
OpenAPI 3.x가 사실상 표준이 된 지금, 새 프로젝트는 drf-spectacular를 첫 선택. 이 글도 spectacular 기준입니다.
설치 #
uv add drf-spectacularINSTALLED_APPS += ["drf_spectacular"]
REST_FRAMEWORK = {
...,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "Blog API",
"DESCRIPTION": "장고 실전 시리즈의 예제 블로그 API",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False, # 별도 schema URL만 노출
"COMPONENT_SPLIT_REQUEST": True, # request/response 컴포넌트 분리
"SCHEMA_PATH_PREFIX": "/api/",
"SERVERS": [
{"url": "https://api.example.com", "description": "Production"},
{"url": "http://localhost:8000", "description": "Local"},
],
"TAGS": [
{"name": "posts", "description": "글 CRUD"},
{"name": "comments", "description": "댓글"},
{"name": "auth", "description": "인증"},
],
}URL 라우트 #
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
urlpatterns = [
...,
# 원본 OpenAPI 스키마 (JSON / YAML)
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
# 사람용 UI
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]이게 끝입니다. 브라우저로 확인:
| URL | 무엇 |
|---|---|
/api/schema/ | OpenAPI 3.x JSON (또는 ?format=openapi-json/-yaml) |
/api/docs/ | Swagger UI — Try it out가능 |
/api/redoc/ | ReDoc 스타일 (읽기 위주의 깔끔한 문서) |
CLI로 추출 #
CI 단계에서 schema 파일을 떨어뜨리고 싶으면.
uv run python manage.py spectacular --file schema.yml --validate--validate가 OpenAPI 3.x 명세를 위반하지 않는지 검사. CI에서 이게 통과하는 게 첫 게이트.
자동 추론 — 무엇을 공짜로 얻는가 #
drf-spectacular는 ViewSet/Serializer만으로 이미 풍부한 스키마를 만들어 줍니다.
| 출처 | 스키마로 변환 |
|---|---|
serializer_class | request / response body |
queryset.model | response의 객체 타입 |
filter_backends, filterset_class | 쿼리 파라미터 (필터) |
ordering_fields, search_fields | ?ordering=, ?search= 파라미터 |
pagination_class | 응답 envelope (count/next/previous) |
permission_classes | 인증 필요 여부, 401 응답 |
URL의 {pk} | path 파라미터 |
@action(detail=True/False) | 추가 라우트 |
#1~#3에서 만든 코드 그대로 두면, Swagger UI에는 이미:
/api/posts/GET (list, 페이지네이션, 필터/정렬/검색 파라미터)/api/posts/POST (PostSerializer 입력, 201 응답)/api/posts/{id}/GET/PUT/PATCH/DELETE/api/posts/{id}/publish/POST- 401 / 403 응답 표시 (perm 클래스 기반)
까지 다 들어있습니다.
@extend_schema — 정밀 명세
#
자동 추론이 못 잡는 부분 — 응답 예시, 다중 응답, 추가 파라미터, 에러 스키마 — 은 데코레이터로 채웁니다.
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiResponse,
OpenApiExample,
inline_serializer,
)
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Post
from .serializers import PostSerializer
@extend_schema_view(
list=extend_schema(
summary="글 목록",
description="공개 글 + 내 글을 페이지네이션으로 반환합니다.",
tags=["posts"],
),
retrieve=extend_schema(summary="글 상세", tags=["posts"]),
create=extend_schema(summary="글 작성", tags=["posts"]),
update=extend_schema(summary="글 전체 수정", tags=["posts"]),
partial_update=extend_schema(summary="글 부분 수정", tags=["posts"]),
destroy=extend_schema(summary="글 삭제", tags=["posts"]),
)
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
@extend_schema(
summary="글 공개 처리",
description="초안 상태의 글을 공개로 전환합니다. 작성자만 가능.",
request=None,
responses={
200: inline_serializer(
name="PublishResponse",
fields={"published": serializers.BooleanField()},
),
403: OpenApiResponse(description="작성자가 아님"),
404: OpenApiResponse(description="글이 없음"),
},
tags=["posts"],
)
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
post = self.get_object()
post.published = True
post.save()
return Response({"published": True})@extend_schema_view의 쓰임
#
ViewSet의 자동 메소드 (list, retrieve, create, …)는 우리가 직접 정의하지 않아 데코레이터를 못 붙입니다. **@extend_schema_view**가 클래스 레벨에서 그 메소드들에 한 번에 데코레이터를 붙여줍니다.
request=None
#
publish 액션은 본문을 받지 않습니다. request=None으로 명시 — 안 하면 spectacular가 serializer_class (PostSerializer)를 추론해서 잘못된 schema가 됩니다.
inline_serializer
#
응답 형태가 작으면 별도 Serializer 클래스를 만들지 않고 인라인으로. 큰 형태는 별도 Serializer 클래스가 재사용에 좋습니다.
응답 예시 — OpenApiExample
#
@extend_schema(
responses={
200: PostSerializer,
},
examples=[
OpenApiExample(
"공개된 글",
value={
"id": 1,
"title": "장고 시작하기",
"body": "...",
"published": True,
"created_at": "2026-05-01T10:00:00Z",
},
response_only=True,
),
OpenApiExample(
"초안",
value={
"id": 2,
"title": "작성 중",
"body": "...",
"published": False,
"created_at": "2026-05-02T11:00:00Z",
},
response_only=True,
),
],
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)Swagger UI에서 예시를 토글로 볼 수 있어 클라이언트 개발자가 빠르게 감을 잡습니다.
에러 스키마 — 일관된 형태 #
DRF의 기본 에러 응답은 형태가 들쭉날쭉합니다 (validation은 dict, exception은 {"detail": "..."}). 일관된 에러 envelope을 만들고 그걸 모든 4xx/5xx 응답에 명세하는 게 좋습니다.
from rest_framework import serializers
class ErrorDetailSerializer(serializers.Serializer):
detail = serializers.CharField()
code = serializers.CharField(required=False)
field_errors = serializers.DictField(
child=serializers.ListField(child=serializers.CharField()),
required=False,
)@extend_schema(
responses={
201: PostSerializer,
400: ErrorDetailSerializer,
401: ErrorDetailSerializer,
403: ErrorDetailSerializer,
},
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)글로벌 에러 핸들러와 같이 #
REST_FRAMEWORK = {
...,
"EXCEPTION_HANDLER": "blog.exceptions.custom_exception_handler",
}from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is None:
return None
data = {"detail": response.data.get("detail", str(exc))}
if isinstance(response.data, dict) and "detail" not in response.data:
data = {"detail": "Validation failed", "field_errors": response.data}
response.data = data
return response응답 형태가 늘 같으면 클라이언트 코드가 단순해지고, schema도 정직해집니다.
인증 헤더 명세 #
#2에서 만든 JWT 인증을 Swagger UI의 “Authorize” 버튼이 인식하도록 — 자동으로 잡히지만, 명시적으로 잡고 싶으면.
SPECTACULAR_SETTINGS = {
...,
"SECURITY": [{"BearerAuth": []}],
"AUTHENTICATION_WHITELIST": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"APPEND_COMPONENTS": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}
},
}Swagger UI에서 /api/auth/token/으로 토큰을 받고, “Authorize” 버튼에 Bearer <access>를 입력하면 이후 모든 요청에 헤더가 자동으로 붙습니다 — Try it out으로 인증된 호출을 바로 시험할 수 있습니다.
쿼리 파라미터 명세 — OpenApiParameter
#
@action이나 커스텀 view에서 쿼리 파라미터를 명시.
@extend_schema(
parameters=[
OpenApiParameter(
name="since",
type=str,
location=OpenApiParameter.QUERY,
description="ISO 8601 datetime — 그 이후 글만",
required=False,
examples=[OpenApiExample("최근 7일", value="2026-05-01T00:00:00Z")],
),
],
responses={200: PostSerializer(many=True)},
)
@action(detail=False, methods=["get"])
def recent(self, request):
...DjangoFilterBackend, OrderingFilter, SearchFilter의 파라미터는 spectacular가 자동으로 잡으므로 따로 적을 필요 없습니다. 자동 추론이 못 잡는 것만 보충.
Versioning — API 버전 #
REST_FRAMEWORK = {
...,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1", "v2"],
}urlpatterns = [
path("api/<version>/", include("blog.urls")),
]drf-spectacular가 버전별로 다른 schema를 만들어 줍니다. 클라이언트가 Accept-Version: v2 같은 헤더로 분기 가능.
클라이언트 SDK 자동 생성 #
OpenAPI의 큰 효용 — TypeScript / Swift / Kotlin SDK 자동 생성. 프론트가 API 호출 코드를 직접 적지 않아도 됩니다.
# TypeScript 타입만
npx openapi-typescript http://localhost:8000/api/schema/ -o api.d.ts
# 풀 SDK (axios 클라이언트 + 타입)
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8000/api/schema/ \
-g typescript-axios \
-o ./clientbackend가 spec을 갱신하면 frontend가 SDK를 다시 생성하는, 타입 안전한 contract-first 흐름이 만들어집니다.
Schema 회귀 테스트 — breaking change 감지 #
#6에서 깊게 다루지만 패턴은 단순합니다. CI에서 schema diff를 검사해 의도하지 않은 breaking change를 막습니다.
uv run python manage.py spectacular --file schema.yml --validate
git diff --exit-code schema.yml # 변경되면 PR에 의도된 것인지 묻기oasdiff 같은 전용 도구는 더 정교한 diff와 breaking 분류를 해줍니다.
FastAPI의 자동 OpenAPI와 비교 #
FastAPI #1에서 봤듯 FastAPI는 빌트인으로 자동 문서가 나옵니다. DRF는 별도 라이브러리(spectacular) + 데코레이터가 더 필요합니다.
| Django + DRF + spectacular | FastAPI | |
|---|---|---|
| 자동 문서 | spectacular 설치/설정 | 빌트인 (/docs) |
| 자동 추론 | ViewSet/Serializer 기반 | 함수 시그니처/Pydantic 기반 |
| 추가 명세 | @extend_schema 데코레이터 | 함수 시그니처 + response_model |
| 명시도 | 데코레이터가 더 필요 | 시그니처 자체가 명세 |
| 결과 품질 | 매우 좋음 | 매우 좋음 |
| 학습 곡선 | spectacular 패턴 학습 | 거의 없음 |
이미 큰 Django 프로젝트가 있다면 spectacular를 얹는 비용은 작고 효용은 큽니다. 둘 다 결국 OpenAPI 3.x로 수렴하므로 클라이언트 SDK 생성/외부 도구는 동일.
자주 만나는 함정 #
1) 자동 추론된 스키마가 어색 #
fields = "__all__"로 만든 ModelSerializer가 password_hash 같은 필드를 노출. 명시적인 fields가 안전합니다 (#1에서도 같은 권장).
2) 응답이 여러 가지 #
PostSerializer(many=True) vs PostSerializer()같이 같은 액션이 컨텍스트에 따라 다른 응답을 줄 때, responses={200: PostSerializer(many=True)}같이 명시하세요. 자동 추론이 늘 맞지 않습니다.
3) @action의 serializer_class
#
@action에 다른 입력/출력을 쓰려면 명시적으로:
@extend_schema(request=PublishRequestSerializer, responses=PublishResponseSerializer)
@action(detail=True, methods=["post"], serializer_class=PublishRequestSerializer)
def publish(self, request, pk=None): ...4) schema가 stale #
코드를 바꿨는데 schema가 옛날 형태를 가리킵니다. manage.py spectacular --file ... --validate가 import 순서나 제너릭 에러를 잡아줍니다. CI에 추가하세요 — schema 생성이 깨지면 빌드 실패로.
5) EXCEPTION_HANDLER와 schema 불일치
#
커스텀 예외 핸들러로 응답 형태를 바꿨는데 schema에는 옛 형태가 남습니다. 예외 핸들러가 만드는 응답 형태를 responses=에 정확히 명시.
정리 #
이번 글에서 잡은 것:
- 자동 문서가 가치 있는 지점 — 계약, SDK, 테스트, 외부 공개, 온보딩
- drf-spectacular vs drf-yasg — OpenAPI 3.x 시대는 spectacular
- 설치 +
SPECTACULAR_SETTINGS+ URL 라우트 —/schema/,/docs/,/redoc/ manage.py spectacular --validate로 CI 게이트- 자동 추론으로 얻는 것 — Serializer / 필터 / 페이지네이션 / 권한 / @action
@extend_schema— summary / description / request / responses / tags@extend_schema_view— ViewSet 자동 메소드에 데코inline_serializer,OpenApiExample,OpenApiResponse,OpenApiParameter- 일관된 에러 스키마 + 글로벌
EXCEPTION_HANDLER - JWT 인증 명세, Swagger UI Authorize 동작
- API versioning
- 클라이언트 SDK 자동 생성 (
openapi-typescript,openapi-generator) - Schema 회귀 테스트로 breaking change 감지
- FastAPI의 빌트인 자동 문서와 비교
- 함정 —
__all__, 다중 응답,@actionserializer, stale schema, 예외 핸들러
FastAPI #1의 빌트인 /docs만큼 자연스럽지는 않지만, spectacular로 명시적인 스키마를 만드는 방식은 결과적으로 더 정확한 문서를 줍니다 — 자동 추론이 잡지 못하는 다중 응답/에러 스키마/예시까지 다 들어가기 때문입니다.
다음 글(#6 테스트와 배포)에서는 시리즈의 마지막 — APITestCase / APIClient / pytest-django로 자동 테스트를 짜고, Docker / gunicorn / nginx로 프로덕션 배포까지 — 한곳에 정리합니다.