오늘은 저번 주에 하다가 말았던 drf-spectacular 실습을 마무리하고자 한다. 저번 주에, 두 차례의 포스팅((1), (2))을 통해서 drf-spectacular의 사용법을 알아보고, 약간의 커스터마이징도 진행해 보았다. 하지만, 아직 부족한 부분이 있어 다음 번에 조금 더 실습을 하고 마무리하겠다고 했는데, 오늘 그 실습을 마무리하고자 한다.
OpenApiExample오늘은 예시를 추가하는 작업을 할 것이다.
drf-spectacular의 utils 모듈에는, OpenApiExample이라는 클래스가 있다.
먼저, 공식 문서의 예제를 살펴보자.
@extend_schema_serializer(
exclude_fields=('single',),
examples = [
OpenApiExample(
'Valid example 1',
summary='short summary',
description='longer description',
value={
'songs': {'top10': True},
'single': {'top10': True}
},
request_only=True,
response_only=True,
),
]
)
class AlbumSerializer(serializers.ModelSerializer):
songs = SongSerializer(many=True)
single = SongSerializer(read_only=True)
class Meta:
fields = '__all__'
model = Album
@extend_schema_serializer 데코레이터를 사용하여 시리얼라이저의 스키마를 커스터마이징하는 예이다.examples으로, examples는 OpenApiExample들로 이루어진 리스트임을 알 수 있다.OpenApiExample의 인자는 공식 문서를 참고value 인자에 필드명을 키, 값을 필드의 값으로 하는 딕셔너리를 전달하고 있는 것을 볼 수 있다.시리얼라이저를 커스터마이징해서, 예시를 추가할 것이다.
시리얼라이저를 커스터마이징할 때에는 @extend_schema_serializer 데코레이터를 사용하면 된다.
@extend_schema_serializer(
examples=[
OpenApiExample(
name='감독',
value={
'name': '봉준호',
'profile_url': '(프로필 URL)',
'role': '감독'
},
),
OpenApiExample(
name='배우',
value={
'name': '봉준호',
'profile_url': '(프로필 URL)',
'role': '변호사'
},
)
]
)
class CastListResponseSerializer(serializers.ModelSerializer):
class Meta:
model = models.Cast
fields = ['name', 'profile_url', 'role']
출연진 리스트와 관련된 CastListResponseSerializer를 커스터마이징하여 예시를 추가해 보았다. 이처럼, examples에는 여러 개의 OpenApiExample이 들어갈 수 있다.
물론 1번에서의 예제나 아래의 코드처럼 하나의 OpenApiExample만 있어도 된다.
@extend_schema_serializer(
examples=[
OpenApiExample(
name='Example',
value={
'id': 1,
'nickname': '영화조아',
'comment': '재밌어요!',
'created_at': '2025-07-20 13:13'
},
),
]
)
class CommentResponseSerializer(serializers.ModelSerializer):
'''
1. 영화에 달린 코멘트 목록 열람용 시리얼라이저
2. 코멘트 작성 시 반환되는 응답용 시리얼라이저
'''
nickname = serializers.SerializerMethodField()
created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M")
class Meta:
model = models.Comment
fields = ['id', 'nickname', 'comment', 'created_at']
@extend_schema_field(OpenApiTypes.STR)
def get_nickname(self, obj):
return obj.user_id.nickname
이것은 댓글에 대한 예시로, examples은 하나의 OpenApiExample만으로 이루어진 리스트이다.

편집한 후, 스키마 문서를 살펴보면 Examples가 추가되어 있는 것을 볼 수 있다. 이 부분에서 각각의 OpenApiExample을 선택할 수 있다. 이때 표시되는 이름이 바로 OpenApiExample의 name에 해당한다. 그리고, 선택한 OpenApiExample의 value가 예시값으로 나타나게 된다.
참고로, 오른쪽에 나타나는 DjDT라는 버튼은 Django Debug Toolbar를 설치했기 때문에 나타나는 것이다. Django 프로젝트에서 사용할 수 있는 디버깅 툴인데, SQL 쿼리나, 요청/응답 헤더, Query Parameter 등 다양한 정보를 제공하기에 디버깅 시 유용하게 사용할 수 있다.
뷰에도 약간의 예시를 추가하고자 한다.
영화를 검색할 수 있는 뷰가 있는데, 이 뷰는 영화 목록 뷰에서 쓰이는 시리얼라이저와 같은 시리얼라이저를 사용한다. 하지만, 특정 키워드에 대한 검색 예시를 보여주고 싶기 때문에 이 뷰에서 스키마를 커스터마이징하여 다른 예시를 만들고자 한다.
class MovieSearch(generics.ListAPIView):
'''
한국어 제목을 바탕으로 영화 검색. 영화 제목 내 검색어 포함을 기준으로 검색됨
'''
queryset = models.Movie.objects.all()
serializer_class = serializers.MovieListResponseSerializer
@extend_schema(
summary="한국어 제목을 바탕으로 영화 검색",
examples=[
OpenApiExample(
name='키워드: 아',
value={
'id': 29,
'title_kor': '아이 쏘우 더 TV 글로우',
'poster_url': '(포스터 이미지 URL)',
'rate': 5.841,
'detail_url': 'http://127.0.0.1:8000/api/movies/list/29'
}
)
]
)
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
keyword = request.query_params.get('title', '')
queryset = queryset.filter(title_kor__icontains=keyword)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
키워드를 '아'로 하여 검색하는 예시를 만들었다. 이렇게 지정했을 때, 기존 시리얼라이저에 있는 예시는 보이지 않고, 이 뷰에서 지정한 예시만 보이게 된다.
소스 코드를 살짝 살펴보니, AutoSchema 클래스의 _get_examples 메소드에 아래와 같은 내용이 있었다.
class AutoSchema(ViewInspector):
# ...
def _get_examples(self, serializer, direction, media_type, status_code=None, extras=None):
""" Handles examples for request/response. purposefully ignores parameter examples """
# don't let the parameter examples influence the serializer example retrieval
examples = [e for e in self.get_examples() if not e.parameter_only]
# Examples from Serializers via @extend_schema_serializer are only considered, if
# there were no higher priority examples directly from view annotation.
if not examples:
if is_list_serializer(serializer):
examples = get_override(serializer.child, 'examples', [])
elif is_serializer(serializer):
examples = get_override(serializer, 'examples', [])
# ...
이를 통해 추측해보면,@extend_schema로 추가한 예시가 없으면, 시리얼라이저의 예시를 가져오고, @extend_schcema로 추가한 예시가 있으면 그 예시를 사용하는 것으로 보인다.
만약, 서비스가 커져 사용자들에게 API 문서를 공개할 일이 생긴다면?
예를 들자면, 공공데이터포털에서는 데이터를 엑셀 파일뿐만 아니라, 오픈 API로도 제공한다. 그리고, 이에 대한 문서도 있다.

예를 들어, 착한가격업소현황이라는 데이터에서 오픈 API가 제공되고, 데이터를 조회할 수 있는 목록을 제공한다.
이렇게 제공되는 공개 문서에서는 내부에서만 사용할 API는 제외하고 보여줘야 할 필요성이 있다.
SERVE_URLCONFdrf-spectacular의 설정에는 SERVE_URLCONF라는 옵션이 존재한다. 이 옵션을 사용하면, 스키마 생성에 사용되는 경로 기준을 프로젝트 루트의 url 패턴들이 아니라 특정 앱의 url 패턴으로 설정하여 공개되어선 안되는 api의 노출을 막을 수 있다.
SPECTACULAR_SETTINGS = {
'TITLE': 'MovieReview API',
'DESCRIPTION': '미니 해커톤 2조 MovieReview 사이트의 API입니다.',
'VERSION': 'dev',
'SERVERS': [{'url': 'http://127.0.0.1:8000/api'}],
'SERVE_INCLUDE_SCHEMA': False,
'SCHEMA_PATH_PREFIX': '/api',
'SCHEMA_PATH_PREFIX_TRIM': True,
'SORT_OPERATIONS': False,
'SERVE_URLCONF': 'movies.urls'
}
이렇게 설정하면, 스키마 문서는 movies.urls에 있는 엔드포인트들에 대해서만 API 정보를 보여준다.

movies 앱의 url 패턴에 있지 않던 회원 관련 엔드포인트들이 노출되지 않는 것을 볼 수 있다. 그리고 엔드포인트가 movies/로 시작하지 않고 /으로 시작하는 것을 볼 수 있다.
from django.contrib import admin
from django.urls import path
from . import views
app_name = 'movies'
urlpatterns = [
path('', views.MovieListofTopTen.as_view(), name='movie-main'),
path('list/', views.MovieList.as_view(), name='movie-list'),
path('list/<int:movie_id>/', views.MovieDetail.as_view(), name='movie-detail'),
path('search/', views.MovieSearch.as_view(), name='movie-search'),
path('comment/create/<int:movie_id>/', views.CommentList.as_view(), name='comment-list'),
path('comment/list/<int:movie_id>/', views.CommentList.as_view(), name='comment-create'),
]
이것은 movies 폴더의 urls.py 파일의 내용으로, 여기 정의되어 있는 url 패턴들에 있는 엔드포인트와 위에서 보이는 엔드포인트가 같음을 알 수 있다. 경로의 기준이 movies 앱의 urls.py이기 때문에, 이 파일의 내용과 엔드포인트가 같아지게 된 것이다.
as_view에서의 urlconf 인자SpectacularAPIView의 as_view 메소드에서 urlconf 인자를 지정하는 방법도 있다.
urlpatterns = [
# ...
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/schema/movie/', SpectacularAPIView.as_view(urlconf='movies.urls'), name='schema-movie'),
path('api/schema/movie/swagger/', SpectacularSwaggerView.as_view(url_name='schema-movie'), name='swagger'),
path('api/schema/movie/redoc/', SpectacularRedocView.as_view(url_name='schema-movie'), name='redoc'),
] + debug_toolbar_urls()
debug_toolbar_urls는 drf-spectacular와는 관계없고, Django Debug Tool의 toolbar 모듈에 있는 함수이다.SpectacularAPIView의 as_view 메소드를 호출할 때, urlconf 인자에 urls 파일의 경로를 문자열로 지정한다.api/schema/는 프로젝트 루트의 url 패턴들을 기준으로 하므로 모든 api 엔드포인트를 보여주게 되고, api/schema/movie/는 movies.urls를 기준으로 하므로 movies의 url 패턴들에 있는 엔드포인트들만을 보여주게 된다.
실제로 비교해보면, 문서 내에서 나타나는 엔드포인트가 서로 다른 것을 확인할 수 있다.
위 두 방법에서 SERVE_URLCONF나 urlconf는 Optional[str]이기 때문에, 문자열을 사용하는 것이 원래 의도로 보였고, 그런 방향으로 서술했다. 하지만, 스택 오버플로우에서 문자열이 아니라, 문자열의 리스트를 사용하는 예제를 보아서 이것에 대해 알아보았다.
class SpectacularAPIView(APIView):
# ...
@extend_schema(**SCHEMA_KWARGS)
def get(self, request, *args, **kwargs):
# special handling of custom urlconf parameter
if isinstance(self.urlconf, list) or isinstance(self.urlconf, tuple):
ModuleWrapper = namedtuple('ModuleWrapper', ['urlpatterns'])
if all(isinstance(i, str) for i in self.urlconf):
# list of import string for urlconf
patterns = []
for item in self.urlconf:
url = import_module(item)
patterns += url.urlpatterns
self.urlconf = ModuleWrapper(tuple(patterns))
else:
# explicitly resolved urlconf
self.urlconf = ModuleWrapper(tuple(self.urlconf))
# ...
이것은 SpectacularAPIView의 get 메소드의 소스 코드로 여기에서 urlconf 가 리스트거나 튜플일 때에 대한 처리를 하고 있는 것을 볼 수 있다. 이 상황에서 리스트나 튜플의 각 요소인 경로 문자열에 대해 import_module을 실행하여 해당 모듈에 있는 urlpatterns를 패턴에 추가하고 있다. 따라서, 경로 문자열들로 이루어진 리스트나 튜플을 넣어도, 문제가 생기지 않을 것으로 보인다.
urlpatterns = [
# ...
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/schema/movie/', SpectacularAPIView.as_view(urlconf=['movies.urls']), name='schema-movie'),
path('api/schema/movie/swagger/', SpectacularSwaggerView.as_view(url_name='schema-movie'), name='swagger'),
path('api/schema/movie/redoc/', SpectacularRedocView.as_view(url_name='schema-movie'), name='redoc'),
] + debug_toolbar_urls()
실제로 두번째 방법 (urlconf)에서의 urls.py를 수정한 코드이다. 정상 작동하는 것을 확인하였다. 이 방법을 이용하면, 특정 앱들의 urls.py에 있는 엔드포인트들만 스키마 문서에 나타나게 처리할 수 있다.
추가로, drf-spectacular의 깃헙 PR 목록에 Include a list of application urls for SERVE_URLCONF라는 PR이 있었다.

해당 PR의 커밋으로, 위에서 살펴본 코드와 전체적인 로직이 비슷하다. 이 PR이 실제로 받아들여져 Merged 처리가 된 것을 보아, 이때부터 SERVE_URLCONF나 urlconf에 리스트와 튜플을 사용할 수 있게 된 것으로 보인다.
SpectacularAPIView에서 사용할 수 있는 인자들class SpectacularAPIView(APIView):
__doc__ = _("""
OpenApi3 schema for this API. Format can be selected via content negotiation.
- YAML: application/vnd.oai.openapi
- JSON: application/vnd.oai.openapi+json
""") # type: ignore
renderer_classes = [
OpenApiYamlRenderer, OpenApiYamlRenderer2, OpenApiJsonRenderer, OpenApiJsonRenderer2
]
permission_classes = spectacular_settings.SERVE_PERMISSIONS
authentication_classes = AUTHENTICATION_CLASSES
generator_class: Type[SchemaGenerator] = spectacular_settings.DEFAULT_GENERATOR_CLASS
serve_public: bool = spectacular_settings.SERVE_PUBLIC
urlconf: Optional[str] = spectacular_settings.SERVE_URLCONF
api_version: Optional[str] = None
custom_settings: Optional[Dict[str, Any]] = None
patterns: Optional[List[Any]] = None
# ...
SpectacularAPIView의 맨 앞부분이다. 여기에 있는 속성들을 인자로 사용할 수 있는 것으로 보인다. 예를 들어 api_version 인자를 이용해 보겠다.
# ...
urlpatterns = [
# ...
path('api/schema/movie/', SpectacularAPIView.as_view(urlconf='movies.urls', api_version='test'), name='schema-movie'),
path('api/schema/movie/swagger/', SpectacularSwaggerView.as_view(url_name='schema-movie'), name='swagger'),
path('api/schema/movie/redoc/', SpectacularRedocView.as_view(url_name='schema-movie'), name='redoc'),
] + debug_toolbar_urls()
예상대로라면, api 버전이 test로 나타날 것이다.

실제로 이렇게 괄호에 test라고 적혀서 나타나는 것을 볼 수 있다. 만약, dev를 바꾸고 싶다면, drf-spectacular의 설정에서 변경해야 한다.
이렇게 drf-spectacular 실습을 마무리해보았다. 사실, 아직 더 커스터마이징하고 싶은 부분도 있고, 예전에 만들어 놓았던 API 명세서 파일과 내용이 완전히 같지는 않기도 하다. 하지만 이러한 부분들에 대해 검색해 보았을 때 좋은 답이 나오질 않아서 타협하는 수밖에 없었다. 아마, DRF의 스키마의 한계이자, 이 패키지의 한계인 것 같기도 하다. 아니면 내가 아직 완전한 사용법을 몰라서일 수도 있다.
어쨌든, 이렇게 drf-spectacular를 이용해 스키마를 생성해 보니, 확실히 직접 yaml을 작성하는 것에 비해 효율적임을 느낄 수 있었다. 또한, 스키마 생성에 걸리는 시간도 많이 줄여줘서, API 문서 생성에 큰 시간을 들이지 않아도 된다는 점은 매우 매력적으로 느껴졌다. 커스터마이징을 지원하여 마음에 들지 않는 부분이나 추가하고 싶은 부분은 더 추가할 수 있는 것도 플러스 요인이었다.
다만 앞서 살펴본 것처럼, 완벽하게 커스터마이징이 되는 것은 아니기에, 스키마에서 부족한 부분은 따로 보충 설명을 해주는 등의 필요가 있을 것 같다.
이제 사용법도 공부하고, 실습도 해보았으니 다음 프로젝트부터는 drf-spectacular를 도입하여 생산성을 향상시키고 체계적인 API 문서를 만들어 봐야겠다.