장고 기초 #6 Forms와 ModelForm

7 분 소요

#4 URL과 Views의 끝에서 request.POST.get("title", "").strip() 같은 손작업으로 폼 데이터를 받았습니다. 항상 이러면 검증 / 에러 메시지 / 재렌더 / CSRF를 매번 다시 짜게 됩니다. Django Form이 이걸 한 번에 정리합니다.

Form의 정체 — 무엇을 해주는가 #

Django Form은 다섯 가지 일을 한곳에서 해줍니다.

  • HTML 폼 렌더링<input>, <select>, <textarea> 자동 생성
  • 데이터 검증 — 타입, 길이, 정규식, 사용자 정의 규칙
  • 에러 메시지 — 한글 가능, 필드별 표시
  • 타입 변환 — 문자열 입력 → Python 타입 (int, date, datetime)
  • 재렌더 시 입력 보존 — 검증 실패 시 사용자가 입력했던 값 그대로

Form 클래스 한 번 #

blog/forms.py
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(max_length=50, label="이름")
    email = forms.EmailField(label="이메일")
    subject = forms.CharField(max_length=200, label="제목")
    message = forms.CharField(widget=forms.Textarea, label="내용")
    subscribe = forms.BooleanField(required=False, label="뉴스레터 구독")

이 클래스 하나로 5개 필드의 HTML, 검증, 에러를 다 만듭니다.

View에서 사용 — 표준 패턴 #

blog/views.py
from django.shortcuts import redirect, render

from .forms import ContactForm


def contact(request):
    if request.method == "POST":
        form = ContactForm(request.POST)
        if form.is_valid():
            # form.cleaned_data가 검증/변환된 데이터
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            # ... 처리 (메일 발송 등)
            return redirect("blog:contact_thanks")
    else:
        form = ContactForm()  # GET — 빈 폼

    return render(request, "blog/contact.html", {"form": form})

이게 Django Form의 표준 흐름 입니다. 폼 처리 뷰는 매번 이 패턴으로 작성합니다.

  1. GET — 빈 form 인스턴스
  2. POSTrequest.POST로 form 만들기
  3. is_valid() — 검증 실행
  4. 성공cleaned_data 사용 + redirect
  5. 실패 — 같은 폼 재렌더 (에러 메시지가 자동으로 들어감)

템플릿에서 Form 렌더링 #

가장 짧은 방법:

blog/templates/blog/contact.html
{% extends "base.html" %}

{% block content %}
<h1>문의</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">보내기</button>
</form>
{% endblock %}

{{ form.as_p }} — 각 필드를 <p>로 감싸서 라벨 + input + 에러를 한 번에 정리합니다. 빠른 프로토타입에 좋습니다.

다른 옵션:

  • {{ form.as_table }}<table> 행으로
  • {{ form.as_ul }}<ul>
  • {{ form.as_div }}<div>로 (Django 4.1+)

필드별 세밀한 렌더링 #

진짜 사이트에서는 디자인이 들어가니 필드별로 직접 그립니다.

필드별 렌더링
<form method="post" class="space-y-4">
  {% csrf_token %}

  <div>
    <label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
    {{ form.name }}
    {% if form.name.errors %}
      <ul class="errors">
        {% for error in form.name.errors %}
          <li>{{ error }}</li>
        {% endfor %}
      </ul>
    {% endif %}
  </div>

  <div>
    <label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
    {{ form.email }}
    {% for error in form.email.errors %}
      <p class="error">{{ error }}</p>
    {% endfor %}
  </div>

  {# 비-필드 에러 (form-wide) #}
  {% if form.non_field_errors %}
    <div class="errors">{{ form.non_field_errors }}</div>
  {% endif %}

  <button type="submit">보내기</button>
</form>

{{ form.name }}만 출력하면 widget이 그대로 그려집니다 (<input type="text" name="name" id="id_name" maxlength="50">). 라벨, 에러는 따로.

Widget 변경 — input 타입 바꾸기 #

widget 커스터마이즈
class ContactForm(forms.Form):
    name = forms.CharField(
        max_length=50,
        widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "이름"}),
    )
    email = forms.EmailField(
        widget=forms.EmailInput(attrs={"class": "form-control"}),
    )
    message = forms.CharField(
        widget=forms.Textarea(attrs={"class": "form-control", "rows": 5}),
    )
    birthday = forms.DateField(
        widget=forms.DateInput(attrs={"type": "date"}),
    )
    color = forms.ChoiceField(
        choices=[("red", "빨강"), ("blue", "파랑")],
        widget=forms.RadioSelect,
    )

attrs로 HTML 속성 (class, placeholder, data-…)을 추가. CSS 프레임워크 (Bootstrap, Tailwind)와 잘 어울립니다.

사용자 정의 검증 — clean_<field> / clean #

한 필드 검증 #

필드별 clean
class SignupForm(forms.Form):
    username = forms.CharField(max_length=30)
    password = forms.CharField(widget=forms.PasswordInput, min_length=8)
    password_confirm = forms.CharField(widget=forms.PasswordInput)

    def clean_username(self):
        username = self.cleaned_data["username"]
        from django.contrib.auth import get_user_model
        if get_user_model().objects.filter(username=username).exists():
            raise forms.ValidationError("이미 사용 중인 아이디입니다.")
        return username

clean_<필드명> 메소드는 그 필드의 검증이 끝난 뒤 호출됩니다. 반드시 검증된 값을 반환 해야 합니다.

여러 필드를 함께 검증 #

model-level clean
def clean(self):
    cleaned = super().clean()
    pw1 = cleaned.get("password")
    pw2 = cleaned.get("password_confirm")
    if pw1 and pw2 and pw1 != pw2:
        raise forms.ValidationError("비밀번호가 일치하지 않습니다.")
    return cleaned

clean()은 모든 필드별 검증 이후. 두 필드 비교 같은 폼 단위 규칙은 여기서.

특정 필드에 에러를 매달고 싶다면:

add_error
def clean(self):
    cleaned = super().clean()
    if cleaned.get("password") != cleaned.get("password_confirm"):
        self.add_error("password_confirm", "비밀번호가 일치하지 않습니다.")
    return cleaned

ModelForm — 모델에서 폼을 자동 생성 #

같은 필드 정의를 모델과 폼 양쪽에 쓰는 건 비효율입니다. ModelForm은 모델의 필드를 그대로 폼으로 만들어줍니다.

blog/forms.py
from django import forms

from .models import Post


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "content", "tags", "is_published"]
        widgets = {
            "content": forms.Textarea(attrs={"rows": 10}),
        }
        labels = {
            "title": "제목",
            "content": "본문",
            "tags": "태그",
            "is_published": "발행",
        }

세 줄 (model, fields)이면 끝. 필드 타입, 검증, label, max_length 모두 모델에서 자동으로.

fields vs exclude #

  • fields = ["title", "content"]명시한 것만 폼에
  • fields = "__all__" — 모든 필드 (위험, 추천 안 함)
  • exclude = ["author", "created_at"] — 명시한 것만 제외

명시적 화이트리스트 (fields = [...])가 안전합니다 — 새 필드를 모델에 추가했을 때 의도하지 않게 폼에 노출되는 걸 막습니다.

View에서 ModelForm — save() #

blog/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render

from .forms import PostForm
from .models import Post


@login_required
def post_new(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)   # 아직 DB 저장 X
            post.author = request.user        # 사용자 채워넣고
            post.save()                       # 그 다음 저장
            form.save_m2m()                   # M2M 필드 (tags) 별도 저장
            return redirect("blog:post_detail", post_id=post.id)
    else:
        form = PostForm()

    return render(request, "blog/post_form.html", {"form": form})


@login_required
def post_edit(request, post_id):
    post = get_object_or_404(Post, pk=post_id, author=request.user)
    if request.method == "POST":
        form = PostForm(request.POST, instance=post)   # instance= 가 핵심
        if form.is_valid():
            form.save()
            return redirect("blog:post_detail", post_id=post.id)
    else:
        form = PostForm(instance=post)

    return render(request, "blog/post_form.html", {"form": form})

핵심 패턴:

  • form.save() — DB에 저장하고 인스턴스 반환
  • form.save(commit=False) — 인스턴스만 만들고 저장은 미룸. author같이 폼에 없는 필드를 채워넣을 때
  • form.save_m2m()commit=False 후에 M2M 필드를 따로 저장 (M2M은 인스턴스 PK가 있어야 저장 가능)
  • PostForm(request.POST, instance=post) — 수정 시 기존 인스턴스를 묶어 폼 생성

이 패턴 하나만 외우면 대부분의 CRUD 폼이 됩니다.

파일 업로드 #

이미지 / 첨부 파일을 받는 폼은 두 가지가 더 필요합니다.

1. 모델에 FileField / ImageField #

blog/models.py
class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover = models.ImageField(upload_to="covers/%Y/%m/", blank=True, null=True)
    attachment = models.FileField(upload_to="attachments/", blank=True, null=True)

upload_toMEDIA_ROOT 안의 저장 경로. %Y/%m/ 같은 형식 표시자도 가능 (covers/2026/04/...같이).

ImageFieldPillow 패키지가 필요합니다 — uv add Pillow.

2. View에서 request.FILES #

파일 업로드 view
@login_required
def post_new(request):
    if request.method == "POST":
        form = PostForm(request.POST, request.FILES)   # request.FILES 추가
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            form.save_m2m()
            return redirect("blog:post_detail", post_id=post.id)
    else:
        form = PostForm()
    return render(request, "blog/post_form.html", {"form": form})

PostForm(request.POST, request.FILES)처럼 **두 번째 인자에 request.FILES**를 같이.

3. 템플릿에 enctype #

파일 업로드 form
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button>업로드</button>
</form>

enctype="multipart/form-data"가 빠지면 파일이 오지 않습니다 — 자주 빼먹습니다.

CSRF — 한 번 더 #

Django의 모든 POST 요청은 CSRF 토큰을 요구합니다. 빠뜨리면 403 Forbidden.

🚫 CSRF 토큰 누락
<form method="post">
  {{ form.as_p }}
  <button>저장</button>
</form>
올바른 폼
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button>저장</button>
</form>

JavaScript fetch / AJAX에서도 헤더에 토큰을 같이 보내야 합니다 — X-CSRFToken: <token>. Django 공식 문서의 Using CSRF protection with AJAX 참고.

Formset — 같은 폼 여러 개 #

한 페이지에서 같은 폼을 N 개 처리해야 한다면 formset. 예: 글 하나에 댓글 5 개를 한 번에 저장.

formset
from django.forms import modelformset_factory

CommentFormSet = modelformset_factory(Comment, fields=["body"], extra=3)

# view
formset = CommentFormSet(request.POST or None, queryset=Comment.objects.none())
if request.method == "POST" and formset.is_valid():
    formset.save()

기초에서는 깊이 안 들어가지만, “한 페이지 N 개 폼” 이 떠오를 때 formset이라는 이름을 기억하세요.

Form vs ModelForm — 언제 무엇을 #

상황쓰는 것
DB 모델과 1:1 매핑 (글 작성/수정)ModelForm
모델과 무관한 입력 (검색, 문의, 로그인)Form
모델 + 추가 필드ModelForm에 필드 더하거나, 별도 Form

처음에는 ModelForm부터 시작하세요. 거기서 부족할 때만 일반 Form으로 내려가는 게 자연스럽습니다.

자주 쓰는 패턴 — 한곳에 #

blog/views.py — 마무리
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render

from .forms import PostForm
from .models import Post


@login_required
def post_new(request):
    form = PostForm(request.POST or None, request.FILES or None)
    if request.method == "POST" and form.is_valid():
        post = form.save(commit=False)
        post.author = request.user
        post.save()
        form.save_m2m()
        return redirect("blog:post_detail", post_id=post.id)
    return render(request, "blog/post_form.html", {"form": form})


@login_required
def post_edit(request, post_id):
    post = get_object_or_404(Post, pk=post_id, author=request.user)
    form = PostForm(request.POST or None, request.FILES or None, instance=post)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("blog:post_detail", post_id=post.id)
    return render(request, "blog/post_form.html", {"form": form, "post": post})

request.POST or None 트릭 — POST 면 데이터, GET이면 None (빈 폼). if/else 분기를 줄이는 흔한 컨벤션입니다.

정리 #

이번 글에서 잡은 것:

  • Form이 해주는 일 — 렌더, 검증, 에러, 타입 변환, 입력 보존
  • 표준 흐름 — GET (빈 폼) / POST (is_validcleaned_data → redirect)
  • 템플릿 렌더 — {{ form.as_p }} 또는 필드별 직접
  • widget, attrs로 HTML 속성 / CSS 클래스
  • clean_<필드>clean()으로 사용자 정의 검증
  • forms.ValidationError로 에러 던지기
  • ModelForm — 모델에서 폼 자동 생성, Meta.model + fields
  • save(commit=False) + 추가 필드 채우기 + save() + save_m2m()
  • instance=post로 수정 폼
  • 파일 업로드 — request.FILES + enctype="multipart/form-data"
  • CSRF 토큰은 모든 POST에 필수
  • request.POST or None 트릭

다음 글(#7 Django Admin과 built-in 인증)에서는 Admin의 자동 CRUD 화면과 Django의 빌트인 인증 (User, login_required)을 다룹니다. #1에서 한 약속 — “Admin이 무료로 따라온다” — 를 거기서 확인합니다.

X