Skip to content

Django REST Framework 소개

지금까지 Django의 웹 개발을 배웠다면, 이제 FastAPI처럼 API를 만들어보겠습니다. Django REST Framework(DRF)는 Django를 기반으로 한 강력한 API 프레임워크로, FastAPI 개발자들에게 익숙한 기능들을 제공합니다.

1. DRF vs FastAPI

기본 개념 비교

FastAPI:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PostCreate(BaseModel):
    title: str
    content: str

@app.post("/posts/")
async def create_post(post: PostCreate):
    return {"message": "Post created", "data": post}

Django REST Framework:

from rest_framework import serializers, viewsets
from rest_framework.response import Response

class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['title', 'content']

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

특징 비교

특징 FastAPI Django REST Framework
타입 힌트 기본 지원 선택적
자동 문서화 Swagger/OpenAPI Browsable API
검증 Pydantic Serializers
ORM 통합 별도 (SQLAlchemy) Django ORM
인증 직접 구현 다양한 방식 내장
권한 수동 구현 세밀한 권한 시스템

2. DRF 설치 및 설정

설치

pip install djangorestframework
pip install django-filter  # 필터링 기능
pip install djangorestframework-simplejwt  # JWT 인증

settings.py 설정

INSTALLED_APPS = [
    # Django 기본 앱들
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # DRF
    'rest_framework',
    'rest_framework_simplejwt',
    'django_filters',

    # 로컬 앱들
    'blog',
    'accounts',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}

# JWT 설정
from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
}

3. Serializers

ModelSerializer

# blog/serializers.py
from rest_framework import serializers
from .models import Post, Category, Tag, Comment
from django.contrib.auth.models import User

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email']

class CategorySerializer(serializers.ModelSerializer):
    post_count = serializers.SerializerMethodField()

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

    def get_post_count(self, obj):
        return obj.posts.filter(status='published').count()

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ['id', 'name', 'slug']

class PostListSerializer(serializers.ModelSerializer):
    author = UserSerializer(read_only=True)
    category = CategorySerializer(read_only=True)
    tags = TagSerializer(many=True, read_only=True)

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'slug', 'author', 'category', 
            'excerpt', 'featured_image', 'created_at', 'view_count', 'tags'
        ]

class PostDetailSerializer(serializers.ModelSerializer):
    author = UserSerializer(read_only=True)
    category = CategorySerializer(read_only=True)
    tags = TagSerializer(many=True, read_only=True)
    comments_count = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'slug', 'author', 'category', 'content',
            'excerpt', 'featured_image', 'status', 'created_at', 
            'updated_at', 'view_count', 'tags', 'comments_count'
        ]

    def get_comments_count(self, obj):
        return obj.comments.filter(is_approved=True).count()

class PostCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['title', 'content', 'excerpt', 'category', 'tags', 'featured_image', 'status']

    def validate_title(self, value):
        if len(value) < 5:
            raise serializers.ValidationError("제목은 5자 이상이어야 합니다.")
        return value

    def create(self, validated_data):
        validated_data['author'] = self.context['request'].user
        return super().create(validated_data)

class CommentSerializer(serializers.ModelSerializer):
    author = UserSerializer(read_only=True)

    class Meta:
        model = Comment
        fields = ['id', 'author', 'content', 'created_at']

    def create(self, validated_data):
        validated_data['author'] = self.context['request'].user
        validated_data['post_id'] = self.context['post_id']
        return super().create(validated_data)

4. Views (ViewSets)

ModelViewSet 사용

# blog/views.py (API 버전)
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import F
from .models import Post, Category, Tag, Comment
from .serializers import (
    PostListSerializer, PostDetailSerializer, PostCreateSerializer,
    CategorySerializer, TagSerializer, CommentSerializer
)

class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
    """카테고리 조회 전용 API"""
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    lookup_field = 'slug'

class TagViewSet(viewsets.ReadOnlyModelViewSet):
    """태그 조회 전용 API"""
    queryset = Tag.objects.all()
    serializer_class = TagSerializer
    lookup_field = 'slug'

class PostViewSet(viewsets.ModelViewSet):
    """포스트 CRUD API"""
    queryset = Post.objects.filter(status='published').select_related('author', 'category')
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['category', 'tags', 'author']
    search_fields = ['title', 'content']
    ordering_fields = ['created_at', 'view_count']
    ordering = ['-created_at']
    lookup_field = 'slug'

    def get_serializer_class(self):
        if self.action == 'list':
            return PostListSerializer
        elif self.action in ['create', 'update', 'partial_update']:
            return PostCreateSerializer
        else:
            return PostDetailSerializer

    def get_permissions(self):
        if self.action in ['create', 'update', 'partial_update', 'destroy']:
            return [permissions.IsAuthenticated()]
        return [permissions.AllowAny()]

    def get_queryset(self):
        queryset = super().get_queryset()

        # 작성자 본인은 초안도 볼 수 있음
        if self.request.user.is_authenticated and self.action in ['list', 'retrieve']:
            if self.request.query_params.get('my_posts'):
                return Post.objects.filter(author=self.request.user).select_related('author', 'category')

        return queryset

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

    def perform_update(self, serializer):
        # 작성자만 수정 가능
        if serializer.instance.author != self.request.user:
            raise PermissionError("수정 권한이 없습니다.")
        serializer.save()

    def retrieve(self, request, *args, **kwargs):
        """조회시 view_count 증가"""
        instance = self.get_object()
        Post.objects.filter(pk=instance.pk).update(view_count=F('view_count') + 1)
        instance.refresh_from_db()
        serializer = self.get_serializer(instance)
        return Response(serializer.data)

    @action(detail=True, methods=['get'])
    def comments(self, request, slug=None):
        """포스트의 댓글 목록"""
        post = self.get_object()
        comments = post.comments.filter(is_approved=True).select_related('author')
        serializer = CommentSerializer(comments, many=True)
        return Response(serializer.data)

    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
    def add_comment(self, request, slug=None):
        """댓글 추가"""
        post = self.get_object()
        serializer = CommentSerializer(
            data=request.data, 
            context={'request': request, 'post_id': post.id}
        )
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
    def my_posts(self, request):
        """내 포스트 목록 (초안 포함)"""
        queryset = Post.objects.filter(author=request.user).select_related('category')
        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = PostListSerializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = PostListSerializer(queryset, many=True)
        return Response(serializer.data)

5. 함수 기반 API 뷰

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated

@api_view(['GET'])
def post_stats(request):
    """포스트 통계 API"""
    stats = {
        'total_posts': Post.objects.filter(status='published').count(),
        'total_categories': Category.objects.count(),
        'total_tags': Tag.objects.count(),
        'most_viewed_post': Post.objects.filter(status='published').order_by('-view_count').first(),
    }

    # 가장 조회수 높은 포스트 정보
    if stats['most_viewed_post']:
        stats['most_viewed_post'] = {
            'title': stats['most_viewed_post'].title,
            'view_count': stats['most_viewed_post'].view_count,
            'slug': stats['most_viewed_post'].slug,
        }

    return Response(stats)

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def toggle_like(request, post_slug):
    """포스트 좋아요 토글"""
    try:
        post = Post.objects.get(slug=post_slug, status='published')
    except Post.DoesNotExist:
        return Response({'error': '포스트를 찾을 수 없습니다.'}, status=404)

    # ManyToMany 관계가 있다고 가정
    if hasattr(post, 'likes'):
        if post.likes.filter(id=request.user.id).exists():
            post.likes.remove(request.user)
            liked = False
        else:
            post.likes.add(request.user)
            liked = True

        return Response({
            'liked': liked,
            'total_likes': post.likes.count()
        })

    return Response({'error': '좋아요 기능을 사용할 수 없습니다.'}, status=400)

6. URL 설정

# blog/urls.py (API 추가)
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
from . import api_views

# API Router
router = DefaultRouter()
router.register(r'posts', api_views.PostViewSet)
router.register(r'categories', api_views.CategoryViewSet)
router.register(r'tags', api_views.TagViewSet)

app_name = 'blog'

urlpatterns = [
    # 웹 뷰들
    path('', views.PostListView.as_view(), name='post_list'),
    path('post/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),

    # API 엔드포인트
    path('api/v1/', include(router.urls)),
    path('api/v1/stats/', api_views.post_stats, name='post_stats'),
    path('api/v1/posts/<slug:post_slug>/like/', api_views.toggle_like, name='toggle_like'),
]

# 메인 urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls')),
    path('accounts/', include('accounts.urls')),

    # API 인증
    path('api/v1/auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/v1/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),

    # DRF Browsable API
    path('api-auth/', include('rest_framework.urls')),
]

7. 인증 및 권한

JWT 토큰 인증

# 토큰 발급 (클라이언트에서)
import requests

response = requests.post('http://localhost:8000/api/v1/auth/token/', {
    'username': 'your_username',
    'password': 'your_password'
})

if response.status_code == 200:
    tokens = response.json()
    access_token = tokens['access']
    refresh_token = tokens['refresh']

    # API 요청시 토큰 사용
    headers = {'Authorization': f'Bearer {access_token}'}
    api_response = requests.get('http://localhost:8000/api/v1/posts/', headers=headers)

커스텀 권한 클래스

# blog/permissions.py
from rest_framework import permissions

class IsAuthorOrReadOnly(permissions.BasePermission):
    """작성자만 수정/삭제 가능, 나머지는 읽기 전용"""

    def has_object_permission(self, request, view, obj):
        # 읽기 권한은 모두에게
        if request.method in permissions.SAFE_METHODS:
            return True

        # 쓰기 권한은 작성자에게만
        return obj.author == request.user

# ViewSet에서 사용
class PostViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthorOrReadOnly]
    # ... 나머지 코드

8. 필터링 및 검색

커스텀 FilterSet

# blog/filters.py
import django_filters
from .models import Post

class PostFilter(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr='icontains')
    content = django_filters.CharFilter(lookup_expr='icontains')
    created_after = django_filters.DateFilter(field_name='created_at', lookup_expr='gte')
    created_before = django_filters.DateFilter(field_name='created_at', lookup_expr='lte')

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

# ViewSet에서 사용
class PostViewSet(viewsets.ModelViewSet):
    filterset_class = PostFilter
    # ... 나머지 코드

9. 테스팅

# blog/tests/test_api.py
from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth.models import User
from blog.models import Post, Category

class PostAPITestCase(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser',
            password='testpass123'
        )
        self.category = Category.objects.create(name='Test Category')
        self.post = Post.objects.create(
            title='Test Post',
            content='Test content',
            author=self.user,
            category=self.category,
            status='published'
        )

    def test_get_post_list(self):
        """포스트 목록 조회 테스트"""
        response = self.client.get('/api/v1/posts/')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_create_post_authenticated(self):
        """인증된 사용자 포스트 생성 테스트"""
        self.client.force_authenticate(user=self.user)
        data = {
            'title': 'New Post',
            'content': 'New content',
            'category': self.category.id,
        }
        response = self.client.post('/api/v1/posts/', data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

    def test_create_post_unauthenticated(self):
        """인증되지 않은 사용자 포스트 생성 테스트"""
        data = {
            'title': 'New Post',
            'content': 'New content',
        }
        response = self.client.post('/api/v1/posts/', data)
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

10. 프론트엔드 연동 예제

JavaScript로 API 사용

// API 클라이언트 클래스
class BlogAPI {
    constructor(baseURL) {
        this.baseURL = baseURL;
        this.token = localStorage.getItem('access_token');
    }

    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`;
        const config = {
            headers: {
                'Content-Type': 'application/json',
                ...options.headers,
            },
            ...options,
        };

        if (this.token) {
            config.headers.Authorization = `Bearer ${this.token}`;
        }

        const response = await fetch(url, config);

        if (!response.ok) {
            throw new Error(`API Error: ${response.status}`);
        }

        return response.json();
    }

    // 포스트 목록
    async getPosts(params = {}) {
        const queryString = new URLSearchParams(params).toString();
        return this.request(`/api/v1/posts/?${queryString}`);
    }

    // 포스트 상세
    async getPost(slug) {
        return this.request(`/api/v1/posts/${slug}/`);
    }

    // 포스트 생성
    async createPost(data) {
        return this.request('/api/v1/posts/', {
            method: 'POST',
            body: JSON.stringify(data),
        });
    }

    // 로그인
    async login(username, password) {
        const response = await this.request('/api/v1/auth/token/', {
            method: 'POST',
            body: JSON.stringify({ username, password }),
        });

        this.token = response.access;
        localStorage.setItem('access_token', this.token);
        localStorage.setItem('refresh_token', response.refresh);

        return response;
    }
}

// 사용 예제
const api = new BlogAPI('http://localhost:8000');

// 포스트 목록 가져오기
api.getPosts({ search: 'Django', page: 1 })
   .then(data => console.log(data))
   .catch(error => console.error(error));

정리

Django REST Framework의 장점: - 완성된 API 프레임워크: 인증, 권한, 직렬화 모두 제공 - Django 통합: Django 모델과 자연스럽게 연동 - 유연한 구조: ViewSet, 함수 기반 뷰 모두 지원 - 풍부한 기능: 필터링, 페이지네이션, 문서화 등

FastAPI와 비교: - 타입 안정성: FastAPI가 더 강함 (Pydantic) - 성능: FastAPI가 더 빠름 (비동기) - 생태계: DRF가 더 성숙함 - 학습 곡선: FastAPI가 더 쉬움 - 문서화: 두 프레임워크 모두 훌륭함

Django로 풀스택 개발 시: - 웹 페이지: Django 템플릿 - API: Django REST Framework - 관리자: Django Admin - 하나의 프로젝트로 모든 기능 제공 가능

DRF는 Django 개발자가 API를 만들 때 최고의 선택이며, FastAPI 개발자들도 쉽게 익힐 수 있는 훌륭한 도구입니다!

Comments