SQLite가 생각보다 강력한 이유 — 개인 프로젝트에서 PostgreSQL 대신
PostgreSQL을 설정하다가 SQLite로 돌아온 이유
2024년 초, 나는 사이드 프로젝트를 시작했다. 처음엔 당연히 PostgreSQL을 써야 한다고 생각했다. 개발하며 느낀 바로는, "진정한" 데이터베이스는 PostgreSQL이었으니까. 하지만 몇 가지 문제가 생겼다:
- AWS RDS t3.micro 인스턴스: 월 $15
- 백업 설정: 복잡함
- 연결 풀 관리: 또 다른 도구 필요
- 마이그레이션 도구: Alembic 설정
그리고 생각해보니 내 프로젝트는:
- 동시 사용자: 최대 10명
- 데이터 크기: 수백 MB
- 처리량: 초당 수십 개 요청
"이 정도면 SQLite로도 충분하지 않을까?"
결정을 내렸다. SQLite로 마이그레이션하기로.
6개월이 지난 지금, 나는 그 결정에 완벽히 만족한다. 최신 SQLite는 생각보다 훨씬 강력하다.
SQLite의 숨겨진 강점 1: WAL 모드
SQLite의 최대 문제는 항상 "동시성"이었다. 하나의 쓰기 트랜잭션 중에 다른 쓰기가 불가능했다. 하지만 WAL (Write-Ahead Logging) 모드는 이 문제를 완전히 해결한다.
import sqlite3
conn = sqlite3.connect('app.db')
conn.execute('PRAGMA journal_mode=WAL')
# WAL 활성화 후의 효과
# - 여러 읽기 쿼리는 동시에 가능
# - 쓰기 중에도 읽기 가능
# - 읽기 성능이 향상됨
# 추가 설정
conn.execute('PRAGMA synchronous=NORMAL') # 약간의 위험을 감수하고 속도 증가
conn.execute('PRAGMA cache_size=-64000') # 64MB 캐시
conn.execute('PRAGMA temp_store=MEMORY') # 임시 테이블을 메모리에
conn.execute('PRAGMA mmap_size=30000000') # 메모리 맵 활성화
conn.close()
실제 성능 비교 (내 프로젝트 기준):
- WAL 모드 전: 동시 쓰기 시 대기
- WAL 모드 후: 여러 요청 동시 처리 가능
동시성 요구사항이 낮다면, WAL 모드 SQLite는 충분히 실무적이다.
SQLite의 숨겨진 강점 2: JSON 지원
SQLite는 3.9.0부터 JSON 함수를 지원한다. 이게 정말 강력하다:
-- 사용자 정보 저장 (JSON 컬럼)
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
metadata JSON
);
-- 삽입
INSERT INTO users VALUES (
1,
'Alice',
json('{"age": 28, "city": "Seoul", "tags": ["developer", "python"], "settings": {"theme": "dark"}}')
);
-- JSON에서 값 추출
SELECT
id,
name,
json_extract(metadata, '$.age') as age,
json_extract(metadata, '$.city') as city,
json_extract(metadata, '$.tags[0]') as first_tag
FROM users;
-- 결과: 1, Alice, 28, Seoul, developer
-- JSON 배열 반복
SELECT
id,
json_each.value as tag
FROM users, json_each(metadata, '$.tags')
WHERE id = 1;
-- 결과: 1, developer
-- 1, python
-- JSON 업데이트
UPDATE users
SET metadata = json_set(metadata, '$.settings.theme', json('"light"'))
WHERE id = 1;
-- JSON 조건 쿼리
SELECT * FROM users
WHERE json_extract(metadata, '$.age') > 25;
PostgreSQL의 JSONB와 비슷한 기능을 SQLite도 제공한다. 반정형 데이터를 저장하기에 완벽하다.
SQLite의 숨겨진 강점 3: FTS5 (Full Text Search)
블로그 검색 기능이 필요했다. 처음엔 좋은 라이브러리 사용을 생각했지만, SQLite의 FTS5는 매우 강력하다:
-- FTS5 테이블 생성
CREATE VIRTUAL TABLE articles_fts USING fts5(
id,
title,
content,
author
);
-- 데이터 삽입
INSERT INTO articles_fts VALUES (
1,
'Python 자동화 스크립트 실전',
'매주 금요일 반복하던 30분짜리 작업을 스크립트 하나로...',
'developer'
);
-- 간단한 검색
SELECT * FROM articles_fts
WHERE articles_fts MATCH 'python';
-- 복잡한 검색
SELECT * FROM articles_fts
WHERE articles_fts MATCH 'python AND automation'
ORDER BY rank;
-- 공백 처리
SELECT * FROM articles_fts
WHERE articles_fts MATCH '"python automation"' -- 정확히 이 구문
ORDER BY rank;
-- 다른 테이블과 조인
CREATE TABLE article_metadata (
id INTEGER,
views INTEGER,
likes INTEGER
);
SELECT
f.id,
f.title,
m.views,
m.likes
FROM articles_fts f
JOIN article_metadata m ON f.id = m.id
WHERE f MATCH 'python'
ORDER BY m.views DESC;
Elasticsearch 같은 전문 검색 도구를 도입하지 않고도, SQLite FTS5로 충분한 검색 기능을 구현할 수 있다.
SQLite의 숨겨진 강점 4: Window Functions
SQLite 3.25.0부터 window 함수를 지원한다. 복잡한 분석이 훨씬 간단해진다:
-- 매월 누적 매출 계산
CREATE TABLE sales (
id INTEGER PRIMARY KEY,
month TEXT,
amount REAL
);
INSERT INTO sales VALUES
(1, '2025-01', 1000),
(2, '2025-02', 1500),
(3, '2025-03', 1200),
(4, '2025-04', 2000);
-- Window function으로 누적합 계산
SELECT
month,
amount,
SUM(amount) OVER (ORDER BY month) as cumulative_total,
ROW_NUMBER() OVER (ORDER BY amount DESC) as rank,
LAG(amount) OVER (ORDER BY month) as previous_month,
(amount - LAG(amount) OVER (ORDER BY month)) as month_over_month_change
FROM sales;
-- 결과:
-- 2025-01, 1000, 1000, 4, NULL, NULL
-- 2025-02, 1500, 2500, 2, 1000, 500
-- 2025-03, 1200, 3700, 3, 1500, -300
-- 2025-04, 2000, 5700, 1, 1200, 800
-- 사용자별 구매 순서 계산
CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
user_id INTEGER,
purchase_date TEXT,
amount REAL
);
SELECT
user_id,
purchase_date,
amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY purchase_date) as purchase_number,
SUM(amount) OVER (PARTITION BY user_id ORDER BY purchase_date) as lifetime_value
FROM purchases
WHERE user_id = 123;
이런 기능들이 모두 SQLite에 내장되어 있다!
SQLite의 숨겨진 강점 5: 트리거와 뷰
데이터 무결성을 유지하는 데 트리거가 도움이 된다:
-- 감시(audit) 테이블
CREATE TABLE users_audit (
id INTEGER PRIMARY KEY,
user_id INTEGER,
action TEXT,
old_value TEXT,
new_value TEXT,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- UPDATE 트리거
CREATE TRIGGER users_update_trigger
AFTER UPDATE ON users
BEGIN
INSERT INTO users_audit (user_id, action, old_value, new_value)
VALUES (OLD.id, 'UPDATE', OLD.email, NEW.email);
END;
-- DELETE 트리거
CREATE TRIGGER users_delete_trigger
BEFORE DELETE ON users
BEGIN
INSERT INTO users_audit (user_id, action, old_value, new_value)
VALUES (OLD.id, 'DELETE', OLD.email, 'DELETED');
END;
-- 뷰로 자주 쓰는 쿼리를 저장
CREATE VIEW active_users AS
SELECT
id,
email,
name,
created_at,
(SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) as order_count
FROM users
WHERE deleted_at IS NULL
AND last_login > datetime('now', '-30 days');
-- 간단히 조회
SELECT * FROM active_users ORDER BY order_count DESC;
SQLite의 제약사항 — 언제는 부족할까?
하지만 SQLite에도 한계가 있다:
- 매우 높은 동시 쓰기: 여전히 PostgreSQL이 나음
- 분산 데이터베이스: SQLite는 단일 파일
- 대규모 데이터 (>100GB): 성능 저하
- 복잡한 권한 관리: 사용자/역할이 없음
내 경험상:
- 개인 프로젝트: SQLite 추천
- 스타트업 (초기): SQLite 추천
- 소/중형 SaaS: SQLite 또는 PostgreSQL
- 대규모 서비스: PostgreSQL 또는 분산 DB
마이그레이션 팁
PostgreSQL에서 SQLite로 옮길 때 주의할 점:
-- PostgreSQL 의존성 제거
-- 1. 자동 증가 (PostgreSQL: SERIAL)
-- PostgreSQL: id SERIAL PRIMARY KEY
-- SQLite: id INTEGER PRIMARY KEY AUTOINCREMENT
-- 2. UUID
-- PostgreSQL: id UUID DEFAULT gen_random_uuid()
-- SQLite: id TEXT DEFAULT (hex(randomblob(16)))
-- 3. 현재 시간
-- PostgreSQL: created_at TIMESTAMP DEFAULT NOW()
-- SQLite: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- 4. 배열
-- PostgreSQL: tags TEXT[] DEFAULT '{}'
-- SQLite: tags JSON DEFAULT '[]'
-- 5. 열거형
-- PostgreSQL: status status_enum
-- SQLite: status TEXT CHECK(status IN ('active', 'inactive'))
Python에서 SQLite 사용하기
Flask나 FastAPI에서 SQLite를 제대로 쓰는 방법:
from contextlib import contextmanager
import sqlite3
class Database:
def __init__(self, path: str):
self.path = path
self._init_db()
def _init_db(self):
conn = sqlite3.connect(self.path)
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA synchronous=NORMAL')
conn.close()
@contextmanager
def get_connection(self):
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def query(self, sql: str, params=None):
with self.get_connection() as conn:
cursor = conn.execute(sql, params or ())
return cursor.fetchall()
def execute(self, sql: str, params=None):
with self.get_connection() as conn:
conn.execute(sql, params or ())
# 사용
db = Database('app.db')
# 조회
rows = db.query('SELECT * FROM users WHERE age > ?', (20,))
for row in rows:
print(dict(row))
# 삽입
db.execute(
'INSERT INTO users (name, email) VALUES (?, ?)',
('Alice', 'alice@example.com')
)
결론 — SQLite는 진지하다
2024년, SQLite는 더 이상 "장난감 데이터베이스"가 아니다. 나는 다음을 확신한다:
- 개인 프로젝트: SQLite로 시작하세요
- PostgreSQL의 과금에 시달린다면: SQLite로 마이그레이션 검토하세요
- 동시 사용자가 적다면: SQLite로 충분합니다
여러 데이터베이스를 오래 다뤄봤지만, 요구사항에 맞는 도구를 선택하는 게 핵심이다. 과도한 엔지니어링은 운영 비용과 복잡성만 증가시킨다.
SQLite: 간단하고, 강력하고, 여전히 충분히 빠르다.