Skip to content

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 보호

<!-- 모든 POST 폼에 필수 -->
<form method="post">
    {% csrf_token %}
    <!-- 폼 필드들 -->
</form>

권한 확인

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를 사용해 더 안전하고 효율적인 폼 처리를 알아보겠습니다!

Comments