장고 실전 #1 DRF 시작 — Serializer, ViewSet, Router

8 분 소요

장고 기초중급고급 21편의 도구가 한곳에 모이는 시리즈입니다. 풀스택 Django가 템플릿/폼/세션을 그대로 쓴다면, **DRF (Django REST Framework)**는 그 같은 ORM,인증 기반 위에 REST API 레이어를 깔끔하게 얹어줍니다.

  • #1 DRF 시작 — Serializer, ViewSet, Router ← 이번 글
  • #2 인증/권한 — Token, JWT, custom permission
  • #3 Filtering / Ordering / Pagination
  • #4 Celery로 비동기 작업
  • #5 OpenAPI 문서 (drf-spectacular)
  • #6 테스트와 배포 — Docker, gunicorn, nginx

만드는 것은 작은 블로그 APIPost, Comment, Author도메인. 글 CRUD, 댓글, 인증, 검색/페이지네이션, 무거운 작업은 Celery, 스키마 자동 문서, 마지막에 컨테이너로 배포까지 정리합니다. 매 글이 그 위에 한 단계씩 쌓아 올립니다.

DRF의 위치 — 풀스택 Django와 어떻게 다른가 #

기초 #4 ~ #6에서 본 풀스택 Django는 템플릿 + 폼 + 서버 렌더링 흐름이었습니다. 사용자가 브라우저에서 양식을 제출하면 view가 HttpResponse(render(...))로 HTML을 돌려주는 구조였습니다.

요즘 SPA(React/Vue/Next) + 모바일 앱 조합에서는 서버가 JSON만 돌려주고, 화면은 클라이언트가 그립니다. 이 자리에 들어오는 게 DRF입니다.

풀스택 DjangoDjango + DRF
응답HTML (template)JSON
입력Form / ModelFormSerializer
인증session + cookieToken / JWT (+ session)
클라이언트브라우저 (서버 렌더링)SPA / 모바일 / 외부
자동 문서별도drf-spectacular (#5)
학습 곡선평탄평탄 + DRF 추가 개념

핵심은 Django ORM, auth, admin, signals 같은 기반은 그대로 가져온다는 점입니다. DRF는 그 위에 입력/출력을 JSON으로 바꾸는 얇은 레이어입니다.

FastAPI와 비교 #

같은 용도(JSON API)에 FastAPI도 있습니다. 비교 표는 이 정도.

Django + DRFFastAPI
스타일풀스택 위에 API 레이어마이크로 + 타입
ORMDjango ORM (성숙)외부 (SQLAlchemy 등)
인증/권한빌트인 (auth, permissions)직접 조립
Admin빌트인 강력없음 (외부)
마이그레이션makemigrations/migrateAlembic (외부)
자동 문서drf-spectacular (별도)빌트인
비동기부분 (4.0+)네이티브
데이터 검증SerializerPydantic
어울리는 경우운영 중심, admin/사용자/권한이 많은 서비스마이크로서비스, 데이터/ML API

이미 Django로 만든 모놀리식 서비스라면 DRF가 자연스럽고, 처음부터 작은 비동기 API만 있으면 FastAPI가 가볍습니다. 이 시리즈는 DRF 쪽을 깊게 봅니다.

프로젝트 셋업 #

기초 #2의 셋업 흐름 위에서 DRF를 추가합니다.

프로젝트 만들기
uv init blog-api --python 3.13
cd blog-api
uv add django djangorestframework
uv run django-admin startproject mysite .
uv run python manage.py startapp blog

INSTALLED_APPS 등록 #

mysite/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",          # DRF 본체
    "blog",                    # 우리 앱
]

DRF는 단순히 앱 하나로 추가됩니다. 별도 미들웨어 변경 없이 바로 동작.

도메인 모델 #

blog/models.py
from django.conf import settings
from django.db import models


class Post(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts",
    )
    title = models.CharField(max_length=200)
    body = models.TextField()
    published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]


class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="comments",
    )
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

기초 #3에서 본 Model 정의 그대로입니다. DRF는 Django 모델을 그대로 쓰는 게 핵심.

마이그레이션
uv run python manage.py makemigrations
uv run python manage.py migrate
uv run python manage.py createsuperuser

Serializer — DRF의 입력/출력 스키마 #

Serializer는 Python 객체 ↔ JSON 변환 + 검증을 담당합니다. Django의 Form/ModelForm과 구조가 비슷한데, HTML이 아닌 JSON을 다룬다는 차이가 핵심.

가장 기본 — serializers.Serializer #

blog/serializers.py — 기본형
from rest_framework import serializers


class PostSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    title = serializers.CharField(max_length=200)
    body = serializers.CharField()
    published = serializers.BooleanField(default=False)
    created_at = serializers.DateTimeField(read_only=True)

    def create(self, validated_data):
        return Post.objects.create(**validated_data)

    def update(self, instance, validated_data):
        for k, v in validated_data.items():
            setattr(instance, k, v)
        instance.save()
        return instance

필드 하나하나를 직접 적는 구조입니다. read_only=True 인 필드는 출력에만 들어갑니다 (서버가 만드는 id, created_at).

ModelSerializer — 모델에서 자동 추론 #

대부분은 모델을 그대로 직렬화합니다. ModelSerializer 한 줄이면 끝.

blog/serializers.py — ModelSerializer
from rest_framework import serializers
from .models import Post, Comment


class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ["id", "author", "title", "body", "published", "created_at", "updated_at"]
        read_only_fields = ["id", "author", "created_at", "updated_at"]


class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ["id", "post", "author", "body", "created_at"]
        read_only_fields = ["id", "author", "created_at"]

ModelSerializer가 자동으로 해주는 것:

  • 모델 필드에서 serializer 필드 추론 (CharFieldserializers.CharField)
  • create(), update() 기본 구현 제공
  • 모델의 unique=True, max_length 등 검증 자동 반영

fields = "__all__"로 모든 필드를 노출할 수도 있지만, 명시적으로 적는 게 안전합니다 — 새 필드가 모델에 추가되면 의도치 않게 노출될 수 있습니다.

read_only_fields의 의미 #

author를 read-only로 둔 이유: 클라이언트가 다른 사람의 글로 위조하지 못하게 막기 위함입니다. ViewSet의 perform_create에서 request.user를 author로 넣어 주는 패턴을 #2에서 봅니다.

검증 — validate_<field>, validate, custom validator #

Serializer에 검증을 얹는 지점은 세 군데입니다.

1) 필드 단위 — validate_<field> #

필드 단위 검증
class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ["id", "title", "body", "published"]

    def validate_title(self, value: str) -> str:
        if value.strip().lower() in {"untitled", "test"}:
            raise serializers.ValidationError("의미 없는 제목 금지")
        return value.strip()

validate_<필드명> 메소드는 그 필드 값만 받아서 검증합니다.

2) 모델 단위 — validate #

여러 필드를 동시에
def validate(self, attrs: dict) -> dict:
    if attrs.get("published") and len(attrs.get("body", "")) < 100:
        raise serializers.ValidationError(
            {"body": "공개 글은 최소 100자 이상이어야 합니다."}
        )
    return attrs

여러 필드를 같이 검사할 때. attrs가 모든 검증된 값의 dict.

3) 재사용 가능한 validator #

블로그 전체에서 재사용
def no_html_tags(value: str) -> None:
    if "<script" in value.lower():
        raise serializers.ValidationError("script 태그는 허용되지 않음")

class PostSerializer(serializers.ModelSerializer):
    body = serializers.CharField(validators=[no_html_tags])
    ...

함수형 validator는 여러 serializer에서 재사용하기 좋습니다.

검증 실패 시 자동으로 400 Bad Request + 어디가 문제인지 JSON으로 응답:

검증 실패 응답 예
{
  "title": ["의미 없는 제목 금지"],
  "body": ["공개 글은 최소 100자 이상이어야 합니다."]
}

View의 계층 — APIView → Generic → ViewSet #

DRF의 view는 계층 입니다. 아래로 갈수록 짧고, 위로 갈수록 자유롭습니다.

계층
APIView         — 가장 원시적, get/post/put 직접 구현
GenericAPIView  — queryset / serializer_class 같은 공통 속성
  ↓ (+ mixins)
ListAPIView, RetrieveAPIView, CreateAPIView, ...
  ↓ (+ Router)
ViewSet → ModelViewSet  — CRUD 한 줄

1) APIView — 가장 원시적 #

blog/views.py — APIView
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status


class PostListAPIView(APIView):
    def get(self, request):
        posts = Post.objects.all()
        serializer = PostSerializer(posts, many=True)
        return Response(serializer.data)

    def post(self, request):
        serializer = PostSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save(author=request.user)
        return Response(serializer.data, status=status.HTTP_201_CREATED)

Django의 CBV와 닮은 구조 — get(), post() 메소드를 직접 구현. 가장 명시적이지만 같은 코드가 반복됩니다.

2) GenericAPIView + mixins — 공통 패턴 추출 #

generic + mixins
from rest_framework import generics, mixins


class PostListCreateAPIView(
    mixins.ListModelMixin,
    mixins.CreateModelMixin,
    generics.GenericAPIView,
):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

queryset + serializer_class 두 줄이 핵심. mixins가 list(), create() 같은 메소드를 제공.

3) Generic 단축형 — ListCreateAPIView#

더 짧게
from rest_framework import generics


class PostListCreateAPIView(generics.ListCreateAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer


class PostDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

DRF가 자주 쓰는 조합을 미리 만들어 둔 클래스들.

클래스메소드
ListAPIViewGET (list)
CreateAPIViewPOST
ListCreateAPIViewGET + POST
RetrieveAPIViewGET (detail)
UpdateAPIViewPUT/PATCH
DestroyAPIViewDELETE
RetrieveUpdateDestroyAPIViewGET + PUT/PATCH + DELETE

목록과 상세를 두 클래스로 나눠 쓰고, URL도 둘로 분리합니다.

ViewSet — 한 클래스로 CRUD 전체 #

ViewSet한 클래스에 list/retrieve/create/update/destroy를 다 묶고, Router가 URL을 자동으로 만들어 줍니다. DRF의 표준 패턴.

blog/views.py — ModelViewSet
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

이 4 줄이 다음을 다 만듭니다:

메소드메소드 (HTTP)URL동작
listGET/posts/목록
retrieveGET/posts/{id}/상세
createPOST/posts/생성
updatePUT/posts/{id}/전체 수정
partial_updatePATCH/posts/{id}/부분 수정
destroyDELETE/posts/{id}/삭제

perform_create()save 시점에 추가 데이터를 넣을 때 쓰는 훅입니다. 위에서는 author를 현재 요청 사용자로 넣어 줍니다.

그 외 자주 쓰는 훅 #

ViewSet 훅들
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def get_queryset(self):
        # 동적으로 queryset 결정 — 예: 자기 글만
        qs = super().get_queryset()
        if self.action == "list" and self.request.user.is_authenticated:
            return qs.filter(author=self.request.user)
        return qs.filter(published=True)

    def get_serializer_class(self):
        # 액션마다 다른 serializer
        if self.action == "list":
            return PostListSerializer
        return PostSerializer
  • get_queryset() — 액션/사용자/권한별로 동적 queryset
  • get_serializer_class() — list는 가벼운 직렬화, retrieve/create는 자세한 직렬화 같은 분리

@action — 추가 엔드포인트 #

표준 CRUD 외에 액션을 더하고 싶을 때.

@action 데코레이터
from rest_framework.decorators import action
from rest_framework.response import Response


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        """POST /posts/{id}/publish/ — 글 공개 처리."""
        post = self.get_object()
        post.published = True
        post.save()
        return Response({"published": True})

    @action(detail=False, methods=["get"])
    def drafts(self, request):
        """GET /posts/drafts/ — 내 초안 목록."""
        qs = self.get_queryset().filter(author=request.user, published=False)
        serializer = self.get_serializer(qs, many=True)
        return Response(serializer.data)
  • detail=True — 인스턴스 단위 (/posts/{id}/publish/)
  • detail=False — 컬렉션 단위 (/posts/drafts/)

Router — URL 자동 생성 #

ViewSet은 그 자체로 URL에 등록할 수 없습니다 — 어떤 메소드를 어떤 HTTP 동사에 매핑할지 알려줘야 합니다. Router가 그걸 자동으로 합니다.

blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import PostViewSet, CommentViewSet

router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")
router.register(r"comments", CommentViewSet, basename="comment")

urlpatterns = router.urls
mysite/urls.py
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("blog.urls")),
]

이게 끝입니다. DefaultRouter가 만들어주는 URL:

자동 생성된 URL
GET    /api/posts/                  → list
POST   /api/posts/                  → create
GET    /api/posts/{pk}/             → retrieve
PUT    /api/posts/{pk}/             → update
PATCH  /api/posts/{pk}/             → partial_update
DELETE /api/posts/{pk}/             → destroy
POST   /api/posts/{pk}/publish/     → @action(detail=True) publish
GET    /api/posts/drafts/           → @action(detail=False) drafts
GET    /api/                        → API root (DefaultRouter만)

DefaultRouter/api/ 루트에 API 인덱스도 자동으로 만들어 줍니다. SimpleRouter는 인덱스 없이 라우트만.

Browsable API — DRF의 비밀 무기 #

브라우저에서 /api/posts/를 그냥 열어 보세요. DRF가 HTML 인터페이스를 자동으로 만들어 줍니다 — 폼으로 POST를 보내고, 응답을 JSON으로 보고, 인증 상태를 토글하는 UI까지 정리합니다.

API 개발 초기에 매우 유용합니다. 프로덕션에서 끄고 싶으면 DEFAULT_RENDERER_CLASSES에서 BrowsableAPIRenderer를 빼면 됩니다.

settings.py — 프로덕션은 JSON만
REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
        # "rest_framework.renderers.BrowsableAPIRenderer",   # 개발에서만
    ],
}

첫 결과물 — 한 화면에 #

blog/serializers.py
from rest_framework import serializers
from .models import Post, Comment


class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ["id", "author", "title", "body", "published", "created_at", "updated_at"]
        read_only_fields = ["id", "author", "created_at", "updated_at"]


class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ["id", "post", "author", "body", "created_at"]
        read_only_fields = ["id", "author", "created_at"]
blog/views.py
from rest_framework import viewsets
from .models import Post, Comment
from .serializers import PostSerializer, CommentSerializer


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)


class CommentViewSet(viewsets.ModelViewSet):
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import PostViewSet, CommentViewSet

router = DefaultRouter()
router.register(r"posts", PostViewSet)
router.register(r"comments", CommentViewSet)

urlpatterns = router.urls
실행
uv run python manage.py runserver
# → http://127.0.0.1:8000/api/

브라우저로 /api/posts/를 열면 Browsable API가 나오고, 폼으로 글을 만들 수 있습니다. 다음 글들이 그 위에 인증/필터링/비동기/문서/배포를 한 단계씩 쌓아 올립니다.

정리 #

이번 글에서 잡은 것:

  • DRF의 위치 — Django 위의 JSON API 레이어 (ORM/auth/admin 그대로)
  • Django + DRF vs FastAPI 비교 — 어울리는 용도가 다름
  • INSTALLED_APPSrest_framework 한 줄 추가
  • Serializer — Python ↔ JSON + 검증
    • serializers.Serializer (수동), ModelSerializer (자동)
    • read_only_fields로 서버 결정 필드 보호
  • 검증의 세 지점 — validate_<field>, validate, 함수형 validator
  • View 계층APIViewGenericAPIView + mixins → 단축형 → ViewSet
  • ModelViewSet — 한 클래스로 CRUD, perform_create로 author 적기
  • @action으로 추가 엔드포인트 (publish, drafts)
  • RouterDefaultRouter().register()로 URL 자동 생성
  • Browsable API — 브라우저에서 바로 테스트 가능한 HTML 인터페이스

회귀 링크: 기초 #3 Models와 ORM의 모델 정의가 그대로 들어왔고, 기초 #7 Admin과 인증User 모델이 author ForeignKey로 연결됐습니다.

다음 글(#2 인증/권한)에서는 누가 그 글을 쓰고 수정할 수 있는지 — Token/JWT 인증, IsAuthenticated/IsOwner 같은 permission 클래스를 다룹니다.

X