지금까지 우리는 URL로 데이터를 받았습니다. /user/alice, /post/123 이런 식으로요. 🤔 ──하지만 실제 웹사이트는 사용자 입력을 많이 받습니다!
로그인할 때 아이디와 비밀번호를 입력하고, 회원가입할 때 여러 정보를 입력하고, 게시글을 작성할 때 제목과 내용을 입력하죠.
이 모든 것이 HTML 폼(Form)을 통해 이루어집니다!
오늘은 Flask에서 폼을 만들고, 사용자 입력을 받아 처리하는 방법을 배웁니다. 입력 검증부터 파일 업로드까지, 실전에서 바로 쓸 수 있는 내용입니다! 💡
(35분 완독 ⭐⭐⭐⭐⭐)
🎯 오늘의 학습 목표
- HTML 폼의 기초와 Flask 연동
- 다양한 입력 필드 처리하기
- 폼 데이터 검증과 에러 처리
- 파일 업로드 구현하기
📚 사전 지식
🎯 학습 목표 1: HTML 폼의 기초와 Flask 연동
한 줄 설명
HTML 폼 = 사용자로부터 정보를 입력받는 양식 (로그인, 회원가입, 검색창 등)
실생활 비유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 📝 설문지 작성:
HTML 폼 = 종이 설문지
- <input type="text"> = 빈칸 채우기 ("이름: _______")
- <select> = 선택형 질문 ("성별: ○남 ○여")
- <button> = 제출 버튼 ("설문지 제출")
Flask = 설문지를 받아서 처리하는 담당자
- GET: 빈 설문지 나눠주기
- POST: 작성된 설문지 받아서 데이터베이스에 저장
과정:
1. 사용자: 설문지 받기 (GET /contact)
2. 사용자: 설문지 작성하고 제출 버튼 클릭
3. 서버: 제출된 설문지 받기 (POST /contact)
4. 서버: request.form.get('이름')으로 답변 읽기
|
HTML 폼이란?
폼(Form)은 사용자로부터 데이터를 입력받는 HTML 요소입니다.
1
2
3
4
5
| <form method="POST" action="/submit">
<input type="text" name="username" placeholder="이름">
<input type="email" name="email" placeholder="이메일">
<button type="submit">제출</button>
</form>
|
폼의 핵심 속성:
1
2
3
| method : HTTP 메서드 (GET 또는 POST)
action : 데이터를 보낼 URL
name : 서버에서 식별할 필드 이름
|
간단한 폼 만들기
1. 템플릿 작성
templates/contact.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| <!DOCTYPE html>
<html>
<head>
<title>문의하기</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
input, textarea { width: 100%; padding: 10px; margin: 10px 0; }
button { padding: 10px 20px; background: #007bff; color: white; border: none; }
</style>
</head>
<body>
<h1>문의하기</h1>
<form method="POST">
<input type="text" name="name" placeholder="이름" required>
<input type="email" name="email" placeholder="이메일" required>
<textarea name="message" rows="5" placeholder="문의 내용" required></textarea>
<button type="submit">보내기</button>
</form>
</body>
</html>
|
2. Flask 라우트 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| # app.py
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route('/contact', methods=['GET', 'POST'])
def contact():
if request.method == 'GET':
# GET: 폼 표시
return render_template('contact.html')
else: # POST
# POST: 폼 데이터 처리
name = request.form.get('name')
email = request.form.get('email')
message = request.form.get('message')
# 데이터 처리 (예: 데이터베이스 저장, 이메일 발송 등)
print(f"받은 문의: {name} ({email}): {message}")
return f"""
<h1>문의가 접수되었습니다!</h1>
<p>감사합니다, {name}님!</p>
<p>곧 {email}로 답변 드리겠습니다.</p>
<a href="/contact">돌아가기</a>
"""
if __name__ == '__main__':
app.run(debug=True)
|
GET vs POST 다시 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # GET 방식 (쿼리 스트링)
# URL: /search?keyword=flask
@app.route('/search')
def search():
keyword = request.args.get('keyword') # request.args (GET)
return f"검색어: {keyword}"
# POST 방식 (폼 데이터)
# URL: /login
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username') # request.form (POST)
password = request.form.get('password')
return f"로그인 시도: {username}"
|
왜 POST를 사용해야 할까?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # ❌ GET 방식 (나쁜 예)
# URL: /login?username=admin&password=1234
# 문제점:
# 1. 비밀번호가 URL에 노출됨
# 2. 브라우저 히스토리에 남음
# 3. 서버 로그에 기록됨
# 4. 북마크/공유 가능 (보안 위험!)
# ✅ POST 방식 (좋은 예)
# URL: /login
# 장점:
# 1. 데이터가 요청 본문에 숨겨짐
# 2. URL에 노출 안 됨
# 3. 더 많은 데이터 전송 가능
# 4. 안전함
|
🎯 학습 목표 2: 다양한 입력 필드 처리하기
한 줄 설명
입력 필드 = 다양한 종류의 정보를 받는 칸 (텍스트, 숫자, 날짜, 체크박스 등)
실생활 비유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| 📋 다양한 양식 칸:
텍스트 입력 = 자유롭게 쓰는 빈칸
- <input type="text"> → "이름: _______"
체크박스 = 여러 개 선택 가능
- <input type="checkbox"> → "☑ Python ☑ JavaScript ☐ Java"
- request.form.getlist() → 선택된 것들 전부 받기
라디오 버튼 = 하나만 선택
- <input type="radio"> → "성별: ● 남성 ○ 여성"
- 하나만 선택되므로 request.form.get() 사용
드롭다운 = 펼쳐서 고르기
- <select> → "지역: [서울 ▼] → 서울, 부산, 대구..."
파일 업로드 = 첨부 파일
- <input type="file"> → "📎 파일 선택"
- request.files.get() → 업로드된 파일 받기
|
텍스트 입력 필드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| <!-- templates/register.html -->
<!DOCTYPE html>
<html>
<head>
<title>회원가입</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
.form-group { margin: 15px 0; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, select { width: 100%; padding: 10px; }
button { padding: 10px 20px; background: #28a745; color: white; border: none; }
</style>
</head>
<body>
<h1>회원가입</h1>
<form method="POST">
<!-- 일반 텍스트 -->
<div class="form-group">
<label for="username">아이디</label>
<input type="text" id="username" name="username" required>
</div>
<!-- 이메일 -->
<div class="form-group">
<label for="email">이메일</label>
<input type="email" id="email" name="email" required>
</div>
<!-- 비밀번호 -->
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required>
</div>
<!-- 숫자 -->
<div class="form-group">
<label for="age">나이</label>
<input type="number" id="age" name="age" min="1" max="120">
</div>
<!-- 날짜 -->
<div class="form-group">
<label for="birthdate">생년월일</label>
<input type="date" id="birthdate" name="birthdate">
</div>
<!-- 전화번호 -->
<div class="form-group">
<label for="phone">전화번호</label>
<input type="tel" id="phone" name="phone" placeholder="010-1234-5678">
</div>
<button type="submit">가입하기</button>
</form>
</body>
</html>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # app.py
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'GET':
return render_template('register.html')
# POST: 모든 필드 받기
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
age = request.form.get('age', type=int) # 정수로 변환
birthdate = request.form.get('birthdate')
phone = request.form.get('phone')
return f"""
<h1>회원가입 완료!</h1>
<ul>
<li>아이디: {username}</li>
<li>이메일: {email}</li>
<li>나이: {age}살</li>
<li>생년월일: {birthdate}</li>
<li>전화번호: {phone}</li>
</ul>
"""
|
선택 필드 (체크박스, 라디오, 드롭다운)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
| <!-- templates/survey.html -->
<!DOCTYPE html>
<html>
<head>
<title>설문조사</title>
</head>
<body>
<h1>설문조사</h1>
<form method="POST">
<!-- 라디오 버튼 (하나만 선택) -->
<fieldset>
<legend>성별</legend>
<label>
<input type="radio" name="gender" value="male" required>
남성
</label>
<label>
<input type="radio" name="gender" value="female">
여성
</label>
<label>
<input type="radio" name="gender" value="other">
기타
</label>
</fieldset>
<!-- 체크박스 (여러 개 선택 가능) -->
<fieldset>
<legend>관심 분야 (복수 선택)</legend>
<label>
<input type="checkbox" name="interests" value="python">
Python
</label>
<label>
<input type="checkbox" name="interests" value="web">
웹 개발
</label>
<label>
<input type="checkbox" name="interests" value="ai">
인공지능
</label>
<label>
<input type="checkbox" name="interests" value="data">
데이터 분석
</label>
</fieldset>
<!-- 드롭다운 (선택 목록) -->
<fieldset>
<legend>경력</legend>
<select name="experience" required>
<option value="">선택하세요</option>
<option value="beginner">초보 (1년 미만)</option>
<option value="intermediate">중급 (1-3년)</option>
<option value="advanced">고급 (3년 이상)</option>
</select>
</fieldset>
<!-- 텍스트 영역 -->
<fieldset>
<legend>의견</legend>
<textarea name="comment" rows="5" cols="50"></textarea>
</fieldset>
<!-- 체크박스 (단일) -->
<label>
<input type="checkbox" name="agree" value="yes" required>
개인정보 수집에 동의합니다
</label>
<br><br>
<button type="submit">제출</button>
</form>
</body>
</html>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| @app.route('/survey', methods=['GET', 'POST'])
def survey():
if request.method == 'GET':
return render_template('survey.html')
# 라디오 버튼 (단일 값)
gender = request.form.get('gender')
# 체크박스 (여러 값) - getlist() 사용!
interests = request.form.getlist('interests')
# 드롭다운
experience = request.form.get('experience')
# 텍스트 영역
comment = request.form.get('comment')
# 단일 체크박스
agree = request.form.get('agree') # 체크 안 하면 None
return f"""
<h1>설문 결과</h1>
<ul>
<li>성별: {gender}</li>
<li>관심 분야: {', '.join(interests) if interests else '없음'}</li>
<li>경력: {experience}</li>
<li>의견: {comment or '없음'}</li>
<li>동의 여부: {'동의함' if agree else '동의 안 함'}</li>
</ul>
"""
|
입력 값 유지하기 (템플릿에 값 전달)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| @app.route('/contact', methods=['GET', 'POST'])
def contact():
# 기본값 설정
form_data = {
'name': '',
'email': '',
'message': ''
}
error = None
if request.method == 'POST':
form_data['name'] = request.form.get('name', '')
form_data['email'] = request.form.get('email', '')
form_data['message'] = request.form.get('message', '')
# 검증 실패 시 폼에 입력값 유지
if not form_data['name']:
error = "이름을 입력해주세요"
elif not form_data['email']:
error = "이메일을 입력해주세요"
else:
# 성공 처리
return "문의가 접수되었습니다!"
return render_template('contact.html', form=form_data, error=error)
|
templates/contact.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <!DOCTYPE html>
<html>
<body>
<h1>문의하기</h1>
{% if error %}
<p style="color: red;">{{ error }}</p>
{% endif %}
<form method="POST">
<input type="text" name="name" value="{{ form.name }}" placeholder="이름" required>
<input type="email" name="email" value="{{ form.email }}" placeholder="이메일" required>
<textarea name="message" required>{{ form.message }}</textarea>
<button type="submit">보내기</button>
</form>
</body>
</html>
|
🎯 학습 목표 3: 폼 데이터 검증과 에러 처리
한 줄 설명
데이터 검증 = 사용자가 올바른 정보를 입력했는지 확인하고, 틀리면 에러 메시지 보여주기
실생활 비유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| 🔍 공항 보안 검색:
클라이언트 측 검증 (HTML required) = 출입구 안내판
- "신분증 필수!" 라고 알려주기
- 하지만 무시하고 들어갈 수도 있음 (개발자 도구로 우회 가능)
서버 측 검증 = 실제 보안 요원이 확인
- 신분증 확인, 짐 검사 → 반드시 통과해야 함!
- 클라이언트 검증을 우회해도 서버에서 막음
예시:
1. HTML: <input type="email" required> ← "이메일 형식으로 입력하세요"
2. 사용자가 개발자 도구로 'required' 삭제
3. 빈 값 전송!
4. 서버: if not email: flash('이메일 필수!') ← 서버에서 다시 검증!
✅ 반드시 서버에서도 검증해야 안전함!
|
서버 측 검증의 중요성
1
2
3
| # ⚠️ HTML 검증만으로는 부족합니다!
# 사용자가 브라우저 개발자 도구로 'required' 속성을 삭제할 수 있습니다.
# 반드시 서버 측에서도 검증해야 합니다!
|
기본 검증 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| # app.py
from flask import Flask, render_template, request, flash, redirect, url_for
app = Flask(__name__)
app.secret_key = 'your-secret-key-here' # flash 메시지를 위해 필요
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username', '').strip()
email = request.form.get('email', '').strip()
password = request.form.get('password', '')
password_confirm = request.form.get('password_confirm', '')
# 검증
errors = []
# 1. 필수 필드 체크
if not username:
errors.append('아이디를 입력해주세요')
elif len(username) < 3:
errors.append('아이디는 3글자 이상이어야 합니다')
# 2. 이메일 형식 체크
if not email:
errors.append('이메일을 입력해주세요')
elif '@' not in email or '.' not in email:
errors.append('올바른 이메일 형식이 아닙니다')
# 3. 비밀번호 체크
if not password:
errors.append('비밀번호를 입력해주세요')
elif len(password) < 8:
errors.append('비밀번호는 8자 이상이어야 합니다')
elif password != password_confirm:
errors.append('비밀번호가 일치하지 않습니다')
# 에러가 있으면 폼으로 돌아가기
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('register'))
# 검증 통과 - 회원가입 처리
flash(f'{username}님, 회원가입을 환영합니다!', 'success')
return redirect(url_for('home'))
return render_template('register.html')
@app.route('/')
def home():
return "<h1>홈 페이지</h1><a href='/register'>회원가입</a>"
if __name__ == '__main__':
app.run(debug=True)
|
templates/register.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| <!DOCTYPE html>
<html>
<head>
<title>회원가입</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
.form-group { margin: 15px 0; }
label { display: block; margin-bottom: 5px; }
input { width: 100%; padding: 10px; }
button { padding: 10px 20px; background: #28a745; color: white; border: none; }
.flash-messages { margin: 20px 0; }
.flash-error { background: #f8d7da; color: #721c24; padding: 10px; margin: 5px 0; }
.flash-success { background: #d4edda; color: #155724; padding: 10px; margin: 5px 0; }
</style>
</head>
<body>
<h1>회원가입</h1>
<!-- Flash 메시지 표시 -->
<div class="flash-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<form method="POST">
<div class="form-group">
<label for="username">아이디</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="email">이메일</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="password_confirm">비밀번호 확인</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit">가입하기</button>
</form>
</body>
</html>
|
정규표현식을 이용한 고급 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| import re
def validate_email(email):
"""이메일 형식 검증"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_password(password):
"""비밀번호 강도 검증"""
# 최소 8자, 대문자, 소문자, 숫자 포함
if len(password) < 8:
return False, "비밀번호는 8자 이상이어야 합니다"
if not re.search(r'[A-Z]', password):
return False, "대문자를 포함해야 합니다"
if not re.search(r'[a-z]', password):
return False, "소문자를 포함해야 합니다"
if not re.search(r'[0-9]', password):
return False, "숫자를 포함해야 합니다"
return True, ""
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
email = request.form.get('email', '')
password = request.form.get('password', '')
# 이메일 검증
if not validate_email(email):
flash('올바른 이메일 형식이 아닙니다', 'error')
return redirect(url_for('register'))
# 비밀번호 검증
is_valid, message = validate_password(password)
if not is_valid:
flash(message, 'error')
return redirect(url_for('register'))
# 검증 통과
flash('회원가입 성공!', 'success')
return redirect(url_for('home'))
return render_template('register.html')
|
🎯 학습 목표 4: 파일 업로드 구현하기
한 줄 설명
파일 업로드 = 사용자 컴퓨터의 파일을 서버로 전송해서 저장하기 (프로필 사진, 문서 등)
실생활 비유
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| 📤 우체국에 소포 보내기:
파일 업로드 = 소포를 우체국에 맡기기
- <input type="file"> = 소포 선택
- enctype="multipart/form-data" = 소포용 특수 포장 (필수!)
- request.files.get('file') = 우체국에서 소포 받기
보안 검사 = 파일 검증
1. 확장자 확인: allowed_file() → ".exe 파일은 안 돼요!"
2. 파일명 안전하게: secure_filename() → "../../../virus.exe" 차단!
3. 크기 제한: MAX_CONTENT_LENGTH → "10MB 이하만 가능!"
저장 과정:
1. 사용자: 프로필 사진 선택 (cat.jpg)
2. 서버: 파일 받기
3. 서버: 안전한 이름으로 변경 (cat.jpg → a1b2c3d4.jpg)
4. 서버: uploads/ 폴더에 저장
|
기본 파일 업로드
1. 템플릿 작성
templates/upload.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| <!DOCTYPE html>
<html>
<head>
<title>파일 업로드</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
.upload-box { border: 2px dashed #ccc; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h1>파일 업로드</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p style="color: {% if category == 'error' %}red{% else %}green{% endif %}">
{{ message }}
</p>
{% endfor %}
{% endif %}
{% endwith %}
<div class="upload-box">
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" required>
<br><br>
<button type="submit">업로드</button>
</form>
</div>
</body>
</html>
|
2. Flask 라우트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| import os
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = 'your-secret-key'
# 업로드 설정
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB 제한
# 업로드 폴더 생성
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def allowed_file(filename):
"""허용된 파일 확장자인지 확인"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
# 파일이 있는지 확인
if 'file' not in request.files:
flash('파일이 선택되지 않았습니다', 'error')
return redirect(request.url)
file = request.files['file']
# 파일명이 비어있는지 확인
if file.filename == '':
flash('파일이 선택되지 않았습니다', 'error')
return redirect(request.url)
# 파일 확장자 확인
if file and allowed_file(file.filename):
# 안전한 파일명으로 변환
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
flash(f'파일 "{filename}"이 업로드되었습니다!', 'success')
return redirect(url_for('upload_file'))
else:
flash('허용되지 않는 파일 형식입니다', 'error')
return redirect(request.url)
return render_template('upload.html')
|
다중 파일 업로드
templates/multi_upload.html:
1
2
3
4
5
6
7
8
9
10
11
| <!DOCTYPE html>
<html>
<body>
<h1>여러 파일 업로드</h1>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="files" multiple required>
<br><br>
<button type="submit">업로드</button>
</form>
</body>
</html>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @app.route('/multi-upload', methods=['GET', 'POST'])
def multi_upload():
if request.method == 'POST':
files = request.files.getlist('files')
if not files:
flash('파일을 선택해주세요', 'error')
return redirect(request.url)
uploaded = []
for file in files:
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
uploaded.append(filename)
flash(f'{len(uploaded)}개의 파일이 업로드되었습니다: {", ".join(uploaded)}', 'success')
return redirect(url_for('multi_upload'))
return render_template('multi_upload.html')
|
이미지 미리보기와 업로드
templates/image_upload.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| <!DOCTYPE html>
<html>
<head>
<title>이미지 업로드</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
#preview { max-width: 300px; margin: 20px 0; display: none; }
</style>
</head>
<body>
<h1>프로필 사진 업로드</h1>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="photo" id="photo" accept="image/*" required>
<br><br>
<!-- 미리보기 -->
<img id="preview" alt="미리보기">
<br>
<input type="text" name="caption" placeholder="사진 설명 (선택)" style="width: 100%; padding: 10px;">
<br><br>
<button type="submit">업로드</button>
</form>
<script>
// 파일 선택 시 미리보기
document.getElementById('photo').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('preview');
preview.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
</script>
</body>
</html>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @app.route('/image-upload', methods=['GET', 'POST'])
def image_upload():
if request.method == 'POST':
photo = request.files.get('photo')
caption = request.form.get('caption', '')
if photo and allowed_file(photo.filename):
filename = secure_filename(photo.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
photo.save(filepath)
# 실제로는 데이터베이스에 저장
print(f"사진: {filename}, 설명: {caption}")
flash('프로필 사진이 업로드되었습니다!', 'success')
return redirect(url_for('image_upload'))
else:
flash('이미지 파일만 업로드 가능합니다', 'error')
return render_template('image_upload.html')
|
💻 실전 예제
예제 1: 완전한 회원가입 폼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
| # app.py
from flask import Flask, render_template, request, flash, redirect, url_for
import re
app = Flask(__name__)
app.secret_key = 'super-secret-key'
# 가짜 사용자 데이터베이스
users_db = []
def validate_registration(data):
"""회원가입 데이터 검증"""
errors = []
# 아이디 검증
username = data.get('username', '').strip()
if not username:
errors.append('아이디를 입력해주세요')
elif len(username) < 3:
errors.append('아이디는 3글자 이상이어야 합니다')
elif any(u['username'] == username for u in users_db):
errors.append('이미 사용 중인 아이디입니다')
# 이메일 검증
email = data.get('email', '').strip()
if not email:
errors.append('이메일을 입력해주세요')
elif not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
errors.append('올바른 이메일 형식이 아닙니다')
# 비밀번호 검증
password = data.get('password', '')
password_confirm = data.get('password_confirm', '')
if not password:
errors.append('비밀번호를 입력해주세요')
elif len(password) < 8:
errors.append('비밀번호는 8자 이상이어야 합니다')
elif password != password_confirm:
errors.append('비밀번호가 일치하지 않습니다')
# 나이 검증
age = data.get('age', type=int)
if age and (age < 14 or age > 120):
errors.append('나이는 14세 이상 120세 이하여야 합니다')
# 약관 동의 검증
if not data.get('terms'):
errors.append('이용약관에 동의해야 합니다')
return errors
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
errors = validate_registration(request.form)
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('register'))
# 회원가입 처리
user = {
'username': request.form.get('username'),
'email': request.form.get('email'),
'age': request.form.get('age', type=int),
'gender': request.form.get('gender')
}
users_db.append(user)
flash(f'{user["username"]}님, 회원가입을 환영합니다!', 'success')
return redirect(url_for('home'))
return render_template('register_full.html')
@app.route('/')
def home():
return render_template('home.html', users=users_db)
if __name__ == '__main__':
app.run(debug=True)
|
templates/register_full.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
| <!DOCTYPE html>
<html>
<head>
<title>회원가입</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial; background: #f5f5f5; }
.container { max-width: 500px; margin: 50px auto; background: white; padding: 30px; border-radius: 10px; }
h1 { margin-bottom: 20px; color: #333; }
.form-group { margin: 15px 0; }
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; }
button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 5px; font-size: 16px; cursor: pointer; }
button:hover { background: #0056b3; }
.flash { padding: 10px; margin: 10px 0; border-radius: 5px; }
.flash-error { background: #f8d7da; color: #721c24; }
.flash-success { background: #d4edda; color: #155724; }
.radio-group { display: flex; gap: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>회원가입</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label for="username">아이디 *</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="email">이메일 *</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">비밀번호 *</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="password_confirm">비밀번호 확인 *</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<div class="form-group">
<label for="age">나이</label>
<input type="number" id="age" name="age" min="14" max="120">
</div>
<div class="form-group">
<label>성별</label>
<div class="radio-group">
<label><input type="radio" name="gender" value="male"> 남성</label>
<label><input type="radio" name="gender" value="female"> 여성</label>
<label><input type="radio" name="gender" value="other"> 기타</label>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="terms" value="yes" required>
이용약관에 동의합니다 *
</label>
</div>
<button type="submit">가입하기</button>
</form>
</div>
</body>
</html>
|
예제 2: 블로그 글쓰기 폼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| from datetime import datetime
posts_db = []
@app.route('/write', methods=['GET', 'POST'])
def write_post():
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
category = request.form.get('category')
tags = request.form.get('tags', '')
# 검증
if not title:
flash('제목을 입력해주세요', 'error')
return redirect(url_for('write_post'))
if not content:
flash('내용을 입력해주세요', 'error')
return redirect(url_for('write_post'))
# 태그 처리 (쉼표로 구분)
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
# 게시글 저장
post = {
'id': len(posts_db) + 1,
'title': title,
'content': content,
'category': category,
'tags': tag_list,
'created_at': datetime.now()
}
posts_db.append(post)
flash('게시글이 작성되었습니다!', 'success')
return redirect(url_for('view_post', post_id=post['id']))
return render_template('write_post.html')
@app.route('/post/<int:post_id>')
def view_post(post_id):
post = next((p for p in posts_db if p['id'] == post_id), None)
if not post:
flash('게시글을 찾을 수 없습니다', 'error')
return redirect(url_for('home'))
return render_template('view_post.html', post=post)
|
templates/write_post.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| <!DOCTYPE html>
<html>
<head>
<title>글쓰기</title>
<style>
body { font-family: Arial; max-width: 800px; margin: 50px auto; }
.form-group { margin: 20px 0; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, textarea, select { width: 100%; padding: 10px; border: 1px solid #ddd; }
textarea { resize: vertical; }
button { padding: 12px 30px; background: #28a745; color: white; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1>새 글 쓰기</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p style="color: {% if category == 'error' %}red{% else %}green{% endif %}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label for="title">제목</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="category">카테고리</label>
<select id="category" name="category" required>
<option value="">선택하세요</option>
<option value="tech">기술</option>
<option value="life">일상</option>
<option value="review">리뷰</option>
</select>
</div>
<div class="form-group">
<label for="content">내용</label>
<textarea id="content" name="content" rows="15" required></textarea>
</div>
<div class="form-group">
<label for="tags">태그 (쉼표로 구분)</label>
<input type="text" id="tags" name="tags" placeholder="예: python, flask, 웹개발">
</div>
<button type="submit">게시</button>
<a href="/">취소</a>
</form>
</body>
</html>
|
⚠️ 주의사항
1. CSRF 공격 방지
1
2
3
4
5
6
7
8
9
10
11
12
| # ⚠️ 실제 프로덕션에서는 Flask-WTF 사용 권장
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.secret_key = 'your-secret-key'
csrf = CSRFProtect(app)
# 템플릿에서:
# <form method="POST">
# {{ csrf_token() }}
# ...
# </form>
|
2. 파일 업로드 보안
1
2
3
4
5
6
7
8
9
10
11
12
| # ❌ 위험: 파일명을 그대로 사용
file.save(file.filename) # "../../../etc/passwd" 같은 경로 주입 가능!
# ✅ 안전: secure_filename() 사용
from werkzeug.utils import secure_filename
filename = secure_filename(file.filename)
file.save(os.path.join(UPLOAD_FOLDER, filename))
# ✅ 더 안전: 파일명을 UUID로 변경
import uuid
ext = file.filename.rsplit('.', 1)[1].lower()
filename = f"{uuid.uuid4()}.{ext}"
|
3. 입력 검증은 항상 서버에서
1
2
3
4
5
6
7
| # ❌ HTML만으로는 부족
<input type="email" required> # 클라이언트에서 우회 가능
# ✅ 서버에서도 검증
email = request.form.get('email')
if not email or '@' not in email:
return "Invalid email", 400
|
🔧 트러블슈팅
문제 1: 파일 업로드 시 413 에러
증상: 파일 업로드 시 “413 Request Entity Too Large” 에러
원인: 파일 크기가 제한을 초과
해결:
1
2
| # 파일 크기 제한 늘리기
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
문제 2: Flash 메시지가 표시되지 않음
원인: secret_key가 설정되지 않음
해결:
1
| app.secret_key = 'your-secret-key-here'
|
문제 3: 폼 제출 후 데이터가 None
원인: name 속성이 없거나 잘못됨
해결:
1
2
3
4
5
| <!-- ❌ -->
<input type="text">
<!-- ✅ -->
<input type="text" name="username">
|
🧪 연습 문제
문제 1: 로그인 폼 만들기
로그인 폼을 만들고 다음 검증을 구현하세요:
- 아이디: 필수, 3자 이상
- 비밀번호: 필수, 6자 이상
- 검증 실패 시 에러 메시지 표시
✅ 정답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| # app.py
from flask import Flask, render_template, request, flash, redirect, url_for
app = Flask(__name__)
app.secret_key = 'secret'
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
errors = []
if not username:
errors.append('아이디를 입력해주세요')
elif len(username) < 3:
errors.append('아이디는 3자 이상이어야 합니다')
if not password:
errors.append('비밀번호를 입력해주세요')
elif len(password) < 6:
errors.append('비밀번호는 6자 이상이어야 합니다')
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('login'))
# 실제로는 데이터베이스에서 확인
if username == 'admin' and password == 'password':
flash(f'{username}님, 환영합니다!', 'success')
return redirect(url_for('home'))
else:
flash('아이디 또는 비밀번호가 틀렸습니다', 'error')
return redirect(url_for('login'))
return render_template('login.html')
if __name__ == '__main__':
app.run(debug=True)
|
templates/login.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <!DOCTYPE html>
<html>
<body>
<h1>로그인</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p style="color: {% if category == 'error' %}red{% else %}green{% endif %}">
{{ message }}
</p>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="text" name="username" placeholder="아이디" required>
<br><br>
<input type="password" name="password" placeholder="비밀번호" required>
<br><br>
<button type="submit">로그인</button>
</form>
</body>
</html>
|
문제 2: 프로필 편집 폼
사용자 프로필을 수정하는 폼을 만드세요:
- 이름, 이메일, 자기소개, 프로필 사진 업로드
- 이메일 형식 검증
- 파일은 이미지만 허용
✅ 정답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
| # app.py
from flask import Flask, render_template, request, flash, redirect, url_for
import os
import re
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = 'profile-secret-key'
# 업로드 설정
UPLOAD_FOLDER = 'uploads/profiles'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def allowed_file(filename):
"""이미지 파일만 허용"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def validate_email(email):
"""이메일 형식 검증"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@app.route('/profile/edit', methods=['GET', 'POST'])
def edit_profile():
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip()
bio = request.form.get('bio', '').strip()
photo = request.files.get('photo')
# 검증
errors = []
if not name:
errors.append('이름을 입력해주세요')
elif len(name) < 2:
errors.append('이름은 2글자 이상이어야 합니다')
if not email:
errors.append('이메일을 입력해주세요')
elif not validate_email(email):
errors.append('올바른 이메일 형식이 아닙니다')
# 프로필 사진 검증 (선택 사항)
photo_filename = None
if photo and photo.filename:
if not allowed_file(photo.filename):
errors.append('이미지 파일만 업로드 가능합니다 (png, jpg, jpeg, gif)')
else:
photo_filename = secure_filename(photo.filename)
if errors:
for error in errors:
flash(error, 'error')
return redirect(url_for('edit_profile'))
# 파일 저장
if photo_filename:
filepath = os.path.join(app.config['UPLOAD_FOLDER'], photo_filename)
photo.save(filepath)
# 실제로는 데이터베이스에 저장
flash('프로필이 수정되었습니다!', 'success')
return redirect(url_for('edit_profile'))
return render_template('edit_profile.html')
if __name__ == '__main__':
app.run(debug=True)
|
templates/edit_profile.html:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| <!DOCTYPE html>
<html>
<head>
<title>프로필 편집</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
.form-group { margin: 20px 0; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; }
textarea { resize: vertical; min-height: 100px; }
button { padding: 12px 30px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.flash { padding: 10px; margin: 10px 0; border-radius: 5px; }
.flash-error { background: #f8d7da; color: #721c24; }
.flash-success { background: #d4edda; color: #155724; }
#preview { max-width: 200px; margin-top: 10px; display: none; border-radius: 5px; }
</style>
</head>
<body>
<h1>프로필 편집</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="name">이름 *</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">이메일 *</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="bio">자기소개</label>
<textarea id="bio" name="bio" placeholder="간단한 자기소개를 입력하세요"></textarea>
</div>
<div class="form-group">
<label for="photo">프로필 사진 (선택)</label>
<input type="file" id="photo" name="photo" accept="image/*">
<img id="preview" alt="미리보기">
</div>
<button type="submit">💾 저장하기</button>
</form>
<script>
// 이미지 미리보기
document.getElementById('photo').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('preview');
preview.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
</script>
</body>
</html>
|
설명:
enctype="multipart/form-data": 파일 업로드에 필수 allowed_file(): 서버 측에서 이미지 파일만 허용 secure_filename(): 파일명을 안전하게 처리 validate_email(): 정규표현식으로 이메일 검증 - JavaScript: 업로드 전 이미지 미리보기 기능
📝 요약
이번 Day 84에서 학습한 내용:
- HTML 폼:
<form>, <input>, POST/GET 메서드 - 다양한 입력: 텍스트, 체크박스, 라디오, 드롭다운, 파일
- 검증: 필수 필드, 형식 검증, 정규표현식
- 파일 업로드:
secure_filename(), 확장자 검증, 크기 제한
핵심 코드:
1
2
3
4
5
6
7
8
9
| @app.route('/form', methods=['GET', 'POST'])
def handle_form():
if request.method == 'POST':
# 폼 데이터 받기
data = request.form.get('field_name')
# 파일 받기
file = request.files.get('file_name')
return "처리 완료"
return render_template('form.html')
|
📚 다음 학습
Day 85: 데이터베이스 연동 (SQLite) ⭐⭐⭐⭐
지금까지는 데이터를 변수에만 저장했습니다. 서버를 재시작하면 모두 사라지죠! 내일은 SQLite 데이터베이스를 사용해 데이터를 영구적으로 저장하는 방법을 배웁니다!
“안전한 폼 처리는 웹 보안의 시작입니다!” 🔒
| Day 84/100 | Phase 9: 웹 개발 입문 | #100DaysOfPython |