CRUD 구현하기 (함수 기반 뷰)
FastAPI에서는 각 엔드포인트를 함수로 정의했듯이, Django에서도 함수 기반 뷰(FBV)로 CRUD를 구현할 수 있습니다. 이번 장에서는 블로그 포스트의 생성, 조회, 수정, 삭제 기능을 구현해봅니다.
1. CRUD란?
- Create: 새로운 데이터 생성
- Read: 데이터 조회 (목록, 상세)
- Update: 기존 데이터 수정
- Delete: 데이터 삭제
FastAPI vs Django CRUD
FastAPI:
@app.get("/posts/")
async def get_posts():
return posts
@app.post("/posts/")
async def create_post(post: PostSchema):
return post
Django:
def post_list(request):
posts = Post.objects.all()
return render(request, 'blog/post_list.html', {'posts': posts})
def post_create(request):
if request.method == 'POST':
# 폼 처리
return render(request, 'blog/post_form.html')
2. 모델 준비
# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
content = models.TextField()
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
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'pk': self.pk})
3. URL 패턴 설정
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.post_list, name='post_list'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
path('post/new/', views.post_create, name='post_create'),
path('post/<int:pk>/edit/', views.post_edit, name='post_edit'),
path('post/<int:pk>/delete/', views.post_delete, name='post_delete'),
]
4. Read - 조회 기능
목록 조회
# blog/views.py
from django.shortcuts import render
from django.core.paginator import Paginator
from .models import Post
def post_list(request):
# 쿼리 파라미터로 필터링
queryset = Post.objects.all()
# 검색 기능
search = request.GET.get('search', '')
if search:
queryset = queryset.filter(title__icontains=search)
# 페이지네이션
paginator = Paginator(queryset, 10) # 페이지당 10개
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'search': search,
}
return render(request, 'blog/post_list.html', context)
목록 템플릿
<!-- templates/blog/post_list.html -->
{% extends 'base.html' %}
{% block content %}
<h1>블로그 포스트</h1>
<!-- 검색 폼 -->
<form method="get" class="search-form">
<input type="text" name="search" value="{{ search }}" placeholder="검색...">
<button type="submit">검색</button>
</form>
<!-- 새 글 작성 버튼 -->
{% if user.is_authenticated %}
<a href="{% url 'blog:post_create' %}" class="btn btn-primary">새 글 작성</a>
{% endif %}
<!-- 포스트 목록 -->
<div class="post-list">
{% for post in page_obj %}
<article class="post">
<h2><a href="{% url 'blog:post_detail' pk=post.pk %}">{{ post.title }}</a></h2>
<p class="meta">
작성자: {{ post.author.username }} |
{{ post.created_at|date:"Y-m-d H:i" }}
</p>
<p>{{ post.content|truncatewords:30 }}</p>
</article>
{% empty %}
<p>아직 포스트가 없습니다.</p>
{% endfor %}
</div>
<!-- 페이지네이션 -->
<div class="pagination">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?page=1{% if search %}&search={{ search }}{% endif %}">처음</a>
<a href="?page={{ page_obj.previous_page_number }}{% if search %}&search={{ search }}{% endif %}">이전</a>
{% endif %}
<span class="current">
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if search %}&search={{ search }}{% endif %}">다음</a>
<a href="?page={{ page_obj.paginator.num_pages }}{% if search %}&search={{ search }}{% endif %}">마지막</a>
{% endif %}
</span>
</div>
{% endblock %}
상세 조회
from django.shortcuts import render, get_object_or_404
from django.http import Http404
def post_detail(request, pk):
# 방법 1: get_object_or_404 사용 (권장)
post = get_object_or_404(Post, pk=pk)
# 방법 2: try-except 사용
# try:
# post = Post.objects.get(pk=pk)
# except Post.DoesNotExist:
# raise Http404("Post does not exist")
# 조회수 증가
post.view_count = post.view_count + 1
post.save(update_fields=['view_count'])
context = {
'post': post,
}
return render(request, 'blog/post_detail.html', context)
상세 템플릿
<!-- templates/blog/post_detail.html -->
{% extends 'base.html' %}
{% block title %}{{ post.title }} - {{ block.super }}{% endblock %}
{% block content %}
<article class="post-detail">
<h1>{{ post.title }}</h1>
<div class="post-meta">
<span>작성자: {{ post.author.username }}</span>
<span>작성일: {{ post.created_at|date:"Y-m-d H:i" }}</span>
{% if post.updated_at != post.created_at %}
<span>수정일: {{ post.updated_at|date:"Y-m-d H:i" }}</span>
{% endif %}
<span>조회수: {{ post.view_count }}</span>
</div>
<div class="post-content">
{{ post.content|linebreaks }}
</div>
<!-- 수정/삭제 버튼 (작성자만) -->
{% if user == post.author %}
<div class="post-actions">
<a href="{% url 'blog:post_edit' pk=post.pk %}" class="btn btn-secondary">수정</a>
<a href="{% url 'blog:post_delete' pk=post.pk %}" class="btn btn-danger">삭제</a>
</div>
{% endif %}
</article>
<a href="{% url 'blog:post_list' %}">목록으로</a>
{% endblock %}
5. Create - 생성 기능
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
@login_required # 로그인 필수
def post_create(request):
if request.method == 'POST':
# 폼 데이터 받기
title = request.POST.get('title')
content = request.POST.get('content')
# 유효성 검사
errors = {}
if not title:
errors['title'] = '제목을 입력하세요.'
if not content:
errors['content'] = '내용을 입력하세요.'
if errors:
return render(request, 'blog/post_form.html', {
'errors': errors,
'title': title,
'content': content,
})
# 포스트 생성
post = Post.objects.create(
title=title,
content=content,
author=request.user,
slug=slugify(title) # 또는 수동으로 입력받기
)
messages.success(request, '포스트가 작성되었습니다.')
return redirect('blog:post_detail', pk=post.pk)
return render(request, 'blog/post_form.html')
생성 폼 템플릿
<!-- templates/blog/post_form.html -->
{% extends 'base.html' %}
{% block content %}
<h1>{% if post %}포스트 수정{% else %}새 포스트 작성{% endif %}</h1>
<form method="post" class="post-form">
{% csrf_token %}
<div class="form-group">
<label for="title">제목</label>
<input type="text" name="title" id="title"
value="{{ post.title|default:title }}"
class="form-control {% if errors.title %}is-invalid{% endif %}"
required>
{% if errors.title %}
<div class="invalid-feedback">{{ errors.title }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea name="content" id="content" rows="10"
class="form-control {% if errors.content %}is-invalid{% endif %}"
required>{{ post.content|default:content }}</textarea>
{% if errors.content %}
<div class="invalid-feedback">{{ errors.content }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">
{% if post %}수정{% else %}작성{% endif %}
</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">취소</a>
</form>
{% endblock %}
6. Update - 수정 기능
@login_required
def post_edit(request, pk):
post = get_object_or_404(Post, pk=pk)
# 작성자 확인
if post.author != request.user:
messages.error(request, '수정 권한이 없습니다.')
return redirect('blog:post_detail', pk=pk)
if request.method == 'POST':
title = request.POST.get('title')
content = request.POST.get('content')
# 유효성 검사
errors = {}
if not title:
errors['title'] = '제목을 입력하세요.'
if not content:
errors['content'] = '내용을 입력하세요.'
if errors:
return render(request, 'blog/post_form.html', {
'post': post,
'errors': errors,
})
# 업데이트
post.title = title
post.content = content
post.save()
messages.success(request, '포스트가 수정되었습니다.')
return redirect('blog:post_detail', pk=post.pk)
return render(request, 'blog/post_form.html', {'post': post})
7. Delete - 삭제 기능
@login_required
def post_delete(request, pk):
post = get_object_or_404(Post, pk=pk)
# 작성자 확인
if post.author != request.user:
messages.error(request, '삭제 권한이 없습니다.')
return redirect('blog:post_detail', pk=pk)
if request.method == 'POST':
post.delete()
messages.success(request, '포스트가 삭제되었습니다.')
return redirect('blog:post_list')
return render(request, 'blog/post_confirm_delete.html', {'post': post})
삭제 확인 템플릿
<!-- templates/blog/post_confirm_delete.html -->
{% extends 'base.html' %}
{% block content %}
<h1>포스트 삭제</h1>
<p>정말로 "{{ post.title }}" 포스트를 삭제하시겠습니까?</p>
<p class="text-danger">이 작업은 되돌릴 수 없습니다.</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">삭제</button>
<a href="{% url 'blog:post_detail' pk=post.pk %}" class="btn btn-secondary">취소</a>
</form>
{% endblock %}
8. AJAX를 활용한 비동기 처리
좋아요 기능 추가
# models.py에 추가
class Post(models.Model):
# ... 기존 필드
likes = models.ManyToManyField(User, related_name='liked_posts', blank=True)
def total_likes(self):
return self.likes.count()
# views.py
from django.http import JsonResponse
@login_required
def post_like(request, pk):
if request.method == 'POST':
post = get_object_or_404(Post, pk=pk)
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 JsonResponse({
'liked': liked,
'total_likes': post.total_likes()
})
return JsonResponse({'error': 'Invalid request'}, status=400)
JavaScript 코드
// 좋아요 버튼 처리
document.addEventListener('DOMContentLoaded', function() {
const likeBtn = document.getElementById('like-btn');
if (likeBtn) {
likeBtn.addEventListener('click', function() {
const postId = this.dataset.postId;
const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
fetch(`/blog/post/${postId}/like/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
document.getElementById('like-count').textContent = data.total_likes;
if (data.liked) {
this.classList.add('liked');
this.textContent = '좋아요 취소';
} else {
this.classList.remove('liked');
this.textContent = '좋아요';
}
});
});
}
});
9. 보안 고려사항
CSRF 보호
권한 확인
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
@login_required
def post_edit(request, pk):
post = get_object_or_404(Post, pk=pk)
# 방법 1: 리다이렉트
if post.author != request.user:
messages.error(request, '권한이 없습니다.')
return redirect('blog:post_detail', pk=pk)
# 방법 2: 403 에러
# if post.author != request.user:
# raise PermissionDenied
SQL Injection 방지
# 나쁜 예 - SQL Injection 위험
search = request.GET.get('search')
posts = Post.objects.raw(f"SELECT * FROM blog_post WHERE title LIKE '%{search}%'")
# 좋은 예 - Django ORM 사용
search = request.GET.get('search', '')
posts = Post.objects.filter(title__icontains=search)
10. 성능 최적화
def post_list(request):
# select_related로 JOIN 최적화
posts = Post.objects.select_related('author').all()
# prefetch_related로 M2M 최적화
posts = Post.objects.prefetch_related('tags', 'likes').all()
# 필요한 필드만 선택
posts = Post.objects.only('id', 'title', 'created_at', 'author__username')
return render(request, 'blog/post_list.html', {'posts': posts})
실습: 댓글 기능 추가
# models.py
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
# views.py에 추가
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
# 댓글 처리
if request.method == 'POST' and request.user.is_authenticated:
comment_content = request.POST.get('comment')
if comment_content:
Comment.objects.create(
post=post,
author=request.user,
content=comment_content
)
return redirect('blog:post_detail', pk=pk)
comments = post.comments.select_related('author').all()
context = {
'post': post,
'comments': comments,
}
return render(request, 'blog/post_detail.html', context)
정리
Django FBV로 구현한 CRUD: - 간단하고 직관적: 함수로 각 기능 구현 - 유연한 처리: 복잡한 로직도 쉽게 구현 - 데코레이터 활용: @login_required 등으로 기능 확장 - 템플릿 통합: HTML 렌더링과 자연스럽게 연동
FastAPI와 비교: - Django는 HTML 응답이 기본, FastAPI는 JSON 응답이 기본 - Django는 CSRF 보호 내장, FastAPI는 별도 구현 필요 - Django는 폼 처리가 프레임워크에 통합 - 두 프레임워크 모두 함수 기반으로 직관적인 구현 가능
다음 장에서는 Django Forms를 사용해 더 안전하고 효율적인 폼 처리를 알아보겠습니다!