장고 기초 #6 Forms와 ModelForm
#4 URL과 Views의 끝에서 request.POST.get("title", "").strip() 같은 손작업으로 폼 데이터를 받았습니다. 항상 이러면 검증 / 에러 메시지 / 재렌더 / CSRF를 매번 다시 짜게 됩니다. Django Form이 이걸 한 번에 정리합니다.
Form의 정체 — 무엇을 해주는가 #
Django Form은 다섯 가지 일을 한곳에서 해줍니다.
- HTML 폼 렌더링 —
<input>,<select>,<textarea>자동 생성 - 데이터 검증 — 타입, 길이, 정규식, 사용자 정의 규칙
- 에러 메시지 — 한글 가능, 필드별 표시
- 타입 변환 — 문자열 입력 → Python 타입 (
int,date,datetime) - 재렌더 시 입력 보존 — 검증 실패 시 사용자가 입력했던 값 그대로
Form 클래스 한 번 #
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에서 사용 — 표준 패턴 #
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의 표준 흐름 입니다. 폼 처리 뷰는 매번 이 패턴으로 작성합니다.
- GET — 빈 form 인스턴스
- POST —
request.POST로 form 만들기 is_valid()— 검증 실행- 성공 —
cleaned_data사용 + redirect - 실패 — 같은 폼 재렌더 (에러 메시지가 자동으로 들어감)
템플릿에서 Form 렌더링 #
가장 짧은 방법:
{% 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 타입 바꾸기 #
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
#
한 필드 검증 #
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 usernameclean_<필드명> 메소드는 그 필드의 검증이 끝난 뒤 호출됩니다. 반드시 검증된 값을 반환 해야 합니다.
여러 필드를 함께 검증 #
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 cleanedclean()은 모든 필드별 검증 이후. 두 필드 비교 같은 폼 단위 규칙은 여기서.
특정 필드에 에러를 매달고 싶다면:
def clean(self):
cleaned = super().clean()
if cleaned.get("password") != cleaned.get("password_confirm"):
self.add_error("password_confirm", "비밀번호가 일치하지 않습니다.")
return cleanedModelForm — 모델에서 폼을 자동 생성 #
같은 필드 정의를 모델과 폼 양쪽에 쓰는 건 비효율입니다. ModelForm은 모델의 필드를 그대로 폼으로 만들어줍니다.
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()
#
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
#
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_to — MEDIA_ROOT 안의 저장 경로. %Y/%m/ 같은 형식 표시자도 가능 (covers/2026/04/...같이).
ImageField는 Pillow 패키지가 필요합니다 — uv add Pillow.
2. View에서 request.FILES
#
@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 method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button>업로드</button>
</form>enctype="multipart/form-data"가 빠지면 파일이 오지 않습니다 — 자주 빼먹습니다.
CSRF — 한 번 더 #
Django의 모든 POST 요청은 CSRF 토큰을 요구합니다. 빠뜨리면 403 Forbidden.
<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 개를 한 번에 저장.
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으로 내려가는 게 자연스럽습니다.
자주 쓰는 패턴 — 한곳에 #
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_valid→cleaned_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이 무료로 따라온다” — 를 거기서 확인합니다.