문자열 HTML의 한계
지금까지는 뷰 함수에서 문자열을 바로 반환했습니다.
@app.route('/')
def index():
return '<h1>안녕하세요</h1>'작은 예제에서는 이 방식도 괜찮습니다. 하지만 화면이 조금만 길어져도 금방 불편해집니다. 파이썬 코드 안에 HTML 태그가 섞이고, 따옴표를 맞추기 어려워지고, 같은 메뉴를 여러 페이지에 반복해서 써야 합니다.
그래서 Flask에서는 템플릿을 사용합니다. 템플릿은 HTML 파일 안에 파이썬에서 보낸 값을 끼워 넣을 수 있는 화면 파일입니다. Flask는 기본 템플릿 엔진으로 Jinja를 사용합니다. 템플릿 엔진은 "HTML 문서에 데이터를 알맞게 채워 최종 HTML을 만드는 도구"라고 이해하면 됩니다.1
이 장의 목표는 파이썬 코드와 HTML을 나누는 것입니다.
app.py: 요청을 받고 데이터를 준비합니다.templates/*.html: 브라우저에 보여 줄 화면을 만듭니다.
이렇게 나누면 뷰 함수는 짧아지고, 화면 코드는 HTML 파일에서 따로 관리할 수 있어 훨씬 읽기 쉬워집니다.
AI 생성 이미지
templates 폴더와 render_template
Flask는 기본적으로 애플리케이션 파일 옆의 templates 폴더에서 HTML 템플릿을 찾습니다. 먼저 프로젝트 구조를 다음처럼 만듭니다.
flask-study/
├─ app.py
└─ templates/
└─ index.htmlapp.py를 다음처럼 수정합니다.
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html', name='Flask')이번에는 return '<h1>...</h1>'처럼 HTML 문자열을 직접 쓰지 않습니다. 대신 render_template() 함수를 사용합니다.
from flask import Flask, render_template: 템플릿을 렌더링할 함수를 가져옵니다.render_template('index.html', name='Flask'):templates/index.html파일을 읽고, 그 안에서 사용할name값을 전달합니다.렌더링은 템플릿과 데이터를 합쳐 최종 HTML을 만드는 과정입니다.
이제 templates/index.html을 만듭니다.
<!doctype html>
<html lang='ko'>
<head>
<meta charset='utf-8'>
<title>Flask 템플릿</title>
</head>
<body>
<h1>안녕하세요, {{ name }}!</h1>
</body>
</html>{{ name }}은 Jinja 문법입니다. 파이썬에서 전달한 name='Flask' 값이 이 자리에 들어갑니다. 브라우저에는 다음처럼 보입니다.
안녕하세요, Flask!템플릿을 사용하면 뷰 함수는 "어떤 데이터를 보낼지"에 집중하고, HTML 파일은 "어떻게 보여 줄지"에 집중할 수 있습니다.
Jinja의 변수, 조건문, 반복문
Jinja에서 가장 자주 쓰는 문법은 세 가지입니다.
| 문법 | 역할 |
|---|---|
{{ 값 }} | 값을 HTML에 출력합니다. |
{% if 조건 %} | 조건에 따라 다른 HTML을 보여 줍니다. |
{% for 항목 in 목록 %} | 목록을 반복해서 출력합니다. |
작은 게시글 목록 화면을 만들어 보겠습니다. app.py에 임시 게시글 데이터를 둡니다. 아직 데이터베이스를 배우기 전이므로 리스트와 딕셔너리로 대신합니다.
from flask import Flask, render_template, abort
app = Flask(__name__)
posts = [
{'id': 1, 'title': '첫 번째 글', 'content': 'Flask 템플릿을 배웁니다.'},
{'id': 2, 'title': '두 번째 글', 'content': 'Jinja로 목록을 출력합니다.'},
]
@app.route('/')
def index():
return render_template('index.html', name='Flask')
@app.route('/posts')
def post_list():
return render_template('posts.html', posts=posts)여기서 abort는 잠시 뒤 상세 페이지에서 없는 게시글을 요청했을 때 404 응답을 보내기 위해 미리 가져왔습니다.
posts=posts에서 왼쪽 posts는 템플릿에서 사용할 이름입니다. 오른쪽 posts는 파이썬 변수입니다. 이름이 같아서 처음에는 헷갈릴 수 있지만, 왼쪽은 "템플릿에 넘길 이름", 오른쪽은 "현재 파이썬 코드에 있는 값"이라고 생각하면 됩니다.
이제 templates/posts.html을 만듭니다.
<!doctype html>
<html lang='ko'>
<head>
<meta charset='utf-8'>
<title>게시글 목록</title>
</head>
<body>
<h1>게시글 목록</h1>
{% if posts %}
<ul>
{% for post in posts %}
<li>{{ post.title }}</li>
{% endfor %}
</ul>
{% else %}
<p>아직 게시글이 없습니다.</p>
{% endif %}
</body>
</html>여기서 {% if posts %}는 게시글 목록이 비어 있지 않은지 확인합니다. 게시글이 있으면 <ul> 목록을 보여 주고, 없으면 안내 문장을 보여 줍니다.
{% for post in posts %}는 파이썬의 for post in posts:와 비슷합니다. 목록 안의 게시글을 하나씩 꺼내 post라는 이름으로 사용합니다. Jinja에서는 딕셔너리 값을 post['title']처럼 쓸 수도 있지만, 보통 post.title처럼 점으로 접근하는 방식을 많이 사용합니다.
base.html로 공통 레이아웃 만들기
지금 index.html과 posts.html에는 <!doctype html>, <html>, <head>, <body> 같은 구조가 반복됩니다. 페이지가 10개가 되면 같은 코드를 10번 수정해야 할 수도 있습니다.
이 문제를 줄이기 위해 템플릿 상속을 사용합니다. 템플릿 상속은 공통 뼈대 파일을 하나 만들고, 각 페이지가 필요한 부분만 채우는 방식입니다. Flask 공식 튜토리얼에서도 base.html에 공통 구조를 두고 다른 템플릿이 이를 확장하는 방식을 사용합니다.2
먼저 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("index") }}'>홈</a>
<a href='{{ url_for("post_list") }}'>게시글</a>
</nav>
<hr>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>{% block title %}과 {% block content %}는 "각 페이지가 바꿔 넣을 수 있는 자리"입니다. 마치 빈칸이 있는 공통 양식과 같습니다.
url_for('index')는 index 뷰 함수에 연결된 URL을 만들어 줍니다. 지금은 /를 반환합니다. 링크 주소를 직접 href='/'로 써도 되지만, 나중에 URL이 바뀌면 모든 HTML을 찾아 고쳐야 합니다. url_for()를 쓰면 함수 이름을 기준으로 URL을 만들기 때문에 변경에 더 강합니다.
이제 templates/index.html을 짧게 바꿉니다. 앞에서 만들었던 전체 HTML 문서를 아래 내용으로 교체하면 됩니다.
{% extends 'base.html' %}
{% block title %}홈{% endblock %}
{% block content %}
<h1>Flask 게시판</h1>
<p>템플릿으로 화면을 구성하는 연습을 합니다.</p>
{% endblock %}{% extends 'base.html' %}는 이 템플릿이 base.html을 기본 구조로 사용한다는 뜻입니다. 그리고 block title, block content 안에 이 페이지에서만 필요한 내용을 넣습니다.
posts.html도 같은 방식으로 바꿉니다.
{% extends 'base.html' %}
{% block title %}게시글 목록{% endblock %}
{% block content %}
<h1>게시글 목록</h1>
{% if posts %}
<ul>
{% for post in posts %}
<li>
<a href='{{ url_for("post_detail", post_id=post.id) }}'>
{{ post.title }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>아직 게시글이 없습니다.</p>
{% endif %}
{% endblock %}이번에는 게시글 제목에 링크를 걸었습니다. post_detail이라는 뷰 함수에 post_id 값을 넘겨 상세 페이지 URL을 만듭니다. 아직 post_detail 뷰 함수는 만들지 않았으므로, 바로 다음 절에서 추가하겠습니다.
상세 페이지 템플릿 만들기
목록에서 제목을 클릭하면 상세 페이지로 이동하게 만들어 보겠습니다. app.py에 라우트를 추가합니다.
@app.route('/posts/<int:post_id>')
def post_detail(post_id):
post = None
for item in posts:
if item['id'] == post_id:
post = item
break
if post is None:
abort(404)
return render_template('post_detail.html', post=post)코드를 차례대로 보겠습니다.
/posts/<int:post_id>는 URL의 숫자 부분을post_id로 받습니다. 예를 들어/posts/1로 접속하면post_id에는 정수1이 들어갑니다.post = None은 아직 게시글을 찾지 못했다는 표시입니다.for item in posts:는 임시 게시글 목록을 하나씩 확인합니다.item['id'] == post_id이면 요청한 글을 찾은 것입니다.끝까지 찾지 못하면
abort(404)로 "없는 페이지" 응답을 보냅니다.찾았다면
post_detail.html에post를 전달합니다.
이제 templates/post_detail.html을 만듭니다.
{% extends 'base.html' %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</article>
<p>
<a href='{{ url_for("post_list") }}'>목록으로 돌아가기</a>
</p>
{% endblock %}이제 우리 예제는 세 화면을 갖게 되었습니다.
/: 홈 화면/posts: 게시글 목록 화면/posts/1,/posts/2: 게시글 상세 화면
중요한 점은 모든 페이지가 base.html을 공유한다는 것입니다. 메뉴를 바꾸고 싶으면 base.html만 수정하면 됩니다.
자동 이스케이프와 안전한 출력
웹 화면에는 사용자가 입력한 값이 자주 표시됩니다. 예를 들어 누군가 게시글 제목에 다음 값을 넣었다고 생각해 봅시다.
<script>alert('위험')</script>이 문자열이 그대로 HTML로 해석되면 브라우저에서 스크립트가 실행될 수 있습니다. 이런 공격을 XSS라고 부릅니다. XSS는 사용자가 입력한 글자를 웹페이지 코드처럼 실행하게 만드는 공격입니다.
Flask는 .html, .htm 같은 템플릿을 render_template()로 렌더링할 때 자동 이스케이프를 기본으로 켭니다.1 이스케이프는 <, >, & 같은 특수 문자를 HTML 코드가 아니라 일반 글자로 보이도록 바꾸는 처리입니다.
그래서 템플릿에 다음처럼 쓰면,
{{ post.title }}사용자가 <script>를 입력해도 보통은 코드로 실행되지 않고 글자로 표시됩니다. 초보 단계에서는 사용자가 입력한 값은 항상 {{ ... }}로 출력한다고 생각하면 안전한 습관을 들일 수 있습니다.
Jinja에는 |safe라는 기능도 있습니다.
{{ content|safe }}이것은 "이 값은 안전하니 HTML로 해석해도 된다"는 뜻입니다. 하지만 사용자가 입력한 값에 |safe를 붙이면 위험할 수 있습니다. 정말 믿을 수 있는 HTML일 때만 사용해야 합니다. 처음에는 거의 쓰지 않는 편이 좋습니다.
이번 장에서 만든 구조
지금까지 만든 파일 구조는 다음과 같습니다.
flask-study/
├─ app.py
└─ templates/
├─ base.html
├─ index.html
├─ posts.html
└─ post_detail.html이 장의 핵심은 단순합니다.
HTML은
templates폴더에 둡니다.뷰 함수에서는
render_template()로 템플릿을 렌더링합니다.{{ 변수 }}로 값을 출력합니다.{% if %},{% for %}로 조건과 반복을 처리합니다.base.html과{% extends %}로 중복을 줄입니다.url_for()로 뷰 함수 이름을 기준으로 링크 주소를 만듭니다.사용자 입력을 출력할 때는 자동 이스케이프가 보안을 도와줍니다.
이제 Flask에서 화면을 만드는 기본 흐름을 익혔습니다. 이후에는 이 템플릿 위에 사용자 입력, 데이터 저장, 프로젝트 구조 정리 같은 기능을 차례로 얹어 가면서 더 실제 웹 애플리케이션에 가까운 형태로 발전시킬 수 있습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.