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

๊ณต์œ 

๊ด€๋ จ ๊ธฐ์‚ฌ

Django์™€ Python ๋ฉด์ ‘ ์งˆ๋ฌธ - ์ข…ํ•ฉ ๊ฐ€์ด๋“œ

Django์™€ Python ๋ฉด์ ‘ ์งˆ๋ฌธ: 2026๋…„ ํ•ต์‹ฌ 25์„ 

Django์™€ Python ๋ฉด์ ‘์—์„œ ๊ฐ€์žฅ ์ž์ฃผ ์ถœ์ œ๋˜๋Š” 25๊ฐ€์ง€ ์งˆ๋ฌธ์„ ์ƒ์„ธํžˆ ๋‹ค๋ฃน๋‹ˆ๋‹ค. ORM, ๋ทฐ, ๋ฏธ๋“ค์›จ์–ด, Django REST Framework, ์‹œ๊ทธ๋„, ์„ฑ๋Šฅ ์ตœ์ ํ™”์— ๋Œ€ํ•œ ์ƒ์„ธํ•œ ๋‹ต๋ณ€๊ณผ ์ฝ”๋“œ ์˜ˆ์ œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

Django REST Framework ์‹œ๋ฆฌ์–ผ๋ผ์ด์ €์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ์ค‘์ฒฉ ๋ฐ N+1 ๋ฌธ์ œ ํ•ด๊ฒฐ ์„ค๋ช… ๋‹ค์ด์–ด๊ทธ๋žจ

Django REST Framework ์‹œ๋ฆฌ์–ผ๋ผ์ด์ € ์‹ฌ์ธต ๋ถ„์„: ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ์ค‘์ฒฉ ๋ฐ N+1 ๋ฌธ์ œ

DRF ์‹œ๋ฆฌ์–ผ๋ผ์ด์ €์˜ ๊ณ ๊ธ‰ ๊ธฐ๋ฒ•์„ ์ƒ์„ธํžˆ ๋‹ค๋ฃน๋‹ˆ๋‹ค. ์ปค์Šคํ…€ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ, ์ค‘์ฒฉ ์‹œ๋ฆฌ์–ผ๋ผ์ด์ €, N+1 ๋ฌธ์ œ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•, ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ํฌ๊ด„์ ์œผ๋กœ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

๋™์‹œ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” Django ๋น„๋™๊ธฐ ๋ทฐ์™€ ASGI ์•„ํ‚คํ…์ฒ˜

2026๋…„ Django ๋น„๋™๊ธฐ ๋ทฐ์™€ ASGI: ์„ฑ๋Šฅ๊ณผ ๋ฉด์ ‘ ์งˆ๋ฌธ

2026๋…„ Django ๋น„๋™๊ธฐ ๋ทฐ์™€ ASGI ์‹ฌ์ธต ๋ถ„์„: ๋‚ด๋ถ€ ๋™์ž‘ ์›๋ฆฌ, ๋ฐฐํฌํ•  ์„œ๋ฒ„ ์„ ํƒ, ๋น„๋™๊ธฐ ORM๊ณผ SynchronousOnlyOperation ํ•จ์ •, ๊ทธ๋ฆฌ๊ณ  ๋ฉด์ ‘ ์งˆ๋ฌธ๊นŒ์ง€ ๋‹ค๋ฃน๋‹ˆ๋‹ค.