Django 5: Django REST Framework로 REST API 구축하기

Django 5와 DRF를 활용한 본격적인 REST API 구축 가이드. 시리얼라이저, ViewSet, JWT 인증, 모범 사례를 실전 중심으로 설명합니다.

Django 5와 Django REST Framework를 활용한 REST API 구축 가이드

Django REST Framework(DRF)는 Python으로 REST API를 구축하기 위한 표준 프레임워크로서 확고한 위치를 유지하고 있습니다. Django 5와 결합하면 강력한 시리얼라이저, 자동화된 ViewSet, 유연한 인증 시스템을 통해 뛰어난 개발 경험을 제공합니다. 본 가이드에서는 설치부터 테스트까지 프로덕션 수준의 API 구축 전 과정을 다룹니다.

Django 5 + DRF 3.15

Django REST Framework 3.15는 Django 5 완전 지원, 상당한 성능 개선, Python 네이티브 타입과의 향상된 통합을 제공합니다. 이 조합은 프로덕션 Python API에서 가장 선호되는 구성입니다.

프로젝트 설치 및 설정

Django 프로젝트에 DRF를 도입하려면 몇 가지 설정 단계가 필요합니다. 가상 환경을 사용하고 명확한 프로젝트 구조를 갖추면 장기적인 유지보수가 용이해집니다.

bash
# terminal
# Create virtual environment and install dependencies
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate   # Windows

# Install Django 5 and DRF
pip install django djangorestframework
pip install django-filter  # Advanced filtering
pip install djangorestframework-simplejwt  # JWT authentication

# Create Django project
django-admin startproject config .
python manage.py startapp api

위 명령어를 실행하면 REST 엔드포인트를 위한 전용 api 애플리케이션이 포함된 Django 프로젝트가 생성됩니다.

python
# config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Third-party apps
    'rest_framework',
    'rest_framework_simplejwt',
    'django_filters',
    # Local apps
    'api',
]

REST_FRAMEWORK = {
    # Default authentication classes
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    # Default permissions: authentication required
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    # Global pagination
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    # Filter backends
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}

이 설정을 통해 API 전체에 JWT 인증, 기본 페이지네이션, 필터 백엔드가 적용됩니다.

데이터 모델 생성

Django 모델은 API의 데이터 구조를 정의합니다. 체계적인 모델 설계는 시리얼라이저와 뷰 생성을 크게 단순화합니다.

python
# api/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator, MaxValueValidator
import uuid

class User(AbstractUser):
    """Custom user model with additional fields."""
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    bio = models.TextField(blank=True, max_length=500)
    avatar = models.URLField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.username


class Category(models.Model):
    """Category for organizing articles."""
    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(max_length=100, unique=True)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = 'categories'
        ordering = ['name']

    def __str__(self):
        return self.name


class Article(models.Model):
    """Blog article with author and category relations."""
    STATUS_CHOICES = [
        ('draft', 'Draft'),
        ('published', 'Published'),
        ('archived', 'Archived'),
    ]

    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    content = models.TextField()
    excerpt = models.TextField(max_length=300, blank=True)
    # Author relation
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='articles'
    )
    # Category relation
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        related_name='articles'
    )
    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default='draft'
    )
    views_count = models.PositiveIntegerField(default=0)
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title

UUID를 기본 키로 사용하면 보안(예측 불가능한 식별자)이 향상되고 데이터 분산 처리도 용이해집니다.

커스텀 User 모델

프로젝트 시작 시 추가 필드가 없더라도 반드시 커스텀 User 모델을 정의해야 합니다. 초기 마이그레이션 이후에 User 모델을 변경하는 것은 복잡하고 오류가 발생하기 쉬운 작업입니다.

시리얼라이저: 데이터 변환과 유효성 검사

시리얼라이저는 DRF의 핵심 구성 요소입니다. Python 객체를 JSON으로 변환하고(역변환도 포함), 수신 데이터의 유효성 검사를 수행합니다.

python
# api/serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from .models import Article, Category

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    """Serializer for reading user data."""
    # Computed field: number of published articles
    articles_count = serializers.SerializerMethodField()

    class Meta:
        model = User
        fields = [
            'id', 'username', 'email', 'bio',
            'avatar', 'articles_count', 'created_at'
        ]
        # Read-only fields
        read_only_fields = ['id', 'created_at']

    def get_articles_count(self, obj):
        """Count user's published articles."""
        return obj.articles.filter(status='published').count()


class UserCreateSerializer(serializers.ModelSerializer):
    """Serializer for user creation with password validation."""
    password = serializers.CharField(
        write_only=True,
        required=True,
        validators=[validate_password],
        style={'input_type': 'password'}
    )
    password_confirm = serializers.CharField(
        write_only=True,
        required=True,
        style={'input_type': 'password'}
    )

    class Meta:
        model = User
        fields = [
            'id', 'username', 'email', 'password',
            'password_confirm', 'bio', 'avatar'
        ]

    def validate(self, attrs):
        """Verify that both passwords match."""
        if attrs['password'] != attrs['password_confirm']:
            raise serializers.ValidationError({
                'password_confirm': 'Passwords do not match.'
            })
        return attrs

    def create(self, validated_data):
        """Create user with hashed password."""
        # Remove confirmation field
        validated_data.pop('password_confirm')
        # Use create_user to hash the password
        user = User.objects.create_user(**validated_data)
        return user


class CategorySerializer(serializers.ModelSerializer):
    """Serializer for categories with article counter."""
    articles_count = serializers.IntegerField(
        source='articles.count',
        read_only=True
    )

    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'description', 'articles_count']


class ArticleListSerializer(serializers.ModelSerializer):
    """Lightweight serializer for article listings."""
    # Display username instead of UUID
    author = serializers.StringRelatedField()
    category = serializers.StringRelatedField()

    class Meta:
        model = Article
        fields = [
            'id', 'title', 'slug', 'excerpt',
            'author', 'category', 'status',
            'views_count', 'published_at', 'created_at'
        ]


class ArticleDetailSerializer(serializers.ModelSerializer):
    """Complete serializer for article details."""
    # Include full author data
    author = UserSerializer(read_only=True)
    category = CategorySerializer(read_only=True)
    # Write fields (accepts ID)
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        source='category',
        write_only=True,
        required=False
    )

    class Meta:
        model = Article
        fields = [
            'id', 'title', 'slug', 'content', 'excerpt',
            'author', 'category', 'category_id',
            'status', 'views_count',
            'published_at', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'author', 'views_count', 'created_at', 'updated_at']

    def create(self, validated_data):
        """Automatically assign the author to the logged-in user."""
        validated_data['author'] = self.context['request'].user
        return super().create(validated_data)

ArticleListSerializer(경량 버전)와 ArticleDetailSerializer(완전 버전)를 분리하면 목록 조회 시 불필요한 데이터 로딩을 방지하여 성능이 최적화됩니다.

Django 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

ViewSet과 자동 라우팅

ViewSet은 CRUD 작업을 하나의 클래스로 통합합니다. 라우터와 결합하면 API URL이 자동으로 생성됩니다.

python
# api/views.py
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from .models import Article, Category
from .serializers import (
    UserSerializer, UserCreateSerializer,
    ArticleListSerializer, ArticleDetailSerializer,
    CategorySerializer
)
from .permissions import IsAuthorOrReadOnly
from .filters import ArticleFilter

User = get_user_model()


class UserViewSet(viewsets.ModelViewSet):
    """
    ViewSet for user management.

    Generated endpoints:
    - GET /users/ : user list
    - POST /users/ : creation (registration)
    - GET /users/{id}/ : detail
    - PUT/PATCH /users/{id}/ : update
    - DELETE /users/{id}/ : delete
    - GET /users/me/ : logged-in user profile
    """
    queryset = User.objects.all()
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['username', 'email']
    ordering_fields = ['created_at', 'username']

    def get_serializer_class(self):
        """Use different serializer for creation."""
        if self.action == 'create':
            return UserCreateSerializer
        return UserSerializer

    def get_permissions(self):
        """Dynamic permissions based on action."""
        if self.action == 'create':
            # Open registration
            return [AllowAny()]
        if self.action in ['update', 'partial_update', 'destroy']:
            # Modification: owner or admin
            return [IsAuthenticated()]
        return [IsAuthenticated()]

    @action(detail=False, methods=['get'])
    def me(self, request):
        """Return the logged-in user's profile."""
        serializer = self.get_serializer(request.user)
        return Response(serializer.data)

    @action(detail=False, methods=['patch'])
    def update_profile(self, request):
        """Update the logged-in user's profile."""
        serializer = self.get_serializer(
            request.user,
            data=request.data,
            partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)


class CategoryViewSet(viewsets.ModelViewSet):
    """
    ViewSet for category management.
    Only admins can create/update/delete.
    """
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    lookup_field = 'slug'
    filter_backends = [filters.SearchFilter]
    search_fields = ['name', 'description']

    def get_permissions(self):
        """Public read, admin-only write."""
        if self.action in ['list', 'retrieve']:
            return [AllowAny()]
        return [IsAdminUser()]


class ArticleViewSet(viewsets.ModelViewSet):
    """
    ViewSet for article management.

    Features:
    - Filter by category, status, author
    - Text search
    - Sort by date, views
    - Custom actions (publish, archive)
    """
    queryset = Article.objects.select_related('author', 'category')
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_class = ArticleFilter
    search_fields = ['title', 'content', 'excerpt']
    ordering_fields = ['created_at', 'published_at', 'views_count']
    ordering = ['-created_at']
    lookup_field = 'slug'

    def get_serializer_class(self):
        """Lightweight serializer for lists, complete for detail."""
        if self.action == 'list':
            return ArticleListSerializer
        return ArticleDetailSerializer

    def get_permissions(self):
        """Permissions based on action."""
        if self.action in ['list', 'retrieve']:
            return [AllowAny()]
        if self.action == 'create':
            return [IsAuthenticated()]
        # Update/delete: author or admin
        return [IsAuthorOrReadOnly()]

    def get_queryset(self):
        """Filter articles based on user."""
        queryset = super().get_queryset()
        user = self.request.user

        # Unauthenticated users: published articles only
        if not user.is_authenticated:
            return queryset.filter(status='published')

        # Admins: all articles
        if user.is_staff:
            return queryset

        # Authenticated users: published + their own articles
        from django.db.models import Q
        return queryset.filter(
            Q(status='published') | Q(author=user)
        )

    def retrieve(self, request, *args, **kwargs):
        """Increment view counter on each retrieval."""
        instance = self.get_object()
        instance.views_count += 1
        instance.save(update_fields=['views_count'])
        serializer = self.get_serializer(instance)
        return Response(serializer.data)

    @action(detail=True, methods=['post'])
    def publish(self, request, slug=None):
        """Publish a draft article."""
        article = self.get_object()

        if article.status == 'published':
            return Response(
                {'error': 'Article already published.'},
                status=status.HTTP_400_BAD_REQUEST
            )

        article.status = 'published'
        article.published_at = timezone.now()
        article.save()

        serializer = self.get_serializer(article)
        return Response(serializer.data)

    @action(detail=True, methods=['post'])
    def archive(self, request, slug=None):
        """Archive a published article."""
        article = self.get_object()
        article.status = 'archived'
        article.save()

        serializer = self.get_serializer(article)
        return Response(serializer.data)

커스텀 액션(@action)을 사용하면 새로운 뷰를 생성하지 않고도 /articles/{slug}/publish/와 같은 전용 엔드포인트를 추가할 수 있습니다.

커스텀 퍼미션

퍼미션은 리소스에 대한 접근을 제어합니다. DRF에서는 복잡한 비즈니스 규칙에 대응하는 재사용 가능한 퍼미션 클래스를 생성할 수 있습니다.

python
# api/permissions.py
from rest_framework import permissions


class IsAuthorOrReadOnly(permissions.BasePermission):
    """
    Custom permission:
    - Read: everyone
    - Write: object author or admin only
    """

    def has_object_permission(self, request, view, obj):
        # GET, HEAD, OPTIONS methods are always allowed
        if request.method in permissions.SAFE_METHODS:
            return True

        # Write allowed only for author or admins
        return obj.author == request.user or request.user.is_staff


class IsOwnerOrAdmin(permissions.BasePermission):
    """
    Permission for user resources:
    - Users can access their own resources
    - Admins can access everything
    """

    def has_object_permission(self, request, view, obj):
        # Check if object is the user themselves
        if hasattr(obj, 'id') and obj.id == request.user.id:
            return True

        # Check if object belongs to the user
        if hasattr(obj, 'user') and obj.user == request.user:
            return True

        # Admins have access to everything
        return request.user.is_staff

이러한 퍼미션은 객체 수준(has_object_permission)에서 적용되어 각 리소스에 대한 세밀한 접근 제어를 구현합니다.

django-filter를 활용한 커스텀 필터

필터를 통해 API 클라이언트는 다양한 조건으로 데이터를 검색하고 필터링할 수 있습니다.

python
# api/filters.py
import django_filters
from .models import Article


class ArticleFilter(django_filters.FilterSet):
    """
    Custom filters for articles.

    Usage examples:
    - /articles/?category=tech
    - /articles/?status=published
    - /articles/?author=username
    - /articles/?created_after=2026-01-01
    - /articles/?min_views=100
    """
    # Filter by category slug
    category = django_filters.CharFilter(
        field_name='category__slug',
        lookup_expr='exact'
    )

    # Filter by author username
    author = django_filters.CharFilter(
        field_name='author__username',
        lookup_expr='exact'
    )

    # Filter by creation date (after)
    created_after = django_filters.DateFilter(
        field_name='created_at',
        lookup_expr='gte'
    )

    # Filter by creation date (before)
    created_before = django_filters.DateFilter(
        field_name='created_at',
        lookup_expr='lte'
    )

    # Filter by minimum views
    min_views = django_filters.NumberFilter(
        field_name='views_count',
        lookup_expr='gte'
    )

    # Filter by title (contains)
    title = django_filters.CharFilter(
        field_name='title',
        lookup_expr='icontains'
    )

    class Meta:
        model = Article
        fields = ['status', 'category', 'author']

이러한 필터는 DRF의 브라우저블 인터페이스에서 자동으로 문서화되어 표시됩니다.

필터 성능

icontains를 사용한 텍스트 필드 필터는 대규모 테이블에서 속도가 저하될 수 있습니다. 전문 검색이 필요한 경우 PostgreSQL의 SearchVector 또는 Elasticsearch 도입을 고려하십시오.

URL 설정과 라우터

DRF 라우터는 등록된 ViewSet으로부터 RESTful URL을 자동 생성합니다.

python
# api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)
from .views import UserViewSet, CategoryViewSet, ArticleViewSet

# Create router with automatic URL generation
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'categories', CategoryViewSet, basename='category')
router.register(r'articles', ArticleViewSet, basename='article')

urlpatterns = [
    # Router-generated URLs
    path('', include(router.urls)),

    # JWT authentication endpoints
    path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('auth/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]
python
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    # All API URLs under /api/
    path('api/', include('api.urls')),
]

이 설정으로 다음 엔드포인트가 노출됩니다:

  • POST /api/auth/token/ : JWT 토큰 발급
  • POST /api/auth/token/refresh/ : 토큰 갱신
  • GET/POST /api/users/ : 사용자 목록 및 생성
  • GET/PUT/PATCH/DELETE /api/users/{id}/ : 사용자 관련 작업
  • 카테고리와 게시글에 대해서도 동일한 엔드포인트가 생성됩니다.

JWT 인증 상세 설정

JWT 인증에는 보안과 사용자 경험에 맞춘 세부 설정이 필요합니다.

python
# config/settings.py
from datetime import timedelta

SIMPLE_JWT = {
    # Access token lifetime
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    # Refresh token lifetime
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    # Automatic refresh token rotation
    'ROTATE_REFRESH_TOKENS': True,
    # Blacklist old tokens after rotation
    'BLACKLIST_AFTER_ROTATION': True,
    # Signing algorithm
    'ALGORITHM': 'HS256',
    # Signing key (use secret key in production)
    'SIGNING_KEY': SECRET_KEY,
    # Authorization header prefix
    'AUTH_HEADER_TYPES': ('Bearer',),
    # Fields included in token
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
}

토큰 로테이션(ROTATE_REFRESH_TOKENS)을 활성화하면 갱신할 때마다 이전 토큰이 무효화되어 보안이 강화됩니다.

API 자동 테스트

DRF는 API 동작을 검증하기 위한 테스트 도구를 기본으로 제공합니다.

python
# api/tests/test_articles.py
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from api.models import Article, Category

User = get_user_model()


class ArticleAPITestCase(TestCase):
    """Tests for article endpoints."""

    def setUp(self):
        """Set up test data."""
        self.client = APIClient()

        # Create test user
        self.user = User.objects.create_user(
            username='testuser',
            email='test@example.com',
            password='testpass123'
        )

        # Create category
        self.category = Category.objects.create(
            name='Tech',
            slug='tech',
            description='Technology articles'
        )

        # Create published article
        self.article = Article.objects.create(
            title='Test Article',
            slug='test-article',
            content='Detailed test content.',
            excerpt='Short summary',
            author=self.user,
            category=self.category,
            status='published'
        )

    def test_list_articles_unauthenticated(self):
        """Published articles are accessible without authentication."""
        url = reverse('article-list')
        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_list_articles_filters_drafts(self):
        """Drafts are not visible to unauthenticated users."""
        # Create a draft
        Article.objects.create(
            title='Draft Article',
            slug='draft-article',
            content='Draft content',
            author=self.user,
            status='draft'
        )

        url = reverse('article-list')
        response = self.client.get(url)

        # Only published article is visible
        self.assertEqual(len(response.data['results']), 1)

    def test_create_article_authenticated(self):
        """An authenticated user can create an article."""
        self.client.force_authenticate(user=self.user)

        url = reverse('article-list')
        data = {
            'title': 'New Article',
            'slug': 'new-article',
            'content': 'New article content.',
            'category_id': self.category.id,
        }

        response = self.client.post(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Article.objects.count(), 2)
        # Author is automatically assigned
        self.assertEqual(
            Article.objects.get(slug='new-article').author,
            self.user
        )

    def test_create_article_unauthenticated(self):
        """An unauthenticated user cannot create an article."""
        url = reverse('article-list')
        data = {
            'title': 'Unauthorized Article',
            'slug': 'unauthorized-article',
            'content': 'Content',
        }

        response = self.client.post(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_update_own_article(self):
        """An author can update their own article."""
        self.client.force_authenticate(user=self.user)

        url = reverse('article-detail', kwargs={'slug': self.article.slug})
        data = {'title': 'Modified Title'}

        response = self.client.patch(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.article.refresh_from_db()
        self.assertEqual(self.article.title, 'Modified Title')

    def test_update_other_user_article(self):
        """A user cannot update another user's article."""
        other_user = User.objects.create_user(
            username='other',
            email='other@example.com',
            password='otherpass123'
        )
        self.client.force_authenticate(user=other_user)

        url = reverse('article-detail', kwargs={'slug': self.article.slug})
        data = {'title': 'Modified Title'}

        response = self.client.patch(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_publish_action(self):
        """The publish action changes the article status."""
        draft = Article.objects.create(
            title='Draft',
            slug='draft',
            content='Draft content',
            author=self.user,
            status='draft'
        )
        self.client.force_authenticate(user=self.user)

        url = reverse('article-publish', kwargs={'slug': draft.slug})
        response = self.client.post(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        draft.refresh_from_db()
        self.assertEqual(draft.status, 'published')
        self.assertIsNotNone(draft.published_at)

    def test_filter_by_category(self):
        """Filtering by category works correctly."""
        url = reverse('article-list')
        response = self.client.get(url, {'category': 'tech'})

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_search_articles(self):
        """Text search works."""
        url = reverse('article-list')
        response = self.client.get(url, {'search': 'Test'})

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

테스트는 python manage.py test api.tests 명령어로 실행할 수 있습니다.

에러 처리와 통일된 응답 형식

일관된 에러 처리는 API를 사용하는 개발자의 경험을 향상시킵니다.

python
# api/exceptions.py
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status


def custom_exception_handler(exc, context):
    """
    Custom exception handler to standardize error responses.

    Response format:
    {
        "success": false,
        "error": {
            "code": "ERROR_CODE",
            "message": "Error description",
            "details": {...}  # Optional
        }
    }
    """
    # Call the default handler
    response = exception_handler(exc, context)

    if response is not None:
        # Standardize response format
        custom_response = {
            'success': False,
            'error': {
                'code': get_error_code(exc),
                'message': get_error_message(response.data),
                'details': response.data if isinstance(response.data, dict) else None
            }
        }
        response.data = custom_response

    return response


def get_error_code(exc):
    """Return an error code based on exception type."""
    error_codes = {
        'ValidationError': 'VALIDATION_ERROR',
        'AuthenticationFailed': 'AUTHENTICATION_FAILED',
        'NotAuthenticated': 'NOT_AUTHENTICATED',
        'PermissionDenied': 'PERMISSION_DENIED',
        'NotFound': 'NOT_FOUND',
        'MethodNotAllowed': 'METHOD_NOT_ALLOWED',
        'Throttled': 'RATE_LIMIT_EXCEEDED',
    }
    return error_codes.get(exc.__class__.__name__, 'UNKNOWN_ERROR')


def get_error_message(data):
    """Extract a readable error message from response data."""
    if isinstance(data, dict):
        if 'detail' in data:
            return str(data['detail'])
        # Collect validation messages
        messages = []
        for field, errors in data.items():
            if isinstance(errors, list):
                messages.extend([f"{field}: {e}" for e in errors])
            else:
                messages.append(f"{field}: {errors}")
        return '; '.join(messages) if messages else 'Validation error'
    return str(data)
python
# config/settings.py
REST_FRAMEWORK = {
    # ... other configurations
    'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
}

결론

Django REST Framework와 Django 5의 조합은 프로덕션 수준의 REST API를 구축하기 위한 완전한 생태계를 제공합니다. 시리얼라이저의 강력함, ViewSet의 유연성, JWT 인증의 네이티브 통합을 통해 견고하고 안전한 API를 신속하게 구축할 수 있습니다.

Django API 품질 체크리스트

  • 읽기와 쓰기에 별도의 시리얼라이저를 사용할 것
  • 접근 제어를 위한 커스텀 퍼미션을 구현할 것
  • 검색과 정렬을 위한 필터를 설정할 것
  • 토큰 로테이션이 적용된 JWT 인증을 추가할 것
  • 각 엔드포인트에 대한 단위 테스트를 작성할 것
  • 에러 응답 형식을 통일할 것
  • DRF Spectacular 또는 drf-yasg로 API 문서를 생성할 것

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

DRF의 접근 방식은 재사용과 조합을 촉진합니다. 시리얼라이저, 퍼미션, 필터가 서로 결합하여 장기적으로 유지보수 가능한 API를 만들어냅니다. 자동 문서 생성과 브라우저블 인터페이스는 개발 속도를 높이고 프론트엔드 팀과의 통합을 원활하게 합니다.

태그

#django
#django rest framework
#python
#rest api
#api development

공유

관련 기사