SQLite를 로컬 데스크톱 앱 DB로 쓰는 법
서버 없이 SQLite만으로 데스크톱 앱 데이터를 관리하는 실전 패턴.
왜 SQLite인가
로컬 데스크톱 앱에서 데이터를 저장할 때 선택지는 크게 세 가지다.
- 파일(JSON/YAML): 단순하지만 데이터가 커지면 느리다
- SQLite: 관계형 쿼리 가능, 단일 파일, 동시성 처리 내장
- 외부 DB(PostgreSQL 등): 과하다. 사용자에게 DB 설치를 요구할 수 없다
KeyBox는 시크릿을 수백~수천 개 저장할 수 있어야 했다. 검색, 정렬, 필터링이 필요했고, JSON 파일로는 한계가 있었다. SQLite가 정답이었다.
파일 위치
운영체제별로 앱 데이터 디렉토리가 다르다.
use tauri::api::path::app_data_dir;
fn db_path(app: &tauri::AppHandle) -> PathBuf {
let dir = app_data_dir(&app.config()).unwrap();
std::fs::create_dir_all(&dir).ok();
dir.join("keybox.db")
}
- Windows:
%APPDATA%/com.karnellabs.keybox/ - macOS:
~/Library/Application Support/com.karnellabs.keybox/ - Linux:
~/.local/share/com.karnellabs.keybox/
Tauri의 app_data_dir이 OS별 경로를 자동으로 처리한다.
스키마 설계
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
encrypted_value BLOB NOT NULL,
category TEXT DEFAULT 'general',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_secrets_category ON secrets(category);
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
핵심: encrypted_value는 TEXT가 아니라 BLOB이다. 암호화된 데이터는 바이너리이므로 BLOB으로 저장해야 손실이 없다.
마이그레이션
앱 버전이 올라가면 스키마도 변한다. user_version 프라그마를 활용한다.
fn migrate(conn: &Connection) -> Result<()> {
let version: i32 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version < 1 {
conn.execute_batch(include_str!("migrations/001_init.sql"))?;
conn.pragma_update(None, "user_version", 1)?;
}
if version < 2 {
conn.execute_batch(include_str!("migrations/002_add_tags.sql"))?;
conn.pragma_update(None, "user_version", 2)?;
}
Ok(())
}
앱 시작 시 migrate()를 호출하면 기존 사용자의 DB가 자동으로 최신 스키마로 업그레이드된다.
WAL 모드
읽기 성능을 높이려면 WAL(Write-Ahead Logging) 모드를 활성화한다.
conn.pragma_update(None, "journal_mode", "WAL")?;
WAL 모드에서는 읽기와 쓰기가 서로 블로킹하지 않는다. 데스크톱 앱에서 UI 스레드가 DB를 읽는 동안 백그라운드에서 쓰기가 가능해진다.
백업
SQLite의 백업은 파일 복사다. 하지만 쓰기 중에 복사하면 손상될 수 있으므로 SQLite의 Online Backup API를 사용한다.
fn backup_db(source: &Connection, dest_path: &str) -> Result<()> {
let mut dest = Connection::open(dest_path)?;
let backup = rusqlite::backup::Backup::new(source, &mut dest)?;
backup.run_to_completion(5, std::time::Duration::from_millis(250), None)?;
Ok(())
}
KeyBox에서는 설정 메뉴에서 수동 백업 버튼을 제공한다. 자동 백업은 과했다 — 사용자가 명시적으로 제어하는 게 시크릿 매니저 맥락에서 더 적절하다.
정리
SQLite는 로컬 앱의 기본 선택이다. 설치가 필요 없고, 단일 파일이고, SQL을 쓸 수 있다. 다만 암호화 데이터를 다룬다면 BLOB 타입과 마이그레이션 전략은 미리 잡아두자.