PySide6 실전 강좌 #2 SQLite 데이터 층 — 리포지토리 패턴과 마이그레이션
1편에서 뼈대를 세웠으니, 이번에는 앱의 기억을 만듭니다. 습관 목록과 매일의 체크 기록을 저장하는 데이터 층입니다. 이 층의 설계 목표는 하나입니다. SQL이 이 폴더 밖으로 새어 나가지 않게 할 것. UI 코드 어디에서도 SQL 문자열이 보이지 않는 상태를 만들면, 3편부터의 화면 작업이 놀랄 만큼 단순해집니다.
왜 SQLite인가, 그리고 파일은 어디에 두는가 #
로컬 데스크톱 앱의 저장소로 SQLite는 사실상 기본값입니다. 서버가 필요 없고, 파일 하나가 DB 전체이고, 파이썬에 sqlite3 모듈이 내장되어 있어 의존성도 없습니다. JSON 파일 저장과 비교하면, 데이터가 늘어도 전체를 다시 쓰지 않고, 동시 접근이 꼬였을 때의 파손 위험이 훨씬 낮고, 4편의 통계 계산을 SQL 집계로 밀 수 있다는 것이 결정적 차이입니다.
파일 위치는 실수하기 쉬운 지점입니다. 소스 폴더 옆에 두면 개발 중에는 편하지만, 배포된 앱은 설치 폴더에 쓰기 권한이 없는 경우가 많습니다. 답은 운영체제가 정해 둔 앱 데이터 폴더이고, Qt가 그 경로를 알고 있습니다.
# src/daily/data/paths.py
from pathlib import Path
from PySide6.QtCore import QStandardPaths
def db_path() -> Path:
base = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.AppDataLocation
)
folder = Path(base)
folder.mkdir(parents=True, exist_ok=True)
return folder / "daily.db"macOS에서는 ~/Library/Application Support/daily/, Windows에서는 AppData 아래, 리눅스에서는 ~/.local/share/ 계열로 알아서 갈립니다. 1편에서 setApplicationName을 미리 넣어 둔 것이 여기서 회수됩니다. Qt가 그 이름으로 폴더를 만들어 주기 때문입니다.
스키마: 테이블 두 개면 충분합니다 #
CREATE TABLE habits (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL, -- ISO 날짜 문자열
archived INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE checks (
habit_id INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
day TEXT NOT NULL, -- 'YYYY-MM-DD'
PRIMARY KEY (habit_id, day)
);설계 판단 세 가지를 짚어 둡니다. 첫째, 체크는 “습관 X를 날짜 D에 했다"는 사실 그 자체이므로 (habit_id, day) 복합 기본 키가 자연스럽고, 같은 날 중복 체크가 스키마 수준에서 차단됩니다. 둘째, 습관 삭제는 실제 DELETE 대신 archived 플래그를 씁니다. 기록 통계가 과거 습관에 의존하기 때문에, 사용자의 “삭제"는 대부분 “보관"이어야 합니다. 셋째, 날짜는 ISO 문자열(YYYY-MM-DD)로 저장합니다. SQLite에서 문자열 비교가 곧 날짜 비교가 되는 형식이라 다루기 쉽습니다.
리포지토리: SQL의 단 하나의 거처 #
리포지토리는 “저장소에 대해 할 수 있는 일"을 메서드로 노출하는 클래스입니다. 호출하는 쪽은 SQL의 존재를 모릅니다.
# src/daily/data/repository.py
import sqlite3
from datetime import date
from pathlib import Path
from daily.core.habits import Habit
_SCHEMA_VERSION = 1
class HabitRepository:
def __init__(self, path: Path) -> None:
self._conn = sqlite3.connect(path)
self._conn.execute("PRAGMA foreign_keys = ON")
self._migrate()
# --- 습관 ---
def add_habit(self, name: str) -> int:
cur = self._conn.execute(
"INSERT INTO habits (name, created_at) VALUES (?, ?)",
(name, date.today().isoformat()),
)
self._conn.commit()
return cur.lastrowid
def active_habits(self) -> list[Habit]:
rows = self._conn.execute(
"SELECT id, name FROM habits WHERE archived = 0 ORDER BY id"
).fetchall()
return [Habit(id=r[0], name=r[1]) for r in rows]
def archive_habit(self, habit_id: int) -> None:
self._conn.execute("UPDATE habits SET archived = 1 WHERE id = ?", (habit_id,))
self._conn.commit()
# --- 체크 ---
def set_checked(self, habit_id: int, day: date, checked: bool) -> None:
if checked:
self._conn.execute(
"INSERT OR IGNORE INTO checks (habit_id, day) VALUES (?, ?)",
(habit_id, day.isoformat()),
)
else:
self._conn.execute(
"DELETE FROM checks WHERE habit_id = ? AND day = ?",
(habit_id, day.isoformat()),
)
self._conn.commit()
def checked_days(self, habit_id: int) -> set[str]:
rows = self._conn.execute(
"SELECT day FROM checks WHERE habit_id = ?", (habit_id,)
).fetchall()
return {r[0] for r in rows}Habit은 core에 두는 순수 데이터 클래스입니다(@dataclass로 id와 name만). UI는 리포지토리의 메서드만 부르고, 리포지토리는 core의 타입만 반환합니다. 1편에서 그린 한 방향 의존이 코드로 성립하는 순간입니다.
파라미터 바인딩(?)만 쓰고 문자열 포매팅으로 SQL을 만들지 않는 것은 SQLAlchemy 시리즈에서 강조한 것과 같은 원칙입니다. 참고로 이 규모에서는 ORM 없이 sqlite3 직접 사용이 적정 기술입니다. 테이블 두 개에 SQLAlchemy를 들이는 것은 과합니다.
마이그레이션: user_version으로 스키마 버전 관리 #
배포된 앱의 데이터 층에는 웹 서버에 없는 의무가 하나 있습니다. 사용자 기기에 이미 존재하는 옛 버전의 DB 파일을 깨뜨리지 않고 업그레이드하는 것입니다. SQLite에는 이 용도로 쓰기 좋은 PRAGMA user_version(DB 파일에 저장되는 정수)이 있습니다.
def _migrate(self) -> None:
version = self._conn.execute("PRAGMA user_version").fetchone()[0]
if version < 1:
self._conn.executescript("""
CREATE TABLE habits (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE checks (
habit_id INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
day TEXT NOT NULL,
PRIMARY KEY (habit_id, day)
);
""")
# if version < 2: ... 다음 스키마 변경은 여기에 추가
self._conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
self._conn.commit()동작 원리는 단순합니다. 새 설치의 DB는 버전 0이므로 전체 스키마가 생성되고, 기존 사용자의 DB는 저장된 버전보다 큰 단계만 순서대로 적용됩니다. 규칙은 하나만 지키면 됩니다. 배포 후의 스키마 변경은 기존 단계를 고치지 말고 새 if version < N: 블록으로만 추가합니다. Alembic의 축소판인 셈이고, 로컬 앱에는 이 정도가 알맞습니다.
조립: 앱에 연결하기 #
# app.py의 main()에 추가
from daily.data.paths import db_path
from daily.data.repository import HabitRepository
repo = HabitRepository(db_path())
window = MainWindow(repo) # MainWindow가 repo를 받아 화면들에 나눠 줍니다리포지토리를 전역으로 두지 않고 생성자로 내려보내는 것(의존성 주입)은 7편 테스트의 복선입니다. 테스트에서는 임시 파일 경로로 만든 리포지토리를 주입하면 되기 때문입니다.
정리 #
- 로컬 앱의 저장소는 SQLite가 기본값이고, DB 파일은 QStandardPaths가 알려 주는 앱 데이터 폴더에 둡니다.
- 스키마는 habits와 checks 두 테이블입니다. 체크는 복합 기본 키로 중복을 차단하고, 삭제는 archived 플래그로 대신합니다.
- SQL은 리포지토리 한곳에만 존재합니다. UI는 메서드를 부르고 core 타입을 받을 뿐입니다.
- 스키마 버전은 PRAGMA user_version으로 관리하고, 변경은 새 버전 블록의 추가로만 합니다. 배포된 앱의 데이터는 사용자의 재산입니다.
- 리포지토리는 생성자 주입으로 내려보냅니다. 다음 편에서 이 데이터를 오늘 화면의 커스텀 모델에 연결합니다.