사용자가 입력하는 순간, 웹 앱은 현실에 가까워진다
지금까지는 서버가 준비한 내용을 브라우저에 보여 주는 흐름을 주로 다뤘습니다. 이제 방향을 바꿔 보겠습니다. 사용자가 제목과 내용을 입력하고, 서버가 그 값을 받아 처리하게 만들겠습니다.
웹 애플리케이션은 사용자 입력을 다루는 순간부터 더 현실적인 프로그램이 됩니다. 검색어, 로그인 정보, 게시글, 댓글, 주문 정보처럼 실제 서비스의 대부분은 사용자가 보낸 데이터에서 시작합니다.
하지만 입력값은 항상 올바르지 않습니다. 제목을 비워 둘 수도 있고, 너무 긴 내용을 보낼 수도 있습니다. 이메일 칸에 이메일이 아닌 값을 넣을 수도 있습니다. 그래서 폼을 만들 때는 "받기"만 하면 안 됩니다. 반드시 "확인하기", 즉 검증이 필요합니다.
이번 장에서는 게시글 작성 폼을 만들고 다음 흐름을 구현합니다.
브라우저에서 HTML 폼을 보여 준다.
사용자가 제목, 이메일, 내용을 입력한다.
Flask가
request.form으로 값을 읽는다.서버에서 입력값을 검증한다.
오류가 있으면 입력값을 유지한 채 다시 보여 준다.
성공하면 안내 메시지를 보여 주고 목록으로 이동한다.
AI 생성 이미지
HTML 폼과 request.form은 name으로 연결된다
먼저 폼이 서버로 데이터를 보내는 구조를 봅시다.
<form action="/posts/new" method="post">
<input type="text" name="title">
<input type="email" name="author_email">
<textarea name="content"></textarea>
<button type="submit">저장</button>
</form>여기서 중요한 속성은 세 가지입니다.
action: 폼 데이터를 보낼 URL입니다.method: 요청 방식입니다. 입력값을 서버에 제출할 때는 보통post를 사용합니다.name: 서버에서 값을 꺼낼 때 사용하는 이름표입니다.
Flask에서는 다음처럼 읽습니다.
title = request.form.get('title')
author_email = request.form.get('author_email')
content = request.form.get('content')즉, HTML의 name="title"은 Flask의 request.form.get('title')과 연결됩니다. id나 class가 아니라 name이 서버로 전달된다는 점을 꼭 기억하세요. id는 주로 label이나 CSS, JavaScript에서 특정 요소를 가리킬 때 쓰고, class는 여러 요소를 같은 스타일로 묶을 때 자주 씁니다. 서버가 폼 값을 받을 때 보는 이름표는 name입니다.
게시글 작성 라우트 만들기
이제 app.py에 게시글 작성 기능을 추가해 보겠습니다. 데이터베이스는 다음 장에서 다룰 예정이므로, 이번에는 파이썬 리스트에 임시로 저장합니다. 서버를 껐다 켜면 데이터는 사라집니다.
from flask import Flask, render_template, request, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = 'dev-secret-key'
posts = [
{
'id': 1,
'title': '첫 번째 게시글',
'author_email': '[email protected]',
'content': 'Flask로 만든 게시판입니다.'
}
]
def validate_post(form):
errors = {}
title = form['title']
author_email = form['author_email']
content = form['content']
if not title:
errors['title'] = '제목을 입력해 주세요.'
elif len(title) > 50:
errors['title'] = '제목은 50자 이하로 입력해 주세요.'
if not author_email:
errors['author_email'] = '이메일을 입력해 주세요.'
elif '@' not in author_email or '.' not in author_email:
errors['author_email'] = '올바른 이메일 형식으로 입력해 주세요.'
if not content:
errors['content'] = '내용을 입력해 주세요.'
elif len(content) < 10:
errors['content'] = '내용은 10자 이상 입력해 주세요.'
return errors
@app.route('/posts')
def post_list():
return render_template('posts.html', posts=posts)
@app.route('/posts/new', methods=['GET', 'POST'])
def post_new():
if request.method == 'POST':
form = {
'title': request.form.get('title', '').strip(),
'author_email': request.form.get('author_email', '').strip(),
'content': request.form.get('content', '').strip()
}
errors = validate_post(form)
if errors:
flash('입력 내용을 다시 확인해 주세요.', 'error')
return render_template('post_form.html', form=form, errors=errors)
new_post = {
'id': len(posts) + 1,
'title': form['title'],
'author_email': form['author_email'],
'content': form['content']
}
posts.append(new_post)
flash('게시글이 저장되었습니다.', 'success')
return redirect(url_for('post_list'))
form = {
'title': '',
'author_email': '',
'content': ''
}
errors = {}
return render_template('post_form.html', form=form, errors=errors)app.secret_key는 flash() 메시지를 사용할 때 필요합니다. Flask가 메시지를 세션에 넣고, 기본 설정에서는 그 세션을 서명된 쿠키로 다루기 때문입니다. 여기서는 학습용으로 단순한 문자열을 넣었지만, 실제 서비스에서는 공개 저장소에 그대로 올리지 말고 별도 설정값으로 관리해야 합니다.
핵심을 나눠 보겠습니다.
@app.route('/posts/new', methods=['GET', 'POST'])하나의 URL에서 GET과 POST를 모두 처리합니다. GET은 작성 화면을 보여 줄 때 사용하고, POST는 사용자가 폼을 제출했을 때 사용합니다.
request.form.get('title', '').strip()request.form.get()은 폼으로 넘어온 값을 읽습니다. 뒤의 ''는 값이 없을 때 사용할 기본값입니다. strip()은 앞뒤 공백을 제거합니다. 사용자가 제목 칸에 공백만 입력하는 경우도 빈 값처럼 처리하기 위해서입니다.
flash('게시글이 저장되었습니다.', 'success')flash()는 화면에 한 번만 보여 줄 안내 메시지를 저장합니다. "저장되었습니다", "입력값을 확인해 주세요" 같은 메시지에 잘 어울립니다.
성공했을 때는 바로 목록 템플릿을 렌더링하지 않고 redirect(url_for('post_list'))로 이동합니다. 이렇게 하면 사용자가 새로고침했을 때 같은 POST 요청이 다시 제출되는 일을 줄일 수 있습니다. 지금은 간단한 예제라 크게 티가 나지 않지만, 폼 처리에서는 자주 쓰는 습관입니다.
검증 로직을 함수로 분리하기
입력값 검증은 뷰 함수 안에 모두 써도 됩니다. 하지만 조건이 늘어나면 뷰 함수가 금방 복잡해집니다. 그래서 위 코드에서는 검증만 담당하는 validate_post() 함수를 따로 만들었습니다.
def validate_post(form):
errors = {}
title = form['title']
author_email = form['author_email']
content = form['content']
if not title:
errors['title'] = '제목을 입력해 주세요.'
elif len(title) > 50:
errors['title'] = '제목은 50자 이하로 입력해 주세요.'
if not author_email:
errors['author_email'] = '이메일을 입력해 주세요.'
elif '@' not in author_email or '.' not in author_email:
errors['author_email'] = '올바른 이메일 형식으로 입력해 주세요.'
if not content:
errors['content'] = '내용을 입력해 주세요.'
elif len(content) < 10:
errors['content'] = '내용은 10자 이상 입력해 주세요.'
return errors여기서는 세 가지 검증을 합니다.
필수 입력값인지 확인한다.
제목 길이가 너무 길지 않은지 확인한다.
이메일처럼 보여야 하는 값인지 확인한다.
내용이 너무 짧지 않은지 확인한다.
이메일 검증은 실제 서비스에서는 더 정교하게 처리해야 합니다. 지금은 초보자가 이해하기 쉬운 수준으로 @와 .이 들어 있는지만 확인합니다. 중요한 점은 "브라우저가 알아서 해 주겠지"라고 믿지 않고 서버에서 직접 확인한다는 것입니다.
errors는 오류 메시지를 담는 딕셔너리입니다.
errors['title'] = '제목을 입력해 주세요.'이렇게 저장하면 템플릿에서 errors.title 또는 errors['title'] 형태로 꺼내 보여 줄 수 있습니다.
오류가 있어도 입력값은 지우지 않는다
사용자가 폼을 제출했는데 제목이 너무 길다고 가정해 봅시다. 이때 화면을 다시 보여 주면서 사용자가 입력한 내용이 모두 사라지면 불편합니다. 특히 긴 내용을 작성한 뒤 오류가 났는데 내용이 날아가면 사용자는 서비스를 신뢰하기 어렵습니다.
그래서 오류가 있을 때도 사용자가 입력한 값을 form이라는 딕셔너리에 담아 템플릿으로 다시 전달합니다.
if errors:
flash('입력 내용을 다시 확인해 주세요.', 'error')
return render_template('post_form.html', form=form, errors=errors)이제 templates/post_form.html을 만듭니다.
{% extends 'base.html' %}
{% block title %}새 게시글 작성{% endblock %}
{% block content %}
<h2>새 게시글 작성</h2>
<form action="{{ url_for('post_new') }}" method="post" novalidate>
<div>
<label for="title">제목</label>
<input
type="text"
id="title"
name="title"
value="{{ form.title }}"
>
{% if errors.title %}
<p class="error">{{ errors.title }}</p>
{% endif %}
</div>
<div>
<label for="author_email">이메일</label>
<input
type="email"
id="author_email"
name="author_email"
value="{{ form.author_email }}"
>
{% if errors.author_email %}
<p class="error">{{ errors.author_email }}</p>
{% endif %}
</div>
<div>
<label for="content">내용</label>
<textarea id="content" name="content" rows="6">{{ form.content }}</textarea>
{% if errors.content %}
<p class="error">{{ errors.content }}</p>
{% endif %}
</div>
<button type="submit">저장</button>
</form>
{% endblock %}여기서 value="{{ form.title }}"이 중요합니다. 서버에서 다시 전달한 값을 입력 칸에 넣어 줍니다. textarea는 value 속성을 쓰지 않고 시작 태그와 종료 태그 사이에 값을 넣습니다.
<textarea name="content">{{ form.content }}</textarea>novalidate는 브라우저의 기본 제출 검증을 잠시 끄는 속성입니다. 이 속성이 있으면 type="email" 같은 HTML 검증이 있어도 브라우저가 먼저 제출을 막지 않습니다. 학습할 때는 서버 검증 결과를 직접 확인하기 좋습니다. 나중에 실제 화면에서는 novalidate를 제거하고, required나 maxlength 같은 HTML 속성도 함께 사용할 수 있습니다.
다만 여기서 꼭 기억할 점이 있습니다. 브라우저 검증을 켜든 끄든, 서버 검증은 항상 남겨 두어야 합니다. HTML 속성은 사용자가 바꾸거나 우회할 수 있지만, 서버의 검증 로직은 애플리케이션 안으로 들어오는 데이터를 마지막으로 확인하는 역할을 합니다.
flash 메시지를 화면에 표시하기
flash()로 메시지를 저장했지만, 템플릿에서 꺼내 보여 주지 않으면 사용자는 볼 수 없습니다. 공통 레이아웃인 templates/base.html에 메시지 출력 영역을 추가합니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>{% block title %}Flask 게시판{% endblock %}</title>
</head>
<body>
<nav>
<a href="{{ url_for('post_list') }}">게시글 목록</a>
<a href="{{ url_for('post_new') }}">새 글 작성</a>
</nav>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="messages">
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>get_flashed_messages()는 저장된 flash 메시지를 꺼냅니다. with_categories=true를 사용하면 메시지와 함께 분류도 가져옵니다.
flash('게시글이 저장되었습니다.', 'success')
flash('입력 내용을 다시 확인해 주세요.', 'error')여기서 success, error는 우리가 정한 이름입니다. 나중에 CSS를 연결하면 성공 메시지는 초록색, 오류 메시지는 빨간색으로 다르게 꾸밀 수 있습니다.
목록 화면에 작성 링크 추가하기
게시글 목록 화면에서도 새 글 작성 페이지로 이동할 수 있게 해 봅시다.
templates/posts.html을 다음처럼 구성합니다.
{% extends 'base.html' %}
{% block title %}게시글 목록{% endblock %}
{% block content %}
<h2>게시글 목록</h2>
<p>
<a href="{{ url_for('post_new') }}">새 게시글 작성</a>
</p>
{% if posts %}
<ul>
{% for post in posts %}
<li>
<strong>{{ post.title }}</strong>
<span>({{ post.author_email }})</span>
</li>
{% endfor %}
</ul>
{% else %}
<p>아직 게시글이 없습니다.</p>
{% endif %}
{% endblock %}url_for('post_new')는 post_new 함수에 연결된 URL을 만들어 줍니다. URL을 문자열로 직접 쓰는 것보다 안전합니다. 나중에 주소가 바뀌어도 함수 이름만 유지하면 템플릿 코드를 많이 고치지 않아도 됩니다.
클라이언트 검증과 서버 검증은 역할이 다르다
HTML에는 기본 검증 기능이 있습니다.
<input type="email" name="author_email" required maxlength="100">required는 필수 입력을 뜻합니다. type="email"은 이메일 형식을 확인합니다. maxlength는 최대 글자 수를 제한합니다. 이런 검증은 클라이언트 검증입니다. 클라이언트는 사용자의 브라우저를 뜻합니다.
클라이언트 검증은 사용자 경험을 좋게 만듭니다. 서버에 요청을 보내기 전에 "이메일 형식이 아닙니다"라고 바로 알려 줄 수 있기 때문입니다.
하지만 클라이언트 검증만으로는 부족합니다. 사용자는 브라우저 개발자 도구로 HTML 속성을 바꿀 수 있습니다. 또는 브라우저를 거치지 않고 직접 HTTP 요청을 보낼 수도 있습니다. 그래서 서버는 사용자가 보낸 값을 절대 그대로 믿으면 안 됩니다.
정리하면 다음과 같습니다.
클라이언트 검증: 사용자가 빠르게 실수를 고치도록 돕는다.
서버 검증: 잘못된 데이터가 애플리케이션 안으로 들어오지 못하게 막는다.
실무에서는 둘 다 사용합니다. 다만 둘 중 하나만 고르라면 서버 검증이 반드시 필요합니다. 서버 검증은 마지막 문지기입니다.
이번 장에서 만든 흐름 정리
이번 장에서는 HTML 폼으로 사용자 입력을 받고 Flask에서 검증하는 흐름을 만들었습니다.
폼에서 가장 중요한 연결 고리는 name입니다. HTML의 name="title"은 Flask의 request.form.get('title')로 읽습니다. method="post"는 사용자가 입력한 데이터를 서버에 제출할 때 쓰는 기본 방식입니다.
입력값을 받은 뒤에는 비어 있는지, 너무 길지 않은지, 형식이 맞는지 확인했습니다. 오류가 있으면 errors에 메시지를 담고, 사용자가 입력한 form 값도 함께 다시 템플릿에 전달했습니다. 덕분에 오류가 나도 입력 내용이 사라지지 않습니다.
마지막으로 flash()를 사용해 성공과 오류 메시지를 한 번만 보여 주는 방법을 배웠습니다. 이런 작은 안내 메시지는 사용자가 지금 무슨 일이 일어났는지 이해하게 도와줍니다.
다음 장에서는 지금 리스트에 임시로 저장하던 게시글을 데이터베이스에 저장합니다. 서버를 껐다 켜도 데이터가 남도록 만드는 단계입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.