Flask 기초 | Python 웹 프레임워크 시작하기
이 글의 핵심
Flask는 필요한 것만 골라 붙이는 마이크로 프레임워크라 처음 웹 앱을 만들기에 부담이 적습니다. 대신 session을 쓰려면 secret_key를 반드시 설정해야 하고 debug=True를 운영에서 켜 두면 안 되는 등 기본값에 숨은 함정이 있어, 기능을 하나씩 붙이면서 이런 주의점을 함께 짚습니다.
들어가며
Flask는 간단하고 유연한 마이크로 웹 프레임워크입니다. “마이크로”라는 말은 기능이 부족하다는 뜻이 아니라, 코어가 URL 라우팅, 요청·응답 객체, 템플릿 렌더링, 세션 정도만 맡고 나머지는 개발자가 고르게 둔다는 뜻입니다. 데이터베이스는 Flask-SQLAlchemy, 폼 검증은 Flask-WTF, 인증은 Flask-Login처럼 확장을 붙여 구성합니다.
이 방식의 장점은 파일 하나로 서버를 띄울 수 있을 만큼 진입 장벽이 낮다는 것이고, 단점은 프로젝트가 커질수록 구조를 스스로 정해야 한다는 것입니다. 이 글에서는 한 파일에서 라우팅부터 세션까지 기본 기능을 붙여 보고, 각 단계에서 초보자가 자주 부딪히는 오류를 함께 짚습니다.
Flask 시작하기
설치
pip install flask
전역 Python에 바로 설치하기보다 python -m venv .venv로 가상 환경을 만든 뒤 설치하는 편이 좋습니다. 프로젝트마다 Flask 버전이 달라질 수 있고, 나중에 pip freeze > requirements.txt로 배포 환경을 재현할 때도 필요한 패키지만 남습니다.
Hello World
Flask(__name__)는 요청이 들어오면 어느 함수가 응답할지 연결해 주는 소형 공장을 만드는 단계입니다. @app.route('/')는 “주소 /로 오면 아래 함수를 실행해 문자열을 돌려준다”는 표지판을 붙이는 것과 같습니다.
# app.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return "Hello, Flask!"
if __name__ == '__main__':
app.run(debug=True)
# 실행
python app.py
# 브라우저에서 http://localhost:5000 접속
__name__을 넘기는 이유는 Flask가 이 값을 기준으로 templates/, static/ 폴더 위치를 찾기 때문입니다. 뷰 함수가 반환한 문자열은 Flask가 Content-Type: text/html 응답으로 감싸 줍니다.
python app.py 대신 flask --app app run --debug 명령으로도 실행할 수 있습니다. 공식 문서가 권장하는 방식은 이쪽이고, if __name__ == '__main__': 블록 없이도 동작합니다. debug=True는 코드 변경 시 자동 재시작과 브라우저 안의 대화형 디버거를 켜는데, 이 디버거는 브라우저에서 임의의 Python 코드를 실행할 수 있는 기능이라 외부에 노출된 서버에서 켜 두면 원격 코드 실행 취약점이 됩니다. 또 app.run()이 띄우는 서버는 개발용이라 운영에서는 Gunicorn이나 uWSGI 같은 WSGI 서버 뒤에 둡니다.
macOS Monterey 이후 버전에서 5000번 포트로 접속했는데 Flask 로그에는 아무것도 찍히지 않고 403 응답만 온다면, 시스템의 AirPlay 수신 기능이 같은 포트를 쓰고 있는 경우가 많습니다. 처음 Flask를 설치했을 때 코드 문제인 줄 알고 헤매기 쉬운 증상인데, app.run(port=5001)로 포트를 바꾸거나 시스템 설정에서 AirPlay 수신 모드를 끄면 해결됩니다.
라우팅 (Routing)
기본 라우팅
@app.route('/')
def index():
return "홈페이지"
@app.route('/about')
def about():
return "소개 페이지"
@app.route('/user/<username>')
def user_profile(username):
return f"{username}님의 프로필"
@app.route('/post/<int:post_id>')
def show_post(post_id):
return f"포스트 #{post_id}"
<username>처럼 꺾쇠로 감싼 부분은 URL 변수이고, 같은 이름의 함수 인자로 전달됩니다. 기본 타입은 슬래시를 제외한 문자열(string)이며, <int:post_id>처럼 변환기를 붙이면 Flask가 정수로 바꿔 줄 뿐 아니라 /post/abc처럼 숫자가 아닌 요청은 뷰 함수를 호출하지 않고 404로 돌려보냅니다. 함수 안에서 int() 변환과 예외 처리를 직접 할 필요가 없어집니다. 이 밖에 float, path(슬래시 포함), uuid 변환기가 있습니다.
URL 끝 슬래시에도 규칙이 있습니다. @app.route('/about/')처럼 슬래시로 끝나게 정의하면 /about 요청을 /about/로 리다이렉트해 주지만, 위 예제처럼 슬래시 없이 정의한 경로에 /about/로 접근하면 404가 납니다. 팀 안에서 한 가지 규칙으로 통일해 두는 편이 좋습니다.
HTTP 메서드
from flask import request
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
return f"로그인 시도: {username}"
return "로그인 페이지"
methods를 지정하지 않으면 라우트는 GET(과 자동으로 HEAD, OPTIONS)만 받습니다. POST 폼을 보냈는데 405 Method Not Allowed가 뜬다면 대부분 methods=['POST']를 빠뜨린 경우입니다.
request.form['username']처럼 대괄호로 꺼내면 해당 필드가 없을 때 KeyError가 아니라 Flask가 400 Bad Request를 반환합니다. 필드가 선택 사항이라면 다음 절처럼 request.form.get('username')을 써서 None을 받고 직접 처리하는 편이 의도가 분명합니다.
템플릿 (Jinja2)
템플릿 사용
# app.py
from flask import render_template
@app.route('/user/<name>')
def user(name):
return render_template('user.html', name=name)
<!-- templates/user.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{ name }}님의 페이지</title>
</head>
<body>
<h1>안녕하세요, {{ name }}님!</h1>
{% if name == '철수' %}
<p>관리자입니다.</p>
{% else %}
<p>일반 사용자입니다.</p>
{% endif %}
<ul>
{% for i in range(5) %}
<li>항목 {{ i }}</li>
{% endfor %}
</ul>
</body>
</html>
render_template은 app.py와 같은 위치의 templates/ 폴더에서 파일을 찾습니다. 폴더 이름을 template처럼 잘못 쓰면 jinja2.exceptions.TemplateNotFound: user.html 오류가 납니다. 키워드 인자로 넘긴 값(name=name)이 템플릿 안에서 변수로 쓰입니다.
Jinja2는 .html 템플릿에서 {{ }} 출력 값을 자동으로 HTML 이스케이프합니다. URL로 받은 name에 <script>를 넣어도 문자 그대로 표시되는 이유입니다. 신뢰할 수 있는 HTML을 그대로 출력하려고 |safe 필터를 붙이는 순간 이 보호가 꺼지므로, 사용자 입력에는 쓰지 않아야 합니다. 예제의 {% if name == '철수' %}는 문법 예시일 뿐이고, 실제 권한 판단은 템플릿이 아니라 뷰 함수나 인증 로직에서 해야 합니다. 페이지가 여러 개가 되면 공통 레이아웃을 base.html에 두고 {% extends %}와 {% block %}으로 상속하는 방식이 일반적입니다.
폼 처리
폼 데이터 받기
from flask import request, redirect, url_for
@app.route('/submit', methods=['GET', 'POST'])
def submit():
if request.method == 'POST':
name = request.form.get('name')
email = request.form.get('email')
# 데이터 처리
print(f"이름: {name}, 이메일: {email}")
return redirect(url_for('success'))
return '''
<form method="post">
<input type="text" name="name" placeholder="이름">
<input type="email" name="email" placeholder="이메일">
<button type="submit">제출</button>
</form>
'''
@app.route('/success')
def success():
return "제출 완료!"
POST 처리 후 HTML을 바로 반환하지 않고 redirect()로 다른 페이지로 보내는 것을 PRG(Post/Redirect/Get) 패턴이라고 합니다. POST 응답 화면에서 사용자가 새로고침을 누르면 브라우저가 폼을 다시 제출해 같은 데이터가 두 번 저장되는데, 리다이렉트 후에는 새로고침이 GET 요청이 되어 이 문제가 사라집니다.
url_for('success')는 URL 문자열이 아니라 함수 이름을 받아 경로를 만들어 줍니다. 나중에 /success를 /done으로 바꿔도 url_for를 쓴 곳은 고칠 필요가 없습니다. 함수 이름을 잘못 쓰면 werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'sucess' 같은 오류가 납니다.
이 예제는 HTML을 문자열로 반환하고 입력 검증도 하지 않습니다. 실제 서비스라면 템플릿으로 폼을 분리하고, 빈 값·이메일 형식을 서버에서 다시 검사하고, CSRF 토큰을 넣어야 합니다. Flask 코어에는 CSRF 보호가 없어서 Flask-WTF 같은 확장을 붙이는 것이 보통입니다.
JSON API
REST API 만들기
from flask import jsonify, request
users = [
{'id': 1, 'name': '철수', 'age': 25},
{'id': 2, 'name': '영희', 'age': 30}
]
@app.route('/api/users', methods=['GET'])
def get_users():
return jsonify(users)
@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
user = next((u for u in users if u['id'] == user_id), None)
if user:
return jsonify(user)
return jsonify({'error': 'User not found'}), 404
@app.route('/api/users', methods=['POST'])
def create_user():
data = request.get_json()
new_user = {
'id': len(users) + 1,
'name': data['name'],
'age': data['age']
}
users.append(new_user)
return jsonify(new_user), 201
jsonify()는 파이썬 객체를 JSON 문자열로 바꾸고 Content-Type: application/json 헤더까지 붙인 응답 객체를 만듭니다. Flask 1.1 이후로는 뷰에서 dict를 그냥 반환해도 JSON 응답이 되고, 2.2 이후로는 list도 가능합니다. return jsonify(...), 404처럼 튜플 두 번째 값은 상태 코드입니다. 생성 요청에 201 Created를 돌려주는 것은 클라이언트가 “새 리소스가 만들어졌다”는 사실을 코드만으로 알 수 있게 하기 위함입니다.
request.get_json()은 요청 헤더가 Content-Type: application/json일 때만 본문을 파싱합니다. curl로 테스트하면서 -H "Content-Type: application/json"을 빠뜨리면 Flask 2.3 이후 버전에서는 415 Unsupported Media Type이 반환됩니다. 또 data['name']은 필드가 없으면 KeyError로 500 오류가 나므로, 실제로는 필수 필드를 검사하고 400을 돌려줘야 합니다.
'id': len(users) + 1은 예제를 짧게 하기 위한 방법이고, 중간 항목을 삭제하면 id가 중복됩니다. 이 절은 Flask가 JSON을 주고받는 방법만 보여 주는 맛보기입니다. PUT·DELETE까지 갖춘 CRUD, 입력 검증과 대량 할당(mass assignment) 문제, 메모리 저장소가 워커마다 달라지는 이유, 인증과 에러 응답 형식은 Python REST API 편에서 이어서 다룹니다.
세션과 쿠키
세션 사용
from flask import session
app.secret_key = 'your-secret-key'
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
session['username'] = username
return redirect(url_for('dashboard'))
@app.route('/dashboard')
def dashboard():
if 'username' in session:
return f"환영합니다, {session['username']}님!"
return redirect(url_for('login'))
@app.route('/logout')
def logout():
session.pop('username', None)
return redirect(url_for('index'))
Flask의 기본 session은 서버에 저장되지 않고, 내용을 직렬화해 secret_key로 서명한 뒤 쿠키에 담습니다. 서명 덕분에 사용자가 쿠키 값을 바꾸면 Flask가 무효로 처리하지만, 암호화는 되지 않아 base64를 풀면 내용이 그대로 보입니다. 그래서 사용자 id 같은 식별자만 넣고 비밀번호나 개인정보는 넣지 않습니다. secret_key를 설정하지 않고 세션에 값을 쓰면 RuntimeError: The session is unavailable because no secret key was set. 오류가 납니다. 운영에서는 secrets.token_hex(32)로 만든 값을 환경 변수에 두고 읽어 옵니다.
이 절의 코드를 2절 예제와 같은 파일에 붙이면 AssertionError: View function mapping is overwriting an existing endpoint function: login이 납니다. Flask는 기본적으로 함수 이름을 엔드포인트 이름으로 쓰기 때문에 같은 이름의 뷰 함수가 두 번 등록되면 거부합니다. 또 여기의 /login은 POST만 받기 때문에 dashboard에서 redirect(url_for('login'))로 보내면 브라우저의 GET 요청이 405로 막힙니다. 실제로는 2절처럼 GET으로 로그인 폼을 보여 주고 POST로 처리하는 하나의 뷰로 합쳐야 합니다. 세션 기반 로그인 흐름을 직접 짜기보다 Flask-Login 확장을 쓰면 “로그인 필요” 데코레이터와 리다이렉트 처리를 제공받을 수 있습니다.
실전 예제
간단한 블로그 API
from flask import Flask, jsonify, request
from datetime import datetime
app = Flask(__name__)
posts = []
@app.route('/api/posts', methods=['GET'])
def get_posts():
return jsonify(posts)
@app.route('/api/posts', methods=['POST'])
def create_post():
data = request.get_json()
post = {
'id': len(posts) + 1,
'title': data['title'],
'content': data['content'],
'created_at': datetime.now().isoformat()
}
posts.append(post)
return jsonify(post), 201
@app.route('/api/posts/<int:post_id>', methods=['GET'])
def get_post(post_id):
post = next((p for p in posts if p['id'] == post_id), None)
if post:
return jsonify(post)
return jsonify({'error': 'Not found'}), 404
if __name__ == '__main__':
app.run(debug=True)
같은 URL /api/posts에 GET과 POST를 다른 함수로 나눠 등록한 점에 주목하세요. 경로가 같아도 메서드가 다르면 별도 라우트로 취급되므로, 하나의 함수 안에서 if request.method로 분기하는 것보다 함수 하나가 한 가지 일만 하게 됩니다. datetime.now().isoformat()은 서버의 로컬 시간이라 시간대 정보가 없는데, 클라이언트가 여러 지역에 있다면 datetime.now(timezone.utc)로 UTC를 저장하고 표시할 때 변환하는 편이 안전합니다.
파일이 커지면 기능별로 Blueprint를 나누고, create_app() 함수 안에서 앱을 만드는 애플리케이션 팩토리 패턴을 씁니다. 이렇게 하면 테스트에서 설정만 바꾼 앱 인스턴스를 따로 만들 수 있고, 위에서 본 엔드포인트 이름 충돌도 Blueprint 이름이 접두어로 붙어 줄어듭니다.
같이 보면 좋은 글
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- Pandas 기초 | Python 데이터 분석 라이브러리
- Python 예외 처리 | try-except, raise, 커스텀 예외
- Python REST API | Flask/Django로 API 서버 만들기
자주 묻는 질문 (FAQ)
Q. Flask에서 session을 쓰려면 secret_key를 왜 설정해야 하나요?
A. Flask의 기본 세션은 서버가 아니라 브라우저 쿠키에 저장되고, secret_key로 서명해 사용자가 내용을 조작하지 못하게 합니다. 키가 없으면 세션을 사용할 수 없고, 키가 노출되면 누구나 유효한 세션 쿠키를 만들 수 있습니다. 예제처럼 코드에 문자열로 적기보다는 환경 변수에서 읽어 오고, 서명만 될 뿐 암호화되지는 않으므로 비밀번호 같은 민감 정보는 세션에 넣지 않는 것이 좋습니다.