책 '점프 투 플라스크'를 공부하면서 정리한 내용입니다.
출처 : https://wikidocs.net/book/4542
웹 프로그램에서 폼(form)은 사용자에게 입력 양식을 편리하게 제공하기 위해 사용한다. 폼 모듈을 사용하면 폼으로 전송되는 데이터의 필수 여부, 길이, 형식 등을 더 쉽게 검증할 수 있다.
명령 프롬프트에서 Flask-WTF라는 라이브러리를 설치.
(myproject) c:\projects\myproject> pip install Flask-WTF
Flask-WTF를 사용하려면 플라스크 환경 변수 SECRET_KEY가 필요하다. SECRET_KEY는 CSRF(cross-site request forgery)라는 웹 사이트 취약점 공격을 방지하는 데 사용한다. CSRF는 사용자의 요청을 위조하는 웹 사이트 공격 기법인데 SECRET_KEY를 기분으로 해서 생성되는 CSRF 토큰은 폼으로 전송된 데이터가 실제 웹 페이지에서 작성된 데이터인지를 판단해 준다.
쉽게 말해 CSRF 토큰은 CSRF를 방어하려고 플라스크에서 생성하는 무작위 문자열이다.
프로젝트 루트에 있는 config.py 설정 파일을 열고 마지막 줄에 SECRET_KEY 환경 변수를 추가한다.
SECRET_KEY = "dev"
실제 서비스를 운영할 때에는 "dev"처럼 유추하기 쉬운 문자열을 입력하면 안된다.
1. 질문 등록 버튼
질문 목록 조회 템플릿을 열고 table 태그 아래에 질문 등록 버튼을 생성한다.
(... 생략 ...)
</table>
<a href="{{ url_for('question.create') }}" class="btn btn-primary">질문 등록하기</a>
</div>
{% endblock %}
2. 질문 등록 라우트 함수 추가
질문 등록 URL을 추가했으므로 question_views.py 파일에 라우트 함수 create를 추가해야 한다.
from ..forms import QuestionForm
(... 생략 ...)
@bp.route('/create/')
def create():
form = QuestionForm()
return render_template('question/question_form.html', form=form)
create 함수에서 QuestionForm 클래스의 객체 form을 생성하고 return 문에서 render_template 함수가 템플릿을 렌더링할 때 form 객체를 전달한다. QuestionForm 클래스는 질문 등록을 할 때 사용할 플라스크의 폼으로 후에 작성할 것이다. 이후 form 객체는 템플릿에서 라벨이나 입력 폼 등을 만들 때 사용한다.
3. 질문 등록 폼 클래스 작성
pybo 디렉토리에 forms.py 파일을 생성하고 질문 등록을 할 때 사용할 QuestionForm 클래스를 작성한다.
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField
from wtforms.validators import DataRequired
class QuestionForm(FlaskForm):
subject = StringField('제목', validators=[DataRequired()])
content = TextAreaField('내용', validators=[DataRequired()])
QuestionForm 클래스는 Flask-WTF 모듈의 FlaskForm 클래스를 상속받으며 subject, content 속성을 포함한다. 폼 클래스의 속성과 모델 클래스의 속성은 비슷하다.
<input type="text">
또는
<textarea>
와 같은 입력 창에서 사용자가 작성한 값에 대응하는 자료형
StringField : 글자 수 제한 (제목). 첫 번째 인자(제목)는 폼 라벨로 사용되며 템플릿에서 이 값으로 라벨을 출력할 수 있다. 두 번째 인자 validators는 필드값을 검증할 때 사용하는 도구. 필수 항목인지 점검하는 DataRequired, 이메일인지 점검하는 Email, 길이를 점검하는 Length 등이 있다. ex) 필수값이면서 이메일이어야 하면 validators=[DataRequired(), Email()]
TextAreaField : 글자 수 제한 x (내용)
4. 질문 등록 템플릿 작성
question 디렉터리에 question_form.html 파일을 작성한다.
{% extends 'base.html' %}
{% block content %}
<div class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<form method="post" class="post-form my-3">
{{ form.subject.label }}
{{ form.subject() }}
{{ form.content.label }}
{{ form.content() }}
<button type="submit" class="btn btn-primary">저장하기</button>
</form>
</div>
{% endblock %}
5. 질문 전송 방식 수정
질문 등록을 해보면 다음과 같은 오류가 발생한다.

현재 폼이 POST 방식으로 데이터를 전송하기 때문이다.
question_form.html 템플릿의 폼 엘리먼트를 보면 method 속성이 "post"이므로 폼에 입력한 데이터는 모두 POST 방식으로 전송되는데 현재 create 라우트에는 별도의 method 속성을 지정하지 않았으므로 기본 처리 방식인 GET 방식만 처리할 수 있다. -> POST 방식으로 데이터를 처리하게 하려면 질문 등록 라우트를 변경해야 한다.
# @bp.route('/create/')에서 아래와 같이 변경
@bp.route('/create/', methods=('GET', 'POST'))
이 후 질문 등록을 이용하면 오류는 보이지 않지만 아무런 반응 없이 질문 등록 화면만 보임 -> create 함수에 데이터를 저장하는 코드를 작성하지 않았기 때문.
6. 폼 데이터를 저장하는 코드 작성
create 함수에 POST 방식으로 요청된 폼 데이터를 DB에 저장하는 코드 추가
from flask import Blueprint, render_template, request, url_for
from werkzeug.utils import redirect
from pybo.models import Question
from ..forms import QuestionForm
from datetime import datetime
from .. import db
bp = Blueprint('question', __name__, url_prefix='/question')
@bp.route('/list/')
def _list():
question_list = Question.query.order_by(Question.create_date.desc())
return render_template('question/question_list.html', question_list=question_list)
@bp.route('/detail/<int:question_id>/')
def detail(question_id):
question = Question.query.get_or_404(question_id)
return render_template('question/question_detail.html', question=question)
@bp.route('/create/', methods=('GET', 'POST'))
def create():
form = QuestionForm()
if request.method == 'POST' and form.validate_on_submit():
question = Question(subject=form.subject.data, content=form.content.data, create_date=datetime.now())
db.session.add(question)
db.sesstion.commit
return redirect(url_for('main.index'))
return render_template('question/question_form.html', form=form)
request.method : create 함수로 요청된 전송 방식을 의미. 여기서 POST 방식 요청을 걸러냄
form.validate_on_submit 함수 : POST 방식으로 전송된 폼 데이터의 정합성 점검. 폼을 생성할 때 각 필드에 지정한 DataRequired() 같은 점검 항목에 이상이 없는ㄴ지 확인
return redirect(url_for('main.index')) : 데이터 저장이 완료되면 main.index 페이지로 이동
핵심은 데이터 전송 방식이 POST인지 GET인지에 따라 달리 처리하는 부분이다. <질문 등록하기> 버튼이나 <저장하기> 버튼 모두 같은 페이지를 요청하므로 create 라우트가 이 요청을 받으면 request.method == 'POST' 코드로 요청 방식을 구분해서 렌더링한다.
<질문 등록하기> : GET 방식. 그대로 질문 등록 화면 보여줌.
<저장하기> : POST 방식. DB에 폼 데이터를 저장한 다음 질문 목록으로 이동.
여기까지 해도 <질문 등록하기> 버튼을 누르면 여전히 화면에 아무런 변화가 없다. 이것은 나중에 해결할 것이다.
7. 폼에 부트스트랩 적용하기
{{ form.subject() }}와 같은 코드는 HTML이 폼을 자동으로 생성하므로 부트스트랩을 적용할 수 없다. but 템플릿 수정으로 어느정도 적용 가능.
question_form.html에 form.subject나 form.content에 부트스트랩 클래스 class="form-control" 적용
(... 생략 ...)
{{ form.subject.label }}
<!-- ------------------------------ [edit] -------------------------------- -->
{{ form.subject(class="form-control") }}
<!-- ---------------------------------------------------------------------- -->
{{ form.content.label }}
<!-- ------------------------------ [edit] -------------------------------- -->
{{ form.content(class="form-control") }}
<!-- ---------------------------------------------------------------------- -->
(... 생략 ...)
8. 수작업으로 폼 작성하기
{{ form.subject() }} 처럼 폼을 생성하는 HTML 코드를 자동으로 생성하는 방식은 폼을 빠르게 만들지만 원하는 디자인을 적용하기 힘들다. 이 단점을 보완하고자 HTML을 직접 작성해서 질문 등록 기능을 완성한다.
question_form.html 파일을 열고 form 엘리먼트의 내용을 수정한다.
(... 생략 ...)
<form method="post" class="post-form my-3">
<div class="form-group">
<label for="subject">제목</label>
<input type="text" class="form-control" name="subject" id="subject">
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea class="form-control" name="content" id="content" rows="5"></textarea>
</div>
<button type="submit" class="btn btn-primary">저장하기</button>
</form>
</div>
{% endblock %}
수동으로 작성하면 form-group 클래스가 적용돼서 버튼 위에 간격이 생긴다.
데이터를 DB에 저장하는 기능을 추가 했지만 저장하기 버튼을 눌러도 변화가 없었다.
1. 오류 내용을 표시해 원인 알아내기
question_form.html에 오류 표시 코드 추가
<!-- 오류표시 Start -->
{% for field, errors in form.errors.items() %}
<div class="alert alert-danger" role="alert">
<strong>{{ form[field].label }}</strong>: {{ ', '.join(errors) }}
</div>
{% endfor %}
<!-- 오류표시 End -->
form.errors.items의 field는 subject나 content와 같은 입력 폼의 필드를 의미
form.validate_on_submit이 실패하면 폼에 오류 내용이 자동으로 등록된다. 이 오류 정보는 form.errors 속성으로 표시할 수 있다.
아무 값도 입력하지 않고 저장하기 버튼을 눌렀을 경우

CSRF Token 오류 : CSRF는 보안 관련 항목으로 form 엘리먼트를 통해 전송된 데이터가 실제 웹 사이트에서 만들어진 데이터인지 검증하는 데 필요한 CSRF 토큰이 빠졌다는 의미
2. CSRF 토큰 오류 처리
question_form.html의 form 엘리먼트 바로 밑에 {{ form.csrf_token }} 코드로 CSRF 토큰 오류를 탈출한다.
(... 생략 ...)
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<form method="post" class="post-form my-3">
{{ form.csrf_token }}
(... 생략 ...)
이제 저장하기 버튼을 누르면 질문 목록 화면에 등록되어 있는 것을 볼 수 있다.
1. 입력한 값 유지하기
폼을 전송했을 때 오류가 있더라도 이미 입력한 값을 유지하도록 질문 폼 템플릿을 수정한다.
(... 생략 ...)
<div class="form-group">
<label for="subject">제목</label>
<input type="text" class="form-control" name="subject" id="subject"
value="{{ form.subject.data or '' }}">
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea class="form-control" name="content"
id="content" rows="10">
{{ form.content.data or '' }}
</textarea>
</div>
(... 생략 ...)

2. 오류 메시지 한글로 바꾸기
필수 항목을 입력하지 않았을 때 발생하는 오류 메시지 우리말로 바꾸기.
forms.py 파일을 열어 DataRequired에 한글 메시지 설정
[DataRequired('제목은 필수입력 항목입니다.')])
각 질문에 답변을 달 수 있는 기능도 폼을 사용할 수 있도록 한다.
1. 답변 등록 폼 추가
forms.py
class AnswerForm(FlaskForm):
content = TextAreaField('내용', validators=[DataRequired('내용은 필수입력 항목입니다.')])
answer_views.py 파일에서 create 함수가 AnswerForm을 사용하도록 변경
from datetime import datetime
from flask import Blueprint, url_for, request, render_template
from werkzeug.utils import redirect
from pybo import db
from ..forms import AnswerForm
from pybo.models import Question, Answer
bp = Blueprint('answer', __name__, url_prefix='/answer')
@bp.route('/create/<int:question_id>', methods=('POST',))
def create(question_id): # 매개변수 question_id는 URL에서 전달된다.
# question = Question.query.get_or_404(question_id)
#
# # form 엘리먼트를 통해 전달된 데이터들은 create 함수에서 request 객체로 얻을 수 있다.
# # POST 폼 방식으로 전송된 데이터 항목 중 name 속성이 'content'인 값을 의미한다.
# content = request.form['content']
#
# answer = Answer(content=content, create_date=datetime.now())
#
# question.answer_set.append(answer) # ‘질문에 달린 답변들’을 의미
# # Question과 Answer 모델이 연결되어 backref에 설정한 answer_set을 사용할 수 있다.
#
# db.session.commit()
#
# # 답변을 생성한 후 화면을 이동하도록 redirect 함수를 사용
# return redirect(url_for('question.detail', question_id=question_id))
# # question_id는 question_views.py 파일에 있는 detail 함수의 매개변수로 전달
# 폼 사용용
form = AnswerForm()
question = Question.query.get_or_404(question_id)
if form.validate_on_submit():
content = request.form['content']
answer = Answer(content=content, create_date=datetime.now())
question.answer_set.append(answer)
db.session.commit()
return redirect(url_for('question.detail', question_id=question_id))
return render_template('question/question_detail.html', question=question, form=form)
<form action="{{ url_for('answer.create', question_id=question.id) }}" method="post" class="my-3">
{{ form.csrf_token }}
<!-- 오류표시 Start -->
{% for field, errors in form.errors.items() %}
<div class="alert alert-danger" role="alert">
<strong>{{ form[field].label }}</strong>: {{ ', '.join(errors) }}
</div>
{% endfor %}
<!-- 오류표시 End -->
<div class="form-group">
<textarea name="content" id="content" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="답변등록" class="btn btn-primary">
</form>
질문 상세 조회 템플릿에 폼이 추가되었으므로 question_views.py 파일의 detail 함수도 폼을 사용해야 한다.

완성!