Django Template·Form과 CRUD

템플릿·CSRF·ModelForm·POST/Redirect/GET으로 명단 등록과 수정 흐름 만들기

이번 문서에서 만드는 것

4주의 config 프로젝트와 lions 앱에서 이어갑니다. 5주에는 목록과 등록 Form, 6주에는 수정·삭제를 완성합니다. 이번 단계는 가상 데이터만 사용하는 로컬 CRUD입니다. 로그인과 소유자 권한은 선택 확장에서 다루며 그 전에는 서비스를 공개하지 않습니다.

HTML form이 보낸 문자열을 검증해 유효한 데이터만 저장합니다. 성공 뒤에는 목록으로 이동합니다. 이 흐름을 중심으로 실습합니다. ORM을 모두 배우기 전에 최소 모델 하나를 사용하고 7–9주에 관리자·조회·관계를 깊게 다룹니다.

5주: 모델과 입력 폼

lions/models.py를 작성합니다. track은 선택지 밖의 입력을 Form에서 거절하도록 정의합니다.

Python
from django.db import models
class Member(models.Model):    class Track(models.TextChoices):        FRONTEND = "frontend", "프론트엔드"        DESIGN = "design", "기획·디자인"        BACKEND = "backend", "백엔드"
    name = models.CharField(max_length=40)    track = models.CharField(max_length=10, choices=Track.choices)
    def __str__(self):        return self.name
Shell
python manage.py makemigrations lionspython manage.py migrate

makemigrations는 모델 변경 이력 파일을 만들고 migrate는 DB에 적용합니다. 생성된 migration 파일도 코드와 함께 커밋합니다. 오류가 난다고 DB나 기존 migration을 지우지 않습니다.

새 파일 lions/forms.py를 만듭니다.

Python
from django import formsfrom .models import Member
class MemberForm(forms.ModelForm):    class Meta:        model = Member        fields = ["name", "track"]        labels = {"name": "이름", "track": "트랙"}

fields를 명시하면 나중에 모델에 관리자용 필드가 추가되어도 입력에 자동 노출되지 않습니다. ModelForm은 기본적으로 문자열 양끝 공백을 제거하고 필수 값·최대 길이·선택지를 검사합니다. 브라우저의 required만으로 서버 검증을 대신할 수 없습니다.

기존 member_api는 유지하고 lions/views.py에 추가합니다.

Python
from django.shortcuts import get_object_or_404, redirect, renderfrom django.views.decorators.http import require_http_methods, require_POSTfrom .forms import MemberFormfrom .models import Member
@require_GETdef member_list(request):    members = Member.objects.order_by("id")    return render(request, "lions/member_list.html", {"members": members})
@require_http_methods(["GET", "POST"])def member_create(request):    form = MemberForm(request.POST if request.method == "POST" else None)    if request.method == "POST" and form.is_valid():        form.save()        return redirect("lions:member-list")    return render(request, "lions/member_form.html", {"form": form})

@require_GET는 4주 파일에서 이미 import했습니다. lions/urls.py의 urlpatterns에 두 줄을 추가합니다.

Python
path("members/", views.member_list, name="member-list"),path("members/new/", views.member_create, name="member-create"),

lions/templates/lions/member_form.html을 만듭니다. csrf_token은 Django의 기본 CSRF 보호와 함께 작동하며 삭제하면 정상 POST가 403으로 거절됩니다.

HTML
<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>회원 입력</title><main>  <h1>회원 입력</h1>  <form method="post">    {% csrf_token %}    {{ form.as_p }}    <button type="submit">저장</button>  </form>  <a href="{% url 'lions:member-list' %}">목록으로</a></main></html>

lions/templates/lions/member_list.html을 만듭니다.

HTML
<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>회원 명단</title><main>  <h1>회원 명단</h1>  <a href="{% url 'lions:member-create' %}">회원 등록</a>  <ul>    {% for member in members %}      <li>{{ member.name }} · {{ member.get_track_display }}</li>    {% empty %}      <li>아직 등록된 회원이 없습니다.</li>    {% endfor %}  </ul></main></html>

/members/에서 등록 링크를 열어 가상 회원을 저장합니다. 성공하면 POST 응답이 redirect를 반환하고 브라우저는 GET으로 목록을 다시 엽니다. 이 흐름을 POST/Redirect/GET이라고 합니다. 새로고침으로 같은 POST가 반복되는 일을 줄이지만 네트워크 중복 요청 전체를 막는 장치는 아닙니다.

5주 권장 90분: 모델·migration 20분, Form·Template 35분, 정상·오류 입력 20분, 설명 15분. 제출물은 빈 목록·정상 등록·공백 이름 오류 화면입니다. 서버가 꺼졌다 켜져도 저장한 회원이 남아야 합니다.

6주: 수정과 삭제

lions/views.py에 추가합니다. 수정할 때 instance를 전달하지 않으면 새 행을 만들게 됩니다.

Python
@require_http_methods(["GET", "POST"])def member_update(request, pk):    member = get_object_or_404(Member, pk=pk)    form = MemberForm(        request.POST if request.method == "POST" else None,        instance=member,    )    if request.method == "POST" and form.is_valid():        form.save()        return redirect("lions:member-list")    return render(request, "lions/member_form.html", {"form": form})
@require_POSTdef member_delete(request, pk):    member = get_object_or_404(Member, pk=pk)    member.delete()    return redirect("lions:member-list")

lions/urls.py의 urlpatterns에 추가합니다.

Python
path("members/<int:pk>/edit/", views.member_update, name="member-update"),path("members/<int:pk>/delete/", views.member_delete, name="member-delete"),

목록 템플릿의 기존 <li>...</li>를 아래로 바꿉니다. 삭제는 GET 링크가 아닌 CSRF가 있는 POST form입니다. 실제 서비스에서는 삭제 확인과 권한을 추가해야 합니다.

HTML
<li>  {{ member.name }} · {{ member.get_track_display }}  <a href="{% url 'lions:member-update' member.pk %}">수정</a>  <form method="post" action="{% url 'lions:member-delete' member.pk %}">    {% csrf_token %}    <button type="submit">{{ member.name }} 삭제</button>  </form></li>

회귀를 잡는 테스트

기존 lions/tests.py에 추가합니다. 아래 테스트는 일반 기능 검사이며 Django 기본 테스트 클라이언트는 CSRF 검사를 생략합니다. CSRF 자체는 Client(enforce_csrf_checks=True)를 쓰거나 브라우저의 실제 Form 요청으로 따로 검사합니다.

Python
from .models import Member
class MemberFormTests(TestCase):    def test_invalid_name_is_not_saved(self):        response = self.client.post(reverse("lions:member-create"),                                    {"name": " ", "track": "frontend"})        self.assertEqual(response.status_code, 200)        self.assertEqual(Member.objects.count(), 0)        self.assertIn("name", response.context["form"].errors)
    def test_update_does_not_insert(self):        member = Member.objects.create(name="가람", track="frontend")        url = reverse("lions:member-update", args=[member.pk])        self.client.post(url, {"name": "나래", "track": "design"})        member.refresh_from_db()        self.assertEqual(member.name, "나래")        self.assertEqual(Member.objects.count(), 1)
    def test_get_cannot_delete(self):        member = Member.objects.create(name="가람", track="frontend")        response = self.client.get(reverse("lions:member-delete", args=[member.pk]))        self.assertEqual(response.status_code, 405)        self.assertTrue(Member.objects.filter(pk=member.pk).exists())

6주 권장 90분: 수정 25분, 삭제 20분, 오류·테스트 30분, 리뷰 15분. 완료 기준은 등록·수정·삭제 후 실제 DB 상태를 검사하고 없는 ID는 404, GET 삭제는 405, 공백 이름은 폼 오류로 처리하는 것입니다.

문제 해결과 다음 확장

  • TemplateDoesNotExist: 앱 등록과 templates/lions/ 경로, render의 이름을 비교합니다.
  • NoReverseMatch: URL의 name·namespace·필요한 pk를 확인합니다.
  • 잘못된 폼에서 입력이 사라짐: POST 데이터로 만든 bound form을 그대로 다시 render하는지 확인합니다.
  • 저장 후 행이 늘어남: 수정 Form의 instance를 확인합니다.

다음 문서에서 관리자 검색, ORM 조회와 과제·제출 관계를 추가합니다.

연결된 PBL 미션과 VOD

주차·미션참고 VOD 범위
5주 · Template와 FormPython 첫걸음 13·14장
6주 · CRUDPython 첫걸음 13·14장

5–10주는 동일한 Django 구조·CRUD VOD 범위를 반복 참조한다. 주차별 산출물은 서로 다르므로 문서 실습을 순서대로 확장한다.

강좌 안내: 참고 강좌 1. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.