리스트 저장의 한계부터 확인하기
지난 장에서는 사용자가 작성한 게시글을 파이썬 리스트에 저장했습니다.
posts = [
{
'id': 1,
'title': '첫 번째 게시글',
'author_email': '[email protected]',
'content': 'Flask로 만든 게시판입니다.'
}
]이 방식은 처음 구조를 익히기에는 좋습니다. 리스트는 눈에 잘 보이고, append()로 데이터를 추가하는 흐름도 단순합니다.
하지만 큰 문제가 있습니다. 서버를 끄면 데이터가 사라집니다.
파이썬 리스트는 프로그램이 실행되는 동안만 컴퓨터 메모리에 존재합니다. 메모리는 작업 책상과 비슷합니다. 작업하는 동안에는 빠르게 꺼내 쓸 수 있지만, 프로그램이 종료되면 책상 위 내용이 정리됩니다. Flask 개발 서버를 다시 실행하거나, 디버그 모드에서 코드 수정 때문에 앱이 다시 시작되어도 리스트에 있던 데이터는 처음 상태로 돌아갑니다.
반면 데이터베이스는 파일장에 가깝습니다. 프로그램이 종료되어도 데이터를 파일에 저장해 둡니다. 다시 서버를 실행하면 저장된 데이터를 꺼내 볼 수 있습니다.
이번 장에서는 게시글을 파이썬 리스트가 아니라 SQLite 데이터베이스에 저장하겠습니다. 이제 우리가 만드는 게시판은 서버를 다시 실행해도 게시글이 남는 구조로 바뀝니다.
AI 생성 이미지
SQLite는 작은 파일 하나로 시작할 수 있다
SQLite는 초보자가 데이터베이스를 배우기에 좋은 선택입니다.
일반적인 데이터베이스는 별도의 서버 프로그램을 설치하고 실행해야 합니다. 예를 들어 MySQL이나 PostgreSQL은 데이터베이스 서버가 따로 동작합니다. 처음 배우는 입장에서는 Flask도 배워야 하고, 데이터베이스 서버 설정도 배워야 하니 부담이 큽니다.
SQLite는 다릅니다. 데이터베이스가 하나의 파일로 저장됩니다. 예를 들어 board.sqlite3라는 파일 하나가 게시글을 담는 저장소가 됩니다. 파이썬에는 SQLite를 다루는 sqlite3 모듈이 기본으로 들어 있으므로, 별도의 패키지를 설치하지 않아도 됩니다.
정리하면 SQLite의 장점은 다음과 같습니다.
별도 데이터베이스 서버를 설치하지 않아도 됩니다.
데이터가 하나의 파일에 저장됩니다.
Flask 학습, 개인 프로젝트, 작은 내부 도구를 만들 때 시작하기 쉽습니다.
SQL의 기본 흐름을 익히기에 충분합니다.
물론 모든 상황에 SQLite가 맞는 것은 아닙니다. 동시에 많은 사용자가 접속하고, 쓰기 작업이 매우 많은 서비스라면 더 강력한 데이터베이스를 검토해야 합니다. 하지만 지금 단계에서는 SQLite가 가장 단순하고 알맞습니다.
Flask 공식 튜토리얼도 학습용 게시판 예제에서 SQLite 파일과 초기화 명령을 사용해 데이터베이스를 준비합니다.1
게시글 테이블 설계하기
데이터베이스에 데이터를 저장하려면 먼저 "어떤 모양으로 저장할지" 정해야 합니다. 이 저장 구조를 테이블이라고 합니다.
테이블은 엑셀 시트와 비슷합니다. 게시글 하나가 한 줄이고, 제목이나 내용 같은 항목이 열입니다. 데이터베이스에서는 이 열을 컬럼이라고 부릅니다.
우리가 만들 게시글 테이블은 다음 정보를 담습니다.
| 컬럼 | 의미 |
|---|---|
id | 게시글을 구분하는 번호 |
title | 게시글 제목 |
author_email | 작성자 이메일 |
content | 게시글 내용 |
created_at | 작성일 |
이 구조를 SQL로 표현하면 다음과 같습니다.
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author_email TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);처음 보면 낯설 수 있으니 한 줄씩 보겠습니다.
CREATE TABLE IF NOT EXISTS postsposts라는 테이블을 만듭니다. IF NOT EXISTS는 "이미 있으면 다시 만들지 말라"는 뜻입니다. 덕분에 초기화 명령을 실수로 한 번 더 실행해도 같은 테이블을 또 만들려고 하다가 오류가 나는 일을 줄일 수 있습니다.
id INTEGER PRIMARY KEY AUTOINCREMENTid는 게시글을 구분하는 번호입니다. PRIMARY KEY는 테이블 안에서 각 줄을 구분하는 대표 번호라는 뜻입니다. AUTOINCREMENT는 새 글이 추가될 때 번호를 자동으로 증가시키도록 도와줍니다. 지금은 "새 게시글마다 고유한 번호가 붙는다" 정도로 이해하면 충분합니다.
title TEXT NOT NULLtitle은 문자열이므로 TEXT를 사용합니다. NOT NULL은 비워 둘 수 없다는 뜻입니다. 제목, 이메일, 내용은 모두 반드시 있어야 하므로 같은 방식으로 NOT NULL을 붙였습니다.
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMPcreated_at은 작성일입니다. DEFAULT CURRENT_TIMESTAMP는 값을 직접 넣지 않아도 데이터베이스가 현재 시각을 자동으로 채우게 합니다. SQLite에서 이 값은 보통 UTC 기준의 YYYY-MM-DD HH:MM:SS 형태로 저장됩니다. 화면에 한국 시간처럼 보기 좋게 표시하는 일은 나중에 따로 다룰 수 있으니, 지금은 "저장 시각이 자동으로 들어간다"는 흐름에 집중하겠습니다.
app.py에 데이터베이스 연결 코드 추가하기
이제 Flask 앱에서 SQLite 파일에 연결해 보겠습니다. 기존 posts 리스트는 더 이상 사용하지 않습니다.
app.py 위쪽을 다음처럼 준비합니다.
import sqlite3
from flask import Flask, render_template, request, redirect, url_for, flash, abort, g
app = Flask(__name__)
app.secret_key = 'dev-secret-key'
DATABASE = 'board.sqlite3'sqlite3는 파이썬에서 SQLite를 다루는 기본 모듈입니다.
DATABASE = 'board.sqlite3'는 데이터베이스 파일 이름입니다. 이 파일이 아직 없으면 SQLite가 새로 만들 수 있습니다. 단, 현재 터미널 위치가 프로젝트 폴더와 다르면 예상하지 않은 위치에 파일이 생기거나 파일을 열지 못할 수 있으니, 명령은 app.py가 있는 폴더에서 실행하는 습관을 들이면 좋습니다.
이제 데이터베이스 연결을 여는 함수를 작성합니다.
def get_db():
if 'db' not in g:
g.db = sqlite3.connect(DATABASE)
g.db.row_factory = sqlite3.Row
return g.dbg는 Flask가 요청을 처리하는 동안 잠깐 사용할 값을 담아 두는 공간입니다. 여기서는 데이터베이스 연결을 저장합니다.
왜 매번 새로 연결하지 않고 g에 저장할까요?
하나의 요청을 처리하는 동안 목록을 조회하거나 상세 정보를 가져오는 등 데이터베이스를 여러 번 사용할 수 있습니다. 그때마다 연결을 새로 여는 것보다, 한 번 연 연결을 요청이 끝날 때까지 재사용하는 편이 깔끔합니다.
g.db.row_factory = sqlite3.Row이 설정을 하면 조회 결과를 post['title']처럼 컬럼 이름으로 읽을 수 있습니다. 템플릿에서도 post.title처럼 사용할 수 있어 편합니다.
연결을 열었으면 닫는 코드도 필요합니다.
@app.teardown_appcontext
def close_db(error):
db = g.pop('db', None)
if db is not None:
db.close()teardown_appcontext는 Flask가 요청 처리나 CLI 명령 실행을 마무리하면서 앱 컨텍스트를 정리할 때 실행되는 함수입니다. 열어 둔 데이터베이스 연결을 여기서 닫습니다. Flask 공식 문서에서도 요청 동안 연결을 g에 보관하고, 작업이 끝나면 닫는 패턴을 소개합니다.2
데이터베이스 초기화 명령 만들기
테이블은 자동으로 생기지 않습니다. 데이터베이스 파일이 생겼더라도, 그 안에 posts 테이블이 없으면 게시글을 저장할 수 없습니다. 그래서 처음 한 번은 테이블을 만들어 주는 초기화 작업이 필요합니다.
app.py에 다음 코드를 추가합니다.
def init_db():
db = get_db()
db.execute('''
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author_email TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
''')
db.commit()db.execute()는 SQL을 실행합니다. 여기서는 테이블을 만드는 SQL을 실행했습니다.
db.commit()commit()은 변경 내용을 확정합니다. 데이터베이스에서는 데이터를 추가하거나 구조를 바꾸는 작업을 한 뒤 commit()을 해야 실제로 저장됩니다.
이제 터미널에서 실행할 수 있는 Flask 명령을 하나 만들겠습니다.
@app.cli.command('init-db')
def init_db_command():
init_db()
print('Initialized the database.')터미널에서 다음 명령을 실행합니다.
flask --app app init-db성공하면 프로젝트 폴더에 board.sqlite3 파일이 생깁니다. 이 파일이 앞으로 게시글을 저장할 데이터베이스입니다.
초기화 명령은 서버를 실행하는 명령이 아닙니다. 테이블을 준비하는 명령입니다. 보통 프로젝트를 처음 만들었을 때 한 번 실행하고, 그다음 개발 서버를 실행합니다.
게시글 목록을 SQL로 조회하기
이제 /posts 라우트에서 리스트가 아니라 데이터베이스를 조회하겠습니다.
@app.route('/posts')
def post_list():
db = get_db()
posts = db.execute('''
SELECT id, title, author_email, created_at
FROM posts
ORDER BY id DESC
''').fetchall()
return render_template('posts.html', posts=posts)핵심 SQL은 이 부분입니다.
SELECT id, title, author_email, created_at
FROM posts
ORDER BY id DESCSELECT는 데이터를 조회할 때 사용합니다. FROM posts는 posts 테이블에서 가져오겠다는 뜻입니다. ORDER BY id DESC는 최근에 작성한 글이 위에 오도록 id를 큰 순서부터 정렬합니다.
fetchall()은 조회된 모든 결과를 가져옵니다. 게시글 목록처럼 여러 줄을 가져올 때 사용합니다.
templates/posts.html도 상세 페이지로 이동할 수 있게 조금 수정합니다.
{% extends 'base.html' %}
{% block title %}게시글 목록{% endblock %}
{% block content %}
<h2>게시글 목록</h2>
<p>
<a href="{{ url_for('post_new') }}">새 게시글 작성</a>
</p>
{% if posts %}
<ul>
{% for post in posts %}
<li>
<a href="{{ url_for('post_detail', post_id=post.id) }}">
{{ post.title }}
</a>
<span>({{ post.author_email }})</span>
<small>{{ post.created_at }}</small>
</li>
{% endfor %}
</ul>
{% else %}
<p>아직 게시글이 없습니다.</p>
{% endif %}
{% endblock %}post.id, post.title처럼 사용할 수 있는 이유는 앞에서 row_factory를 설정했기 때문입니다.
게시글 상세 조회하기
목록에서 글 제목을 클릭하면 상세 페이지로 이동하게 만들겠습니다.
@app.route('/posts/<int:post_id>')
def post_detail(post_id):
db = get_db()
post = db.execute('''
SELECT id, title, author_email, content, created_at
FROM posts
WHERE id = ?
''', (post_id,)).fetchone()
if post is None:
abort(404)
return render_template('post_detail.html', post=post)여기서 중요한 부분은 WHERE id = ?입니다.
WHERE id = ??는 나중에 값을 넣을 자리입니다. 실제 값은 뒤의 튜플로 전달합니다.
(post_id,)값이 하나뿐인데도 post_id 뒤에 쉼표를 붙였습니다. 파이썬에서 값 하나짜리 튜플을 만들 때는 (post_id,)처럼 쉼표가 필요합니다.
값을 SQL 문자열에 직접 붙이지 않는 이유는 보안 때문입니다. 사용자가 보낸 값을 SQL 문장에 그대로 섞으면 의도하지 않은 SQL이 실행될 위험이 있습니다. 이런 공격을 SQL 인젝션이라고 합니다. ? 자리표시자를 사용하면 sqlite3가 값을 SQL 문장 자체가 아니라 "데이터 값"으로 안전하게 처리해 줍니다.
fetchone()은 결과를 한 줄만 가져옵니다. 상세 페이지는 게시글 하나만 필요하므로 fetchone()이 알맞습니다.
상세 템플릿 templates/post_detail.html을 만듭니다.
{% extends 'base.html' %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<article>
<h2>{{ post.title }}</h2>
<p>
{{ post.author_email }} · {{ post.created_at }}
</p>
<p>{{ post.content }}</p>
</article>
<p>
<a href="{{ url_for('post_list') }}">목록으로</a>
</p>
{% endblock %}존재하지 않는 글 번호로 접근하면 post가 None이 됩니다. 이때 abort(404)로 "페이지를 찾을 수 없음" 응답을 보냅니다.
게시글 작성 내용을 데이터베이스에 저장하기
이제 가장 중요한 글 작성 기능입니다. 지난 장의 검증 함수 validate_post()는 그대로 사용합니다. 바뀌는 부분은 검증을 통과한 뒤 리스트에 append()하던 코드를 SQL INSERT로 바꾸는 것입니다.
@app.route('/posts/new', methods=['GET', 'POST'])
def post_new():
if request.method == 'POST':
form = {
'title': request.form.get('title', '').strip(),
'author_email': request.form.get('author_email', '').strip(),
'content': request.form.get('content', '').strip()
}
errors = validate_post(form)
if errors:
flash('입력 내용을 다시 확인해 주세요.', 'error')
return render_template('post_form.html', form=form, errors=errors)
db = get_db()
db.execute('''
INSERT INTO posts (title, author_email, content)
VALUES (?, ?, ?)
''', (form['title'], form['author_email'], form['content']))
db.commit()
flash('게시글이 저장되었습니다.', 'success')
return redirect(url_for('post_list'))
form = {
'title': '',
'author_email': '',
'content': ''
}
errors = {}
return render_template('post_form.html', form=form, errors=errors)저장 SQL은 이 부분입니다.
INSERT INTO posts (title, author_email, content)
VALUES (?, ?, ?)INSERT INTO는 새 데이터를 추가할 때 사용합니다. created_at은 넣지 않았습니다. 테이블을 만들 때 DEFAULT CURRENT_TIMESTAMP를 설정했기 때문에 데이터베이스가 자동으로 채웁니다.
그리고 반드시 commit()을 호출합니다.
db.commit()commit()이 빠지면 코드가 실행된 것처럼 보여도 변경 내용이 확정되지 않을 수 있습니다. 조회는 SELECT, 추가는 INSERT, 저장 확정은 commit()이라고 기억하면 됩니다.
실행 순서 정리하기
이번 장의 코드를 반영했다면 터미널에서 다음 순서로 실행합니다.
flask --app app init-db
flask --app app run --debug브라우저에서 다음 주소로 이동합니다.
http://127.0.0.1:5000/posts그다음 새 게시글을 작성해 보세요. 저장 후 목록으로 돌아오고, 제목을 클릭하면 상세 화면이 열립니다.
서버를 종료한 뒤 다시 실행해도 게시글이 남아 있다면 성공입니다.
flask --app app run --debug데이터가 남아 있는 이유는 이제 게시글이 파이썬 리스트가 아니라 board.sqlite3 파일 안에 저장되기 때문입니다.
자주 만나는 오류
데이터베이스를 처음 연결하면 오류를 자주 만납니다. 오류 메시지는 겁낼 대상이 아니라, 어디를 확인해야 하는지 알려 주는 안내문입니다.
no such table: posts
posts 테이블이 없다는 뜻입니다. 보통 초기화 명령을 실행하지 않았을 때 발생합니다.
flask --app app init-db명령을 실행한 뒤 서버를 다시 실행해 보세요.
unable to open database file
데이터베이스 파일을 만들거나 열 수 없다는 뜻입니다. 파일 경로가 잘못되었거나, 현재 폴더가 예상과 다를 때 발생할 수 있습니다. 터미널이 app.py가 있는 프로젝트 폴더에서 열려 있는지 확인하세요.
database is locked
SQLite 파일이 다른 작업에 의해 잠겨 있다는 뜻입니다. 개발 중에는 대개 서버를 재시작하거나, 같은 데이터베이스 파일을 열어 둔 다른 프로그램을 닫으면 해결됩니다.
이번 장에서 바뀐 것
이번 장에서 게시판의 저장 방식이 크게 바뀌었습니다.
이전에는 게시글이 파이썬 리스트에만 있었습니다. 그래서 서버를 끄면 사라졌습니다. 이제는 게시글이 SQLite 데이터베이스 파일에 저장됩니다. 서버를 다시 실행해도 데이터가 남습니다.
또한 SQL의 기본 흐름을 사용했습니다.
CREATE TABLE: 데이터를 담을 테이블을 만든다.SELECT: 데이터를 조회한다.INSERT: 새 데이터를 추가한다.WHERE: 특정 조건에 맞는 데이터를 찾는다.commit(): 변경 내용을 확정한다.
데이터베이스를 사용하기 시작하면 웹 애플리케이션은 훨씬 현실적인 형태가 됩니다. 사용자가 입력한 내용이 기록되고, 목록과 상세 화면에서 다시 조회됩니다.
다음 장에서는 코드가 점점 길어지는 문제를 다룹니다. 지금은 app.py 하나에 라우트, 검증, 데이터베이스 코드가 모두 들어 있습니다. 프로젝트가 커질수록 파일을 나누고 구조를 정리해야 합니다. 그때 필요한 개념이 애플리케이션 팩토리와 프로젝트 구조입니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.