7편 — 폼과 CSRF = 글쓰기가 되는 원리 - 테크창
프로그래밍

7편 — 폼과 CSRF = 글쓰기가 되는 원리

AI 활용 테크창 연구팀이 인공지능을 활용해 작성하고, 사실 확인과 편집 심사를 거쳐 발행한 칼럼입니다. 제작 과정 보기

조회 16회
댓글 0개
0
📚 연재 시리즈 AI가 짜준 Django, 이제 알고 씁니다 8 / 8

🎬 지난 이야기

6편에서는 모델을 바꿨을 때 makemigrations와 migrate로 "DB야, 나 바뀌었어"라고 알려주는 과정을 봤습니다. 이제 표(테이블)는 준비됐습니다. 그런데 그 표에 글이 들어오려면 사용자가 지나가는 입구가 필요하죠. 오늘은 그 입구, 폼을 뜯어봅니다.

😵 이런 적 있죠?

AI가 만들어준 글쓰기 화면이 예쁘게 떴습니다. 제목도 쓰고 내용도 쓰고 "등록" 버튼을 눌렀는데, 갑자기 이런 화면이 나옵니다.

Forbidden (403)
CSRF verification failed. Request aborted.

AI에게 "에러 났어"라고 말하면 {% csrf_token %}을 한 줄 넣어줍니다. 그러면 신기하게 해결되죠. 그런데 이 토큰은 대체 뭘까요? 그리고 if request.method == 'POST': 같은 분기는 왜 필요하고, 폼 검증과 commit=False, 리다이렉트는 각각 무슨 일을 할까요? 오늘은 그중 가장 먼저 마주치는 403, 즉 403이 난 요청이 뷰까지 가는 길을 따라가며 토큰이 왜 필요한지 풉니다. POST 분기, commit=False, 리다이렉트는 그 길 위에서 짧게 짚고 갑니다.

🤖 AI가 짜준 코드

프롬프트는 이 정도였을 겁니다.

"로그인한 사용자가 질문을 등록하는 폼 페이지 만들어줘. 카테고리 선택이랑 이미지 첨부도 되게."

아래 뷰는 테크창의 실제 코드로, community/views/question_views.py의 question_create입니다. 폼 클래스도 실제 코드이고, 뒤에 나오는 HTML만 설명용 예제입니다.

@login_required(login_url='common:login')
def question_create(request):
    if request.method == 'POST':
        form = QuestionForm(request.POST, request.FILES)
        if form.is_valid():
            try:
                question = form.save(commit=False)
                question.author = request.user  # author 속성에 로그인 계정 저장
                question.create_date = timezone.now()
                question.save()

                # 다중 이미지 첨부 저장
                _save_question_images(request, question)

                # 질문 작성 포인트 지급 (50포인트) - 유틸리티 함수 사용
                award_points(
                    user=request.user,
                    amount=50,
                    description=f'질문 작성: {question.subject[:30]}',
                    reason=PointHistory.REASON_ADMIN
                )

                messages.success(request, '게시글이 성공적으로 등록되었습니다. (+50 포인트)')
                return redirect('community:index')
            except Exception as e:
                messages.error(request, f'게시글 저장 중 오류가 발생했습니다: {str(e)}')
                print(f"Question creation error: {e}")  # 서버 로그용
        else:
            messages.error(request, '폼 유효성 검사에 실패했습니다. 입력 내용을 확인해주세요.')
    else:
        form = QuestionForm()
    context = {'form': form}
    return render(request, 'community/question_form.html', context)

폼 클래스는 community/forms.py에 있고, 앞부분은 이렇게 시작합니다.

class QuestionForm(forms.ModelForm):
    """질문 생성/수정 폼 - 동적 카테고리 로딩 지원"""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 동적으로 활성 카테고리만 로드
        self.fields['category'].queryset = Category.objects.all().order_by('name')
        self.fields['category'].empty_label = '카테고리를 선택하세요'

(설명용 예제)

<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">등록</button>
</form>

실행하면 보이는 것

  • 주소창으로 처음 들어가면(GET) 빈 입력칸들과 "등록" 버튼이 보입니다.
  • 내용을 채워 등록을 누르면(POST) 검증을 통과했을 때 community:index라는 URL 이름의 페이지로 이동합니다. 템플릿이 messages를 출력한다면 성공 메시지가 보입니다.
  • 검증에 실패하면 같은 화면이 다시 뜹니다. 이때도 템플릿이 messages를 출력한다면 "폼 유효성 검사에 실패했습니다. 입력 내용을 확인해주세요." 메시지가 보입니다.
  • 토큰이 빠져 있으면 위의 403 화면이 나옵니다.

🔍 한 줄씩 통역

먼저 큰 그림: 같은 주소, 두 가지 손님

글쓰기 주소는 하나인데 손님은 두 종류입니다. "글쓰기 화면 좀 보여주세요"라고 온 손님(GET)과 "작성한 글 여기 있어요"라고 온 손님(POST)입니다. 비유하자면 구청 민원 창구입니다. 같은 창구에서 "신청서 양식 주세요"라는 사람과 "작성한 신청서 접수할게요"라는 사람을 모두 응대하는 셈이죠. request.method == 'POST' 분기가 이 둘을 구분합니다.

표 1. 질문 등록 요청의 단계별 흐름

단계 무슨 일이 일어나나
1. GET 요청 브라우저가 글쓰기 주소로 들어옵니다. 뷰가 QuestionForm()으로 빈 폼을 만들어 템플릿에 넘깁니다.
2. 폼 화면 응답 템플릿이 입력칸과 hidden 토큰 필드가 들어간 HTML을 만들어 보냅니다.
3. POST 요청 사용자가 등록을 누르면 입력 데이터와 토큰이 함께 전송됩니다.
4. CSRF 확인 뷰가 실행되기 전에 CsrfViewMiddleware가 쿠키와 토큰을 대조합니다. 실패하면 403입니다.
5. 검증 QuestionForm(request.POST, request.FILES)로 데이터를 담고 is_valid()로 검사합니다.
6. 저장 save(commit=False)로 객체를 받아 author 등을 채우고 question.save()로 DB에 넣습니다.
7. 이동 redirect(...)로 다른 페이지로 보냅니다.
GET  → 빈 폼 보여주기
POST → CSRF 확인 → 검증 → 저장 → 리다이렉트

1) GET이면 빈 폼 보여주기

    else:
        form = QuestionForm()
    context = {'form': form}
    return render(request, 'community/question_form.html', context)

POST가 아니면 인자 없는 빈 폼을 만들어 템플릿에 넘깁니다. 빈 신청서 양식을 꺼내 창구에 올려두는 일이죠.

2) {% csrf_token %}의 정체: 도장과 입장권, 둘 다 있어야 통과

이 태그는 HTML로 렌더링되면 대략 이런 hidden 입력칸이 됩니다.

<input type="hidden" name="csrfmiddlewaretoken" value="(긴 무작위 문자열)">

화면에는 보이지 않지만 폼을 제출할 때 함께 전송됩니다. 값은 응답할 때마다 마스킹되어 달라집니다.

왜 필요할까요? CSRF 공격은 악성 사이트가 링크, 폼 버튼, JavaScript로 이미 로그인한 사용자의 자격 증명을 써서 우리 사이트에 원치 않는 동작을 시키는 공격입니다. 예컨대 여러분이 테크창에 로그인해 둔 채로 이상한 사이트를 열었는데, 그 사이트가 몰래 테크창으로 "글 등록" 요청을 쏴버리는 식입니다. 브라우저는 쿠키를 자동으로 같이 보내니, 서버 입장에서는 정상 요청처럼 보일 수 있습니다.

그래서 Django는 쿠키와 폼의 hidden 토큰을 대조합니다. 비유하면 이렇습니다. 도장(쿠키)은 브라우저가 어느 사이트에서 요청하든 자동으로 찍어 보내고, 입장권(토큰)은 우리 폼 안에만 들어 있습니다. 놀이기구 앞에서 도장과 입장권을 함께 확인하니, 우리 사이트가 발급한 폼으로 온 사람만 통과합니다. 악성 사이트는 도장은 자동으로 붙게 만들 수 있어도 폼 안의 입장권은 알 수 없어서 통과하지 못합니다. 비유 없이 말하면, 공격 사이트가 브라우저로 하여금 쿠키를 자동으로 붙여 보내게 만들 수는 있어도, 우리 폼 안에 들어 있는 토큰 값은 읽을 수 없어서 올바른 csrfmiddlewaretoken을 채울 수 없습니다.

규칙은 이렇습니다. GET, HEAD, OPTIONS, TRACE를 제외한 요청에는 CSRF 쿠키와 올바른 csrfmiddlewaretoken 필드가 모두 있어야 하고, 하나라도 없거나 틀리면 403입니다. 글쓰기는 POST이므로 해당됩니다. 이것이 처음에 만난 403의 정체입니다.

3) POST면 데이터를 폼에 담아 검증

        form = QuestionForm(request.POST, request.FILES)
        if form.is_valid():

request.POST는 입력한 글자 데이터, request.FILES는 첨부 파일입니다. 이 둘을 폼에 담으면 "채워진 신청서"가 되고, is_valid()가 창구 직원처럼 빈칸은 없는지, 형식은 맞는지 검사해 True/False를 돌려줍니다. 통과하면 저장 블록으로 들어가고, 실패하면 else에서 에러 메시지를 띄웁니다.

첨부 파일이 request.FILES로 들어오려면 POST로 전송되고 <form>에 enctype="multipart/form-data"가 있어야 합니다.

4) commit=False로 빈칸 채우고 저장

                question = form.save(commit=False)
                question.author = request.user
                question.create_date = timezone.now()
                question.save()

사용자는 제목과 내용은 입력하지만, "누가 썼는지"는 입력하지 않습니다. 그건 서버가 알고 있죠(request.user). 그런데 form.save()를 바로 하면 author 없이 저장되려 합니다. 그래서 commit=False를 씁니다. 이러면 아직 DB에 저장되지 않은 모델 객체만 돌려줍니다. 서류를 접수하기 전에 담당자 칸에 도장을 찍는 단계입니다. author와 create_date를 채운 뒤, 직접 question.save()를 호출해야 비로소 DB의 표(5편의 그 테이블)에 한 줄이 추가됩니다.

5) 끝나면 다른 페이지로 보내기

저장 성공 후에는 redirect('community:index')로 이동시킵니다. 왜 굳이 보낼까요? 저장 직후 같은 POST 화면을 그대로 응답하면, 사용자가 새로고침할 때 브라우저가 같은 POST를 다시 보내 글이 중복 등록될 수 있습니다. 그래서 저장 뒤에는 다른 페이지로 보내 GET 요청으로 바꿔 줍니다. 이런 패턴을 Post/Redirect/Get이라고 부르는데, 이는 웹 개발 일반에서 쓰는 패턴 이름입니다. Django 튜토리얼 4부에서도 POST 처리 뒤 리다이렉트하는 이유를 볼 수 있습니다(https://docs.djangoproject.com/en/stable/intro/tutorial04/).

redirect 앞뒤에 있는 나머지 줄도 코드에서 확인되는 범위만 짚어 보겠습니다.

  • _save_question_images(request, question): 주석에 "다중 이미지 첨부 저장"이라고 적혀 있고, 요청 request와 방금 저장한 question을 넘겨 받습니다. 이 글에서는 이 함수의 내부 구현은 확인하지 않았으니, 질문 저장 직후 첨부 이미지를 처리하는 단계라고만 이해해 주세요.
  • award_points(...): 로그인한 사용자(user)에게 amount=50을 지급하고, description에 질문 제목 앞 30자를 담아 기록하는 호출입니다. 성공 메시지의 "(+50 포인트)"가 이 값과 짝입니다.
  • messages.success(...): 다음 화면에 보여줄 성공 알림을 등록합니다.
  • try/except: 저장 중 예외가 나면 messages.error로 사용자에게 알리고, print로 서버 로그에 남깁니다. 이 경우에는 redirect에 가지 않고 맨 아래 render로 같은 폼 화면이 다시 뜹니다.

맨 위의 @login_required

로그인하지 않은 사용자는 login_url로 지정한 로그인 페이지로 리다이렉트되고, 현재 경로가 ?next= 쿼리로 함께 전달됩니다. 이 부분은 다음 편에서 자세히 보겠습니다.

⚠️ 자주 깨지는 지점

1) CSRF 403

Forbidden (403)
CSRF verification failed. Request aborted.

  • 원인: POST 폼에 토큰이 없거나 틀렸습니다. 가장 흔한 경우는 <form> 안에 {% csrf_token %}을 빠뜨린 것입니다. 토큰이 <form> 바깥에 있어도 전송되지 않습니다.
  • 고치는 순서
  • 해당 템플릿의 <form method="post">를 찾습니다.
  • 그 안쪽에 {% csrf_token %}을 넣습니다.
  • 페이지를 새로고침한 뒤 다시 제출합니다.
  • 그래도 안 되면 개발자도구에서 csrfmiddlewaretoken hidden 필드가 렌더링됐는지 확인합니다.

주의할 점이 있습니다. 보안 검사를 끄면 에러는 사라지지만 CSRF 방어도 함께 사라집니다. csrf_exempt를 붙이거나 CsrfViewMiddleware를 제거하면 해당 뷰에는 CSRF 보호가 적용되지 않습니다(Django 공식 문서: https://docs.djangoproject.com/en/stable/howto/csrf/). AI가 "CSRF 검사 해제"를 제안하면 따르지 말고 토큰을 넣는 쪽으로 가세요.

AI 코드 리뷰 체크리스트

AI에게 글쓰기 기능을 맡겼다면 결과물에 이 네 가지를 물어보세요.

  1. <form> 안에 {% csrf_token %}이 있는가?
  2. 뷰에 csrf_exempt가 붙어 있지 않은가?
  3. 저장 뒤에 redirect로 이동하는가?
  4. author를 클라이언트 입력이 아니라 request.user로 넣었는가?

2) 제출했는데 저장이 안 되고 같은 화면만 다시 뜸

폼 유효성 검사에 실패했습니다. 입력 내용을 확인해주세요.

  • 원인: is_valid()가 False입니다. 필수 칸이 비었거나 형식이 맞지 않는 경우가 대부분입니다.
  • 고치는 순서
  • 빠진 필수 입력(예: 제목이나 내용 같은 필수 칸)이 없는지 확인합니다.
  • 템플릿에서 {{ form.errors }}를 잠깐 출력해 어느 필드가 문제인지 봅니다.
  • 첨부 파일이 안 올라간다면 앞서 3번 항목에서 본 조건(POST, 파일 전송, enctype="multipart/form-data")을 다시 확인합니다.

🎮 직접 해보기

테크창의 질문 등록 폼에서 토큰을 직접 확인해 봅니다.

  1. 테크창에 로그인한 뒤 질문 등록 페이지를 엽니다.
  2. 브라우저 개발자도구(F12)를 열고 Elements(요소) 탭에서 csrfmiddlewaretoken을 검색합니다.
  3. type="hidden"인 입력칸과 긴 value 값이 보이는지 확인합니다.
  4. 페이지를 새로고침한 뒤 같은 값인지 비교해 봅니다. 값이 달라져 있다면 "응답마다 마스킹되어 달라진다"는 설명을 눈으로 확인한 것입니다.
  5. 이제 403을 직접 일으켜 봅니다. Elements 탭에서 csrfmiddlewaretoken hidden 입력칸을 선택해 Delete 키로 삭제합니다.
  6. 제목과 내용을 채워 등록을 누릅니다. 도입에서 본 "CSRF verification failed. Request aborted." 403 화면이 뜨면, 토큰이 빠진 POST가 4단계 CSRF 확인에서 막힌 것입니다.
  7. 이 단계는 실제 운영 사이트가 아니라 로컬 개발 서버(python manage.py runserver)에서 해 주세요. 운영 사이트에서 등록하면 공개 게시글이 생기고 코드상 50포인트가 지급됩니다. 뒤로 가서 페이지를 새로고침해 토큰이 다시 들어 있는 폼을 받고, 제목과 내용을 채워 등록한 뒤 community:index 페이지로 이동하는 흐름이 표 1과 맞는지 따라가 봅니다.

막히면 Q&A 게시판에 질문하세요. 어느 단계에서 어떤 화면이 나왔는지 적어주시면 답하기 훨씬 수월합니다.

⏭️ 다음 편 예고

그런데 이 글쓰기 뷰 맨 위의 @login_required 한 줄은, 로그인 안 한 손님을 어떻게 알아보고 어디로 돌려보내는 걸까요? 8편에서 "로그인은 어떻게 나를 기억하나"를 풉니다.


📚 시리즈: AI가 짜준 Django, 이제 알고 씁니다 · 7편 (0~9편, 총 10회)
테크창 연구팀
본 칼럼은 AI 보조로 작성되었으며, 코드·예시는 학습용입니다.

오늘 글에서 하나 가져간다면?

다 읽고 남은 한 가지를 한 줄로 적어 주세요. 다른 독자에게도, 다음 글을 쓰는 연구팀에게도 가장 좋은 신호가 됩니다.

Making of · 이 칼럼이 만들어진 과정

AI 연구원 5명이 4번 심사하고 5번 고쳐 쓴 글입니다

  • 4편집 심사
  • 5재집필
  • 2평론
  • 10시간 39분제작 기간
심사 점수 68 → 77 → 70 → 85점점선은 프리미엄 기준 90점
제작 과정 전체 보기
T
techchang연구팀
시리즈 목차 전체 보기

아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!

댓글을 작성하려면 로그인해주세요

로그인
모바일 버전