OPFS(Origin Private File System)는 브라우저가 출처(origin)별로 제공하는 비공개 파일 시스템이고, SQLite WASM은 SQLite를 웹어셈블리로 컴파일한 것이다. 둘을 결합하면 브라우저 안에서 실제 SQL 데이터베이스를 파일 기반으로 돌릴 수 있다.
기존 웹 저장소와의 차이는 결정적이다. localStorage는 문자열 키-값이고 동기 API라 메인 스레드를 막는다. IndexedDB는 비동기지만 질의 표현력이 빈약하다. SQLite는 조인·인덱스·트랜잭션을 그대로 쓸 수 있다.
로컬 우선(local-first) 앱이라는 말이 유행한 지 몇 년 됐지만, 실제로 만들어 보면 저장 계층에서 막힌다. 오프라인에서도 동작해야 하고, 수만 건을 다뤄야 하고, 정렬·필터·집계가 즉시 나와야 한다. IndexedDB로 이걸 만들면 결국 인덱스 관리와 커서 순회 코드를 직접 짜게 된다. SQL이 이미 잘 푸는 문제를 다시 푸는 셈이다.
저장 방식 비교
| 방식 | 질의 능력 | 용량 | 스레드 | 적합한 용도 |
|---|---|---|---|---|
| localStorage | 없음(키-값) | 수 MB | 동기, 메인 스레드 차단 | 설정값, 토글 상태 |
| IndexedDB | 키·인덱스 기반 | 수백 MB~ | 비동기 | 문서 저장, 캐시 |
| OPFS + SQLite | 전체 SQL | 디스크 여유에 비례 | 워커 권장 | 오프라인 앱, 대량 데이터 |
| 서버 API | 전체 SQL | 무제한 | 네트워크 의존 | 온라인 전용 앱 |
아키텍처 — 워커에 두는 이유
SQLite WASM을 메인 스레드에서 돌리면 질의가 도는 동안 UI가 멈춘다. 게다가 OPFS의 고성능 동기 접근 핸들은 워커 컨텍스트에서만 쓸 수 있다. 그래서 표준 구성은 이렇다.
// worker.js — 개념 코드 (라이브러리 API는 버전에 따라 다르다)
import { sqlite3Worker1Promiser } from '@sqlite.org/sqlite-wasm';
let db;
self.onmessage = async (e) => {
const { id, type, sql, params } = e.data;
try {
if (type === 'open') {
// OPFS 기반 영속 DB 파일
db = await openDatabase('file:app.db?vfs=opfs');
self.postMessage({ id, ok: true });
return;
}
const rows = await db.exec({ sql, bind: params, returnValue: 'resultRows' });
self.postMessage({ id, ok: true, rows });
} catch (err) {
self.postMessage({ id, ok: false, error: String(err) });
}
};
스키마 설계와 마이그레이션
브라우저 DB에서도 스키마 버전 관리는 필요하다. 사용자마다 앱 버전이 다를 수 있고, 오래 안 들어온 사용자는 몇 단계를 건너뛴 상태로 돌아온다. SQLite의 user_version PRAGMA를 쓰면 간단히 해결된다.
-- 마이그레이션 패턴
PRAGMA user_version; -- 현재 버전 확인 (초기값 0)
-- v1
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at);
PRAGMA user_version = 1;
-- v2 (나중에 추가)
ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 2;
마이그레이션은 트랜잭션으로 감싸고, 실패 시 어떻게 할지 정해 둬야 한다. 브라우저에서는 "롤백 후 서버에서 다시 받기"가 현실적인 복구 전략인 경우가 많다.
동기화 — 가장 어려운 부분
로컬 DB를 두는 순간 동기화 문제가 생긴다. 같은 사용자가 두 기기에서 같은 항목을 수정하면 어떻게 할 것인가. 완전한 해법은 없고, 요구사항에 맞는 타협만 있다.
| 전략 | 동작 | 장단점 |
|---|---|---|
| 마지막 쓰기 우선(LWW) | 타임스탬프가 늦은 쪽이 이김 | 구현 간단, 조용한 데이터 손실 가능 |
| 서버 권위 | 서버 상태가 정답, 로컬은 캐시 | 충돌 없음, 오프라인 편집 제약 |
| 연산 기반 병합(CRDT) | 편집을 연산으로 기록해 병합 | 충돌 해소 우수, 구현·저장 비용 큼 |
| 수동 해결 | 충돌 시 사용자에게 선택 요청 | 데이터 안전, UX 부담 |
updated_at과 deleted 플래그 — 삭제도 동기화 대상이다성능 팁
- 트랜잭션으로 묶기 — 수천 건 삽입을 개별 실행하면 느리다. 하나의 트랜잭션으로 감싸면 수십 배 차이가 난다.
- 준비된 문장(prepared statement) 재사용 — 반복 질의는 파싱 비용을 아낄 수 있다.
- 인덱스는 실제 질의 기준으로 — 브라우저에서도 인덱스 설계 원칙은 서버와 같다.
- WAL 모드 확인 — 환경에 따라 사용 가능 여부가 다르므로 실제로 켜지는지 확인한다.
- 결과 전송량 줄이기 — 워커 경계를 넘는 데이터가 병목이 되는 경우가 많다. 집계는 SQL에서 끝낸다.
저장 공간과 영속성
브라우저는 저장 공간이 부족하면 데이터를 지울 수 있다. 중요한 앱이라면 영속 저장 권한을 요청해야 한다.
// 영속 저장 요청 (사용자 참여도에 따라 자동 승인될 수 있음)
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('persistent storage:', granted);
}
// 남은 용량 추정
const { usage, quota } = await navigator.storage.estimate();
console.log(`사용 ${(usage/1048576).toFixed(1)}MB / 한도 ${(quota/1048576).toFixed(0)}MB`);
그럼에도 원칙은 변하지 않는다. 브라우저 저장소는 사라질 수 있다. 사용자가 사이트 데이터를 지우거나, 프라이빗 모드로 접속하거나, 저장 공간이 부족하면 없어진다. 로컬 DB는 속도와 오프라인을 위한 것이지 원본 보관소가 아니다.
자주 묻는 질문
OPFS와 IndexedDB 중 무엇을 써야 하나요?
키 기반 문서 저장이면 IndexedDB로 충분합니다. 조인·집계·정렬 같은 질의가 필요하거나 수만 건 이상을 다룬다면 OPFS + SQLite가 유리합니다. 직접 인덱스 관리 코드를 짜고 있다면 SQL로 옮길 시점입니다.
왜 워커에서 실행해야 하나요?
메인 스레드에서 실행하면 질의 중 UI가 멈추고, OPFS의 고성능 동기 접근 핸들은 워커 컨텍스트에서만 사용할 수 있기 때문입니다. 메인 스레드는 메시지로 질의를 요청하고 결과만 받는 구조가 표준입니다.
브라우저 저장 데이터가 사라질 수 있나요?
사라질 수 있습니다. 사용자가 사이트 데이터를 삭제하거나 저장 공간이 부족하면 제거될 수 있습니다. navigator.storage.persist()로 영속 저장을 요청할 수 있지만 보장은 아니므로, 중요한 데이터는 반드시 서버 동기화가 필요합니다.
대량 삽입이 너무 느립니다. 어떻게 하나요?
개별 실행 대신 하나의 트랜잭션으로 묶으세요. 수천 건 단위에서 수십 배 차이가 납니다. 준비된 문장을 재사용하고, 삽입 중에는 불필요한 인덱스를 잠시 제거했다가 나중에 만드는 방법도 효과적입니다.
기기 간 동기화는 어떻게 설계하나요?
모든 행에 갱신 시각과 삭제 플래그를 두고 변경분만 주고받는 것이 기본입니다. 충돌 해결은 마지막 쓰기 우선이 가장 간단하지만 조용한 손실이 생길 수 있으며, 협업 편집이 필요하면 CRDT 같은 병합 방식을 검토해야 합니다.
사파리·파이어폭스에서도 동작하나요?
주요 브라우저에서 OPFS 지원이 진행돼 왔지만 세부 API 동작과 성능은 차이가 있습니다. 특히 동기 접근 핸들과 영속 저장 정책이 다를 수 있으므로, 대상 브라우저에서 실제 데이터 규모로 직접 검증한 뒤 도입하세요.

댓글 0