Skip to main content
Summary

CRUD가 연결되면 게시판이 된다

지금까지 URL, View, Template, Model, Form을 각각 살펴보았다. 이제 이 조각들을 하나로 연결해 게시판의 기본 흐름을 완성해 보자. CRUD 자체는 생성, 조회, 수정, 삭제라는 네 가지 동작을 뜻하지만, 게시판 화면에서는 조회가 보통 목록과 상세로 나뉜다. 그래서 이번 장에서는 다음 다섯 가지 흐름을 차례대로 연결한다.

  • 목록: 게시글을 여러 개 보여 준다.

  • 상세: 게시글 하나를 보여 준다.

  • 생성: 새 게시글을 저장한다.

  • 수정: 기존 게시글을 바꾼다.

  • 삭제: 기존 게시글을 지운다.

보통 생성, 조회, 수정, 삭제를 묶어 CRUD라고 부른다. Create, Read, Update, Delete의 앞글자를 딴 말이다. 게시판을 한 번 끝까지 만들어 보면 Django의 핵심 구성요소가 어떻게 협력하는지 자연스럽게 보인다.

이번 장에서는 함수 기반 View로 구현한다. 다음 장에서는 같은 기능을 클래스 기반 View로 정리해 볼 것이다.

CRUD 기능으로 게시판 만들기AI 생성 이미지

준비: Post 모델과 PostForm

앞 장에서 만든 Post 모델과 PostForm이 있다고 가정하자. 예제에서는 게시글에 제목, 내용, 카테고리, 작성일, 공개 여부가 있다고 보고 진행한다.

# boards/forms.py
from django import forms
from .models import Post


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content', 'category', 'is_published']

ModelForm을 사용하면 생성과 수정에서 같은 폼을 재사용할 수 있다. 새 글을 만들 때는 빈 폼을 보여 주고, 기존 글을 수정할 때는 instance를 넘겨 현재 값을 채운 폼을 보여 주면 된다.

게시판 기능은 boards 앱 안에서 만든다고 하자. URL 이름을 템플릿에서 안전하게 사용하기 위해 app_name도 함께 지정한다.

# boards/urls.py
from django.urls import path
from . import views

app_name = 'boards'

urlpatterns = [
    path('', views.post_list, name='post_list'),
    path('posts/new/', views.post_create, name='post_create'),
    path('posts/<int:pk>/', views.post_detail, name='post_detail'),
    path('posts/<int:pk>/edit/', views.post_update, name='post_update'),
    path('posts/<int:pk>/delete/', views.post_delete, name='post_delete'),
]

여기서 <int:pk>는 URL에서 정수 값을 받아 View로 전달한다. pk는 primary key, 즉 기본키를 뜻한다. 데이터베이스에서 각 게시글을 구분하는 고유한 값이다.

아래 View 예제들은 기능별로 이해하기 쉽도록 필요한 import를 함께 보여 준다. 실제 boards/views.py 파일에서는 같은 import를 여러 번 적기보다 파일 위쪽에 한 번만 모아 두면 된다.

목록: QuerySet을 가져와 최신순으로 보여 주기

목록 페이지는 여러 게시글을 조회한다. Django ORM에서 여러 객체를 조회하면 QuerySet이 반환된다. QuerySet은 데이터베이스 조회 결과를 담는 객체라고 이해하면 된다.

# boards/views.py
from django.shortcuts import render
from .models import Post


def post_list(request):
    posts = Post.objects.filter(is_published=True).order_by('-created_at')
    return render(request, 'boards/post_list.html', {'posts': posts})

filter(is_published=True)는 공개된 글만 가져온다. order_by('-created_at')은 작성일 기준 내림차순 정렬이다. 필드 이름 앞에 -를 붙이면 큰 값이 먼저 온다. 날짜 필드에서는 최신 글이 위에 온다는 뜻이다.

템플릿에서는 전달받은 posts를 반복해서 출력한다.

<!-- boards/templates/boards/post_list.html -->
{% extends 'base.html' %}

{% block content %}
  <h2>게시글 목록</h2>

  <p>
    <a href="{% url 'boards:post_create' %}">새 글 작성</a>
  </p>

  <ul>
    {% for post in posts %}
      <li>
        <a href="{% url 'boards:post_detail' post.pk %}">
          {{ post.title }}
        </a>
        <small>{{ post.created_at }}</small>
      </li>
    {% empty %}
      <li>아직 게시글이 없습니다.</li>
    {% endfor %}
  </ul>
{% endblock %}

중요한 점은 링크를 직접 /posts/1/처럼 쓰지 않는 것이다. {% url 'boards:post_detail' post.pk %}처럼 URL 이름을 사용하면 나중에 주소 구조를 바꿔도 템플릿을 많이 고칠 필요가 없다.

상세: 기본키로 단일 객체 조회하기

상세 페이지는 게시글 하나를 보여 준다. URL에서 받은 pk로 게시글을 조회한다.

# boards/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post


def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk, is_published=True)
    return render(request, 'boards/post_detail.html', {'post': post})

Post.objects.get(pk=pk)를 사용할 수도 있지만, 해당 게시글이 없으면 예외가 발생한다. 사용자가 삭제된 글 주소로 접속하거나 잘못된 주소를 입력할 수 있으므로 이런 상황을 처리해야 한다.

get_object_or_404()는 조건에 맞는 객체가 있으면 반환하고, 없으면 404 응답을 만든다. 404는 "요청한 페이지를 찾을 수 없다"는 HTTP 응답이다. 존재하지 않는 데이터를 억지로 처리하려고 하기보다, 사용자에게 없는 페이지라고 알려 주는 편이 안전하다.

<!-- boards/templates/boards/post_detail.html -->
{% extends 'base.html' %}

{% block content %}
  <h2>{{ post.title }}</h2>

  <p>작성일: {{ post.created_at }}</p>

  <div>
    {{ post.content|linebreaks }}
  </div>

  <p>
    <a href="{% url 'boards:post_update' post.pk %}">수정</a>
    <a href="{% url 'boards:post_delete' post.pk %}">삭제</a>
    <a href="{% url 'boards:post_list' %}">목록으로</a>
  </p>
{% endblock %}

linebreaks 필터는 줄바꿈이 들어 있는 본문을 HTML 문단과 줄바꿈으로 바꿔 보여 준다. 템플릿은 데이터를 보기 좋게 배치하고, 실제 조회 조건은 View에서 담당한다.

생성: GET은 폼 표시, POST는 저장

게시글 작성 View는 두 가지 요청을 처리한다. 사용자가 처음 작성 페이지에 들어오면 GET 요청이다. 이때는 빈 폼을 보여 준다. 사용자가 내용을 입력하고 제출하면 POST 요청이다. 이때는 입력값을 검증하고 저장한다.

# boards/views.py
from django.shortcuts import render, redirect
from .forms import PostForm


def post_create(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save()
            return redirect('boards:post_detail', pk=post.pk)
    else:
        form = PostForm()

    return render(request, 'boards/post_form.html', {'form': form})

request.POST에는 사용자가 제출한 입력값이 들어 있다. form.is_valid()는 필수 값, 길이, 필드 형식, 사용자 정의 검증을 확인한다. 검증을 통과하면 form.save()가 Post 객체를 데이터베이스에 저장한다.

저장 후에는 render()로 바로 상세 화면을 보여 주지 않고 redirect()를 사용한다. 이 방식은 중요하다. 사용자가 저장 직후 새로고침했을 때 같은 POST 요청이 다시 전송되어 중복 저장되는 문제를 줄일 수 있다. 데이터 변경 요청을 처리한 뒤 다른 URL로 이동시키는 흐름은 게시판뿐 아니라 회원가입, 댓글 작성, 주문 생성에서도 자주 사용된다.

작성과 수정은 같은 템플릿을 쓸 수 있다.

<!-- boards/templates/boards/post_form.html -->
{% extends 'base.html' %}

{% block content %}
  <h2>게시글 작성/수정</h2>

  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">저장</button>
  </form>

  <p>
    <a href="{% url 'boards:post_list' %}">목록으로</a>
  </p>
{% endblock %}

{% csrf_token %}은 POST 폼에 반드시 넣어야 한다. CSRF는 사용자가 의도하지 않은 요청을 다른 사이트가 대신 보내게 만드는 공격이다. Django는 CSRF 토큰을 확인해 이런 요청을 막는다.

수정: 같은 ModelForm에 instance만 추가하기

수정은 생성과 거의 같다. 차이는 기존 게시글을 먼저 가져오고, 폼에 instance=post를 넘긴다는 점이다.

# boards/views.py
from django.shortcuts import render, redirect, get_object_or_404
from .models import Post
from .forms import PostForm


def post_update(request, pk):
    post = get_object_or_404(Post, pk=pk)

    if request.method == 'POST':
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save()
            return redirect('boards:post_detail', pk=post.pk)
    else:
        form = PostForm(instance=post)

    return render(request, 'boards/post_form.html', {'form': form})

instance가 없으면 Django는 새 객체를 만든다고 판단한다. instance=post를 넘기면 해당 객체를 수정 대상으로 삼는다.

  • PostForm(request.POST): 새 게시글 생성

  • PostForm(request.POST, instance=post): 기존 게시글 수정

  • PostForm(instance=post): 기존 값을 채운 수정 폼 표시

이 차이를 이해하면 ModelForm의 흐름이 훨씬 선명해진다. 생성과 수정은 다른 기능처럼 보이지만, 실제로는 "검증된 폼 데이터를 저장한다"는 같은 구조를 공유한다.

삭제: 확인 화면을 거친 뒤 POST로만 처리하기

삭제는 특히 조심해야 한다. 링크를 클릭하는 GET 요청만으로 데이터를 삭제하면 위험하다. 검색 엔진, 미리보기 도구, 브라우저 확장 기능이 링크를 방문하는 것만으로 데이터가 지워질 수 있기 때문이다.

따라서 삭제 URL에 처음 접근했을 때는 확인 화면을 보여 주고, 사용자가 확인 버튼을 눌러 POST 요청을 보냈을 때만 삭제한다.

# boards/views.py
from django.shortcuts import render, redirect, get_object_or_404
from .models import Post


def post_delete(request, pk):
    post = get_object_or_404(Post, pk=pk)

    if request.method == 'POST':
        post.delete()
        return redirect('boards:post_list')

    return render(request, 'boards/post_confirm_delete.html', {'post': post})

확인 템플릿은 간단하다.

<!-- boards/templates/boards/post_confirm_delete.html -->
{% extends 'base.html' %}

{% block content %}
  <h2>게시글 삭제</h2>

  <p>정말로 이 게시글을 삭제하시겠습니까?</p>
  <p><strong>{{ post.title }}</strong></p>

  <form method="post">
    {% csrf_token %}
    <button type="submit">삭제</button>
  </form>

  <p>
    <a href="{% url 'boards:post_detail' post.pk %}">취소</a>
  </p>
{% endblock %}

삭제 버튼은 링크가 아니라 폼의 제출 버튼이다. 이 차이가 중요하다. 링크는 보통 조회에 사용하고, 서버의 데이터를 바꾸는 작업은 POST 요청으로 처리한다.

CRUD 흐름 다시 보기

이제 게시판의 기본 흐름이 완성되었다. 각 기능은 서로 다른 URL을 가지지만, 내부 구조는 반복된다.

기능URL 이름View 역할Template 역할
목록post_list여러 게시글 조회반복 출력
상세post_detail게시글 하나 조회, 없으면 404본문과 링크 출력
생성post_create폼 검증 후 새 객체 저장입력 폼 출력
수정post_update기존 객체를 폼으로 수정입력 폼 재사용
삭제post_deletePOST일 때만 삭제삭제 확인 화면 출력

Django의 구조는 여기서 분명해진다. URL은 요청을 어떤 View로 보낼지 결정한다. View는 모델을 조회하거나 폼을 검증하고, 필요한 데이터를 템플릿에 전달한다. Model은 데이터베이스 구조와 저장을 담당한다. Template은 사용자에게 보일 HTML을 만든다.

CRUD는 단순한 예제처럼 보이지만, 대부분의 웹 애플리케이션은 이 흐름을 확장한 것이다. 상품 관리, 공지사항, 댓글, 문의 게시판, 관리자용 데이터 화면도 기본은 비슷하다. 먼저 하나의 게시판을 끝까지 연결해 보는 것이 Django 학습에서 중요한 이유다.

책 목록

Django 활용법: 웹 개발 기본기부터 배포와 운영까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기