본문으로 건너뛰기
김신건의 로그

[Django] Form과 ModelForm

· 수정 · 📖 약 1분 · 526자/단어 #django #form #modelform #validation
django form, ModelForm, form validation, django widget, formset

정의

Django Form은 사용자 입력 정의·검증·렌더링·바인딩을 통합. 일반 Form은 임의 필드, ModelForm은 모델 자동 매핑. 검증과 보안(CSRF, SQL 인젝션 방지) 내장.

Form 기본

# blog/forms.py
from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea, min_length=10)
    subscribe = forms.BooleanField(required=False)

View에서 사용:

def contact(request):
    if request.method == "POST":
        form = ContactForm(request.POST)
        if form.is_valid():
            # 검증 통과: cleaned_data로 안전한 값
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            send_message(name, email, form.cleaned_data["message"])
            return redirect("thanks")
    else:
        form = ContactForm()

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

ModelForm: 모델과 자동 연결

from django import forms
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "body", "tags"]
        # 또는 "__all__" (모든 필드), 또는 exclude=["author", "created_at"]

        widgets = {
            "body": forms.Textarea(attrs={"rows": 10, "class": "prose"}),
        }
        labels = {"body": "본문"}
        help_texts = {"title": "제목을 명확히 작성하세요"}
        error_messages = {
            "title": {"required": "제목은 필수입니다"},
        }

View:

def post_create(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            form.save_m2m()       # M2M은 commit=False 후 별도 호출
            return redirect(post)
    else:
        form = PostForm()
    return render(request, "blog/post_form.html", {"form": form})

commit=False로 저장 전 인스턴스 받아 추가 처리 가능.

Field 타입

Form Field용도
CharField텍스트
IntegerField정수
FloatField, DecimalField실수
BooleanField체크박스
EmailField이메일 검증
URLFieldURL
SlugFieldslug
UUIDFieldUUID
DateField, DateTimeField, TimeField날짜/시간
ChoiceField단일 선택
MultipleChoiceField다중 선택
ModelChoiceFieldDB 객체 선택 (FK 용)
ModelMultipleChoiceField다중 DB 객체 (M2M)
FileField, ImageField파일
JSONFieldJSON
class MyForm(forms.Form):
    rating = forms.ChoiceField(choices=[(1, "Bad"), (2, "OK"), (3, "Good")])
    interests = forms.MultipleChoiceField(
        choices=[("tech", "Tech"), ("design", "Design")],
        widget=forms.CheckboxSelectMultiple,
    )
    category = forms.ModelChoiceField(queryset=Category.objects.all())
    tags = forms.ModelMultipleChoiceField(
        queryset=Tag.objects.all(),
        widget=forms.CheckboxSelectMultiple,
    )

Widget

Field가 데이터 타입, Widget이 HTML 표현. 기본 widget이 있지만 변경 가능.

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "body", "tags"]
        widgets = {
            "title": forms.TextInput(attrs={
                "class": "form-control",
                "placeholder": "제목 입력",
                "autofocus": True,
            }),
            "body": forms.Textarea(attrs={"rows": 10}),
            "tags": forms.CheckboxSelectMultiple,
        }

자주 쓰는 widget:

  • TextInput, Textarea, EmailInput, URLInput, NumberInput
  • Select, SelectMultiple, RadioSelect, CheckboxSelectMultiple
  • DateInput, DateTimeInput, TimeInput
  • PasswordInput
  • HiddenInput
  • FileInput, ClearableFileInput

검증

빌트인

각 Field가 기본 검증 (max_length, required, type 등).

clean_<field>

특정 필드 추가 검증.

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "body"]

    def clean_title(self):
        title = self.cleaned_data["title"]
        if "스팸" in title:
            raise forms.ValidationError("부적절한 단어 포함")
        return title.strip()

clean (전체)

여러 필드의 관계 검증.

class RegistrationForm(forms.Form):
    password = forms.CharField(widget=forms.PasswordInput)
    password_confirm = forms.CharField(widget=forms.PasswordInput)

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

validators 인자

from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator

class MyForm(forms.Form):
    age = forms.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(150)])
    phone = forms.CharField(validators=[
        RegexValidator(r"^\d{3}-\d{4}-\d{4}$", "010-1234-5678 형식")
    ])

에러 표시

<form method="post">
    {% csrf_token %}

    {% if form.non_field_errors %}
        <div class="errors">
            {{ form.non_field_errors }}
        </div>
    {% endif %}

    {% for field in form %}
        <div class="form-group">
            {{ field.label_tag }}
            {{ field }}
            {% for error in field.errors %}
                <div class="error">{{ error }}</div>
            {% endfor %}
        </div>
    {% endfor %}

    <button type="submit">저장</button>
</form>

form.errors는 dict, form.non_field_errorsclean()에서 발생한 전체 에러.

init 커스터마이즈

런타임 동적 필드 변경.

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "body", "category"]

    def __init__(self, *args, user=None, **kwargs):
        super().__init__(*args, **kwargs)
        if user:
            self.fields["category"].queryset = Category.objects.filter(owner=user)
        self.fields["title"].widget.attrs.update({"class": "form-control"})

View:

form = PostForm(request.POST or None, user=request.user)

Formset: 여러 form 동시 처리

from django.forms import formset_factory, modelformset_factory

# 같은 폼 N개
TagFormset = formset_factory(TagForm, extra=3, can_delete=True)

formset = TagFormset(request.POST or None)
if formset.is_valid():
    for form in formset:
        if form.cleaned_data:
            form.save()

modelformset_factory는 모델 단위. inlineformset_factory는 부모-자식 관계 편집에 유용 (Post + Comments 같이).

File Upload

class UploadForm(forms.Form):
    file = forms.FileField()

def upload(request):
    if request.method == "POST":
        form = UploadForm(request.POST, request.FILES)    # FILES 필수!
        if form.is_valid():
            f = form.cleaned_data["file"]
            save_to_disk(f)
            return redirect("done")
    ...
<form method="post" enctype="multipart/form-data">    {# enctype 필수 #}
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">업로드</button>
</form>

enctype="multipart/form-data" 빠뜨리면 파일 안 전송.

crispy-forms / django-widget-tweaks

{{ form.as_p }}는 빠르나 디자인 제한. 커스텀 렌더링이 번거롭다면:

  • django-crispy-forms: Bootstrap/Tailwind 통합 폼 렌더링
  • django-widget-tweaks: 템플릿에서 위젯 attrs 조정
{% load widget_tweaks %}
{{ form.title|add_class:"form-control"|attr:"placeholder:제목" }}

함정

1. is_valid() 호출 전 cleaned_data 접근

form.cleaned_data    # AttributeError! is_valid() 먼저

2. ModelForm + commit=False 후 M2M

post = form.save(commit=False)
post.author = user
post.save()
form.save_m2m()    # M2M은 PK 필요 → 명시 호출

또는:

form.instance.author = user
form.save()    # 이젠 save_m2m 자동

3. 검증 시점에 외부 데이터

clean()은 모델 저장 전 호출. DB 조회 시 동시성 고려.

4. ValidationError 위치

  • 필드 레벨: clean_<field> 또는 validators
  • 폼 레벨: clean()에서 raise
  • 모델 레벨: model.clean() (full_clean에서 호출)

보안

  • CSRF: POST 폼에 {% csrf_token %} 필수
  • HTML escape: 템플릿 자동 (별도 처리 불필요)
  • SQL injection: ORM 사용 시 자동 방지
  • 파일 업로드: 확장자/MIME 검증, 저장 위치 격리, 권한 제한

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기