Skip to main content
Summary

사용자 입력은 별도의 입구다

관리자 페이지는 내부 운영자가 데이터를 다루는 도구다. 하지만 실제 서비스의 사용자는 /admin/에 들어오지 않는다. 사용자는 게시글 작성 화면, 댓글 입력창, 검색창, 회원가입 화면 같은 일반 웹 화면에서 데이터를 입력한다.

이때 필요한 것이 폼이다. HTML 폼은 브라우저가 사용자 입력을 서버로 보내는 표준 방식이고, Django Form은 그 입력이 올바른지 검사하는 Python 코드다. ModelForm은 한 걸음 더 나아가 모델과 연결된 입력 화면을 빠르게 만든다.

폼 처리는 단순히 입력칸을 보여 주는 일이 아니다. 빈 값은 없는지, 이메일 형식은 맞는지, 너무 긴 값은 아닌지, 사용자가 조작한 값은 아닌지 확인해야 한다. 웹 애플리케이션의 많은 기능은 사용자 입력에서 시작되므로, 폼 검증은 안전성과 사용성을 동시에 책임진다.

폼과 사용자 입력 처리하기AI 생성 이미지

GET과 POST의 차이

폼을 이해하려면 먼저 요청 방식부터 구분해야 한다.

GET 요청은 주로 데이터를 조회할 때 사용한다. 게시글 목록 보기, 상세 페이지 보기, 검색처럼 서버의 데이터를 바꾸지 않는 요청에 어울린다. 검색어는 보통 주소 뒤의 쿼리 문자열로 붙는다.

<form method="get" action="{% url 'boards:post_list' %}">
  <input type="text" name="keyword">
  <button type="submit">검색</button>
</form>

이 폼에서 keyword=django를 입력하면 브라우저 주소는 대략 다음과 같이 바뀐다.

/boards/?keyword=django

반대로 POST 요청은 서버의 상태를 바꿀 때 사용한다. 게시글 작성, 댓글 등록, 회원가입, 비밀번호 변경처럼 데이터가 생성되거나 수정되는 작업은 POST로 처리하는 것이 기본이다.

<form method="post" action="{% url 'boards:post_create' %}">
  {% csrf_token %}
  <input type="text" name="title">
  <textarea name="content"></textarea>
  <button type="submit">저장</button>
</form>

Django 공식 문서에서도 폼 처리의 기본 흐름은 분명하다. GET 요청에서는 빈 폼을 보여 주고, POST 요청에서는 제출된 데이터로 폼을 만들고, is_valid()로 검증한 뒤, 성공하면 cleaned_data를 사용하고 다른 URL로 이동한다.1

여기서 중요한 원칙이 있다. GET은 보여 주기, POST는 바꾸기다. 이 원칙을 지키면 브라우저 새로고침, 뒤로 가기, 검색 엔진 접근, 링크 공유 같은 상황에서 예측 가능한 동작을 만들 수 있다.

CSRF 토큰이 필요한 이유

POST 폼에는 {% csrf_token %}을 넣어야 한다. CSRF는 Cross-Site Request Forgery의 줄임말로, 다른 사이트에서 사용자의 브라우저를 속여 원하지 않는 요청을 보내게 만드는 공격이다.

예를 들어 사용자가 우리 사이트에 로그인한 상태라고 하자. 브라우저는 로그인 쿠키를 가지고 있다. 공격자가 만든 다른 사이트에서 우리 사이트의 게시글 삭제 주소로 몰래 POST 요청을 보내면, 브라우저는 쿠키를 함께 보낼 수 있다. 서버 입장에서는 로그인한 사용자의 요청처럼 보일 수 있다.

CSRF 토큰은 이 문제를 막기 위한 확인 값이다. Django가 렌더링한 정상 폼에는 서버가 발급한 토큰이 들어간다. 서버는 POST 요청을 받을 때 그 토큰이 맞는지 확인한다. 토큰이 없거나 맞지 않으면 요청을 거부한다.

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">보내기</button>
</form>

{% csrf_token %}은 보통 <form method="post"> 안쪽에 둔다. GET 폼에는 데이터를 변경하는 요청이 아니므로 일반적으로 필요하지 않다.

forms.Form으로 입력 검증하기

먼저 모델과 직접 연결되지 않는 폼부터 보자. 문의하기, 검색 조건, 비밀번호 확인, 여러 모델을 한 번에 다루는 입력처럼 데이터베이스 테이블 하나와 정확히 대응하지 않는 경우에는 forms.Form이 적합하다.

앱 폴더에 forms.py를 만들고 다음과 같이 작성한다.

# boards/forms.py
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(label='이름', max_length=30)
    email = forms.EmailField(label='이메일')
    message = forms.CharField(
        label='내용',
        min_length=10,
        widget=forms.Textarea(attrs={'rows': 5}),
    )

    def clean_message(self):
        message = self.cleaned_data['message']

        if '광고' in message:
            raise forms.ValidationError('광고성 문구는 입력할 수 없습니다.')

        return message

CharField는 문자열 입력을 검사한다. EmailField는 이메일 형식인지 확인한다. min_length와 max_length는 길이 제한이다. widget은 브라우저에 어떤 HTML 입력 요소로 보여 줄지 정한다. 여기서는 긴 내용을 입력받기 위해 <textarea>를 사용했다.

clean_message()는 message 필드만을 위한 추가 검증 메서드다. 이름은 반드시 clean_필드명 형식이어야 한다. 이 메서드 안에서는 이미 기본 검증을 통과한 값을 self.cleaned_data에서 꺼낼 수 있다. 문제가 있으면 forms.ValidationError를 발생시키고, 문제가 없으면 정리된 값을 반환한다.

이 폼을 View에서 사용해 보자.

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


def contact(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)

        if form.is_valid():
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            message = form.cleaned_data['message']

            # 여기에서 이메일 발송, 로그 저장, 외부 API 호출 등을 처리할 수 있다.
            return redirect('boards:contact_done')
    else:
        form = ContactForm()

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

핵심은 두 줄이다.

form = ContactForm(request.POST)
form.is_valid()

request.POST를 넣어 만든 폼을 바운드 폼이라고 한다. 사용자가 제출한 데이터가 묶여 있는 폼이라는 뜻이다. form.is_valid()를 호출하면 Django는 필수 값, 길이, 이메일 형식, 직접 만든 clean_message() 같은 검사를 실행한다.

검증을 통과한 값은 form.cleaned_data에 들어간다. 이름 그대로 정리된 데이터다. 예를 들어 문자열 앞뒤 공백이 정리되거나, 날짜 문자열이 Python의 날짜 객체로 바뀌거나, 체크박스 값이 True 또는 False로 바뀔 수 있다. 그래서 View에서는 request.POST['email']을 직접 쓰기보다 form.cleaned_data['email']을 사용해야 한다.

검증 실패를 화면에 보여 주기

사용자 입력은 자주 실패한다. 제목을 비워 둘 수도 있고, 이메일 형식을 틀릴 수도 있고, 글자 수 제한을 넘길 수도 있다. 좋은 폼은 실패를 막는 것보다 실패 이유를 정확히 알려 준다.

가장 간단한 템플릿은 다음과 같다.

<!-- boards/templates/boards/contact.html -->
<form method="post">
  {% csrf_token %}

  {{ form.non_field_errors }}

  {% for field in form %}
    <div>
      {{ field.label_tag }}
      {{ field }}
      {{ field.errors }}

      {% if field.help_text %}
        <p>{{ field.help_text }}</p>
      {% endif %}
    </div>
  {% endfor %}

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

field.errors는 각 필드에 연결된 오류를 보여 준다. 예를 들어 이메일 형식이 틀리면 이메일 입력칸 근처에 오류가 표시된다.

form.non_field_errors는 특정 필드 하나에 속하지 않는 오류를 보여 준다. 예를 들어 두 입력값의 조합이 문제일 때 사용한다.

class ContactForm(forms.Form):
    name = forms.CharField(label='이름', max_length=30)
    email = forms.EmailField(label='이메일')
    message = forms.CharField(label='내용', widget=forms.Textarea)

    def clean(self):
        cleaned_data = super().clean()
        name = cleaned_data.get('name')
        message = cleaned_data.get('message')

        if name and message and name == message.strip():
            raise forms.ValidationError('이름과 내용은 같을 수 없습니다.')

        return cleaned_data

clean()은 폼 전체를 검사할 때 사용한다. 여러 필드를 함께 봐야 하는 검증에 적합하다. 여기서 발생한 오류는 특정 입력칸 하나가 아니라 폼 전체 오류로 처리되므로 non_field_errors에 표시된다.

에러 메시지를 직접 바꾸고 싶다면 필드에 error_messages를 지정할 수 있다.

name = forms.CharField(
    label='이름',
    max_length=30,
    error_messages={
        'required': '이름을 입력해 주세요.',
        'max_length': '이름은 30자 이하로 입력해 주세요.',
    },
)

이렇게 하면 Django가 만든 기본 문구 대신 서비스에 맞는 자연스러운 안내를 보여 줄 수 있다.

ModelForm으로 게시글 작성 폼 만들기

forms.Form은 필드를 직접 정의한다. 반면 ModelForm은 모델을 바탕으로 폼 필드를 만든다. 게시글 작성처럼 모델의 필드와 입력 화면이 거의 일치하는 경우에는 ModelForm이 편하다.

앞서 만든 Post 모델이 다음과 비슷하다고 가정하자.

class Post(models.Model):
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
    content = models.TextField()
    is_published = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

이 모델로 게시글 폼을 만든다.

# 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']
        labels = {
            'title': '제목',
            'content': '본문',
            'category': '카테고리',
            'is_published': '공개 여부',
        }
        widgets = {
            'content': forms.Textarea(attrs={'rows': 8}),
        }
        error_messages = {
            'title': {
                'required': '제목을 입력해 주세요.',
                'max_length': '제목은 100자 이하로 입력해 주세요.',
            },
        }

Meta.model은 어떤 모델과 연결할지 지정한다. fields는 사용자에게 입력받을 필드를 정한다. 여기서 created_at은 자동 생성되는 값이므로 폼에 포함하지 않았다.

실무에서는 fields = '__all__'을 신중하게 사용해야 한다. 나중에 모델에 내부 관리용 필드나 권한 관련 필드가 추가되면 의도치 않게 사용자 화면에 노출될 수 있다. 가능하면 사용자에게 받을 필드를 명시하는 편이 안전하다.

작성 View는 짧다.

# 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})

ModelForm의 장점은 form.save()다. 검증된 데이터를 이용해 Post 객체를 만들고 데이터베이스에 저장한다. 저장된 객체가 반환되므로, 곧바로 상세 페이지로 이동할 때 post.pk를 사용할 수 있다.

템플릿은 앞에서 본 방식과 거의 같다.

<!-- boards/templates/boards/post_form.html -->
<form method="post">
  {% csrf_token %}

  {{ form.non_field_errors }}

  {% for field in form %}
    <div>
      {{ field.label_tag }}
      {{ field }}
      {{ field.errors }}
    </div>
  {% endfor %}

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

수정 폼은 instance가 핵심이다

게시글 수정도 같은 PostForm을 사용할 수 있다. 차이는 기존 객체를 폼에 넣어 준다는 점이다.

from django.shortcuts import get_object_or_404, render, redirect
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=post가 없으면 Django는 기존 게시글을 수정하지 않고 새 게시글을 만들려고 한다. GET 요청에서 PostForm(instance=post)를 사용하면 기존 값이 입력칸에 채워진다. POST 요청에서 PostForm(request.POST, instance=post)를 사용하면 제출된 값으로 기존 객체를 수정한다.

저장 전에 값을 추가해야 할 때도 있다. 예를 들어 나중에 사용자 인증을 붙인 뒤, 게시글 작성자를 현재 로그인한 사용자로 저장한다고 하자. 작성자 필드는 사용자가 직접 선택하면 안 되므로 폼의 fields에는 넣지 않는다. 대신 View에서 저장 직전에 넣는다.

if form.is_valid():
    post = form.save(commit=False)
    post.author = request.user
    post.save()
    return redirect('boards:post_detail', pk=post.pk)

commit=False는 아직 데이터베이스에 저장하지 말고 모델 객체만 만들어 달라는 뜻이다. 이렇게 하면 폼에 없는 값을 View에서 채운 뒤 저장할 수 있다.

폼 처리 흐름 정리

Django의 폼 처리는 다음 흐름으로 기억하면 된다.

  1. GET 요청이면 빈 폼 또는 기존 값이 들어간 폼을 만든다.

  2. 템플릿에서 폼을 보여 준다.

  3. 사용자가 제출하면 POST 요청으로 들어온다.

  4. request.POST로 바운드 폼을 만든다.

  5. form.is_valid()로 검증한다.

  6. 성공하면 cleaned_data를 사용하거나 form.save()로 저장한다.

  7. 저장 후에는 다른 URL로 redirect()한다.

  8. 실패하면 같은 템플릿을 다시 렌더링해 입력값과 오류를 보여 준다.

이 구조는 반복해서 등장한다. 회원가입, 로그인, 게시글 작성, 댓글 등록, 프로필 수정도 큰 흐름은 같다. 차이는 어떤 폼을 쓰는지, 어떤 검증을 추가하는지, 검증 성공 후 무엇을 저장하는지뿐이다.

폼은 사용자의 입력을 서버 코드로 들여오는 입구다. 입구에서 값을 검증하고, 실패 이유를 알려 주고, 검증된 값만 저장하면 이후 기능을 훨씬 안정적으로 만들 수 있다. 다음 장에서는 이 폼을 이용해 게시글 목록, 작성, 상세, 수정, 삭제를 연결하는 CRUD 게시판을 완성한다.

참고

1Working with forms | Django documentation

책 목록

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

전체 목차 보기

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

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

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

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

참여하기