장고 실전 #5 OpenAPI 문서 — drf-spectacular

8 분 소요

#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-yasgdrf-spectacular
OpenAPI 버전2.0 (Swagger)3.x
활성도유지보수 모드활발
타입 힌트 추론보통강함
권장 (현재)신규는 이쪽

OpenAPI 3.x가 사실상 표준이 된 지금, 새 프로젝트는 drf-spectacular를 첫 선택. 이 글도 spectacular 기준입니다.

설치 #

패키지
uv add drf-spectacular
settings.py
INSTALLED_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 라우트 #

mysite/urls.py
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 파일을 떨어뜨리고 싶으면.

schema 파일 생성
uv run python manage.py spectacular --file schema.yml --validate

--validate가 OpenAPI 3.x 명세를 위반하지 않는지 검사. CI에서 이게 통과하는 게 첫 게이트.

자동 추론 — 무엇을 공짜로 얻는가 #

drf-spectacular는 ViewSet/Serializer만으로 이미 풍부한 스키마를 만들어 줍니다.

출처스키마로 변환
serializer_classrequest / response body
queryset.modelresponse의 객체 타입
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 — 정밀 명세 #

자동 추론이 못 잡는 부분 — 응답 예시, 다중 응답, 추가 파라미터, 에러 스키마 — 은 데코레이터로 채웁니다.

blog/views.py
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 응답에 명세하는 게 좋습니다.

blog/serializers.py — 에러
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,
    )
ViewSet에서
@extend_schema(
    responses={
        201: PostSerializer,
        400: ErrorDetailSerializer,
        401: ErrorDetailSerializer,
        403: ErrorDetailSerializer,
    },
)
def create(self, request, *args, **kwargs):
    return super().create(request, *args, **kwargs)

글로벌 에러 핸들러와 같이 #

settings.py
REST_FRAMEWORK = {
    ...,
    "EXCEPTION_HANDLER": "blog.exceptions.custom_exception_handler",
}
blog/exceptions.py
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” 버튼이 인식하도록 — 자동으로 잡히지만, 명시적으로 잡고 싶으면.

settings.py
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 버전 #

settings.py
REST_FRAMEWORK = {
    ...,
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}
urls.py
urlpatterns = [
    path("api/<version>/", include("blog.urls")),
]

drf-spectacular가 버전별로 다른 schema를 만들어 줍니다. 클라이언트가 Accept-Version: v2 같은 헤더로 분기 가능.

클라이언트 SDK 자동 생성 #

OpenAPI의 큰 효용 — TypeScript / Swift / Kotlin SDK 자동 생성. 프론트가 API 호출 코드를 직접 적지 않아도 됩니다.

openapi-generator 또는 openapi-typescript
# 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 ./client

backend가 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 + spectacularFastAPI
자동 문서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) @actionserializer_class #

@action에 다른 입력/출력을 쓰려면 명시적으로:

@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__, 다중 응답, @action serializer, stale schema, 예외 핸들러

FastAPI #1의 빌트인 /docs만큼 자연스럽지는 않지만, spectacular로 명시적인 스키마를 만드는 방식은 결과적으로 더 정확한 문서를 줍니다 — 자동 추론이 잡지 못하는 다중 응답/에러 스키마/예시까지 다 들어가기 때문입니다.

다음 글(#6 테스트와 배포)에서는 시리즈의 마지막 — APITestCase / APIClient / pytest-django로 자동 테스트를 짜고, Docker / gunicorn / nginx로 프로덕션 배포까지 — 한곳에 정리합니다.

X