SQLite가 생각보다 강력한 이유 — 개인 프로젝트에서 PostgreSQL 대신

게시일: 2025년 10월 24일 · 14분 읽기

PostgreSQL을 설정하다가 SQLite로 돌아온 이유

2024년 초, 나는 사이드 프로젝트를 시작했다. 처음엔 당연히 PostgreSQL을 써야 한다고 생각했다. 개발하며 느낀 바로는, "진정한" 데이터베이스는 PostgreSQL이었으니까. 하지만 몇 가지 문제가 생겼다:

그리고 생각해보니 내 프로젝트는:

"이 정도면 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 모드 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로 옮길 때 주의할 점:

-- 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: 간단하고, 강력하고, 여전히 충분히 빠르다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.