포스트

[Python 100일 챌린지] Day 84 - 폼 처리 (Form Handling)

[Python 100일 챌린지] Day 84 - 폼 처리 (Form Handling)

지금까지 우리는 URL로 데이터를 받았습니다. /user/alice, /post/123 이런 식으로요. 🤔 ──하지만 실제 웹사이트는 사용자 입력을 많이 받습니다!

로그인할 때 아이디와 비밀번호를 입력하고, 회원가입할 때 여러 정보를 입력하고, 게시글을 작성할 때 제목과 내용을 입력하죠.

이 모든 것이 HTML 폼(Form)을 통해 이루어집니다!

오늘은 Flask에서 폼을 만들고, 사용자 입력을 받아 처리하는 방법을 배웁니다. 입력 검증부터 파일 업로드까지, 실전에서 바로 쓸 수 있는 내용입니다! 💡

(35분 완독 ⭐⭐⭐⭐⭐)

🎯 오늘의 학습 목표

  1. HTML 폼의 기초와 Flask 연동
  2. 다양한 입력 필드 처리하기
  3. 폼 데이터 검증과 에러 처리
  4. 파일 업로드 구현하기

📚 사전 지식


🎯 학습 목표 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에서 학습한 내용:

  1. HTML 폼: <form>, <input>, POST/GET 메서드
  2. 다양한 입력: 텍스트, 체크박스, 라디오, 드롭다운, 파일
  3. 검증: 필수 필드, 형식 검증, 정규표현식
  4. 파일 업로드: 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
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.