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 개발자들도 쉽게 익힐 수 있는 훌륭한 도구입니다!