브라우저 속 PostgreSQL: PGlite 실전 가이드
인터넷 연결 없이도 데이터를 온전히 다루는 앱을 만들고 싶었던 적이 있으신가요? 저도 처음엔 서버 API 호출 실패를 어떻게 처리할까 고민하다가, 결국 복잡한 캐싱 레이어를 끼워 넣고 "오프라인 지원"이라는 이름을 붙인 적이 있습니다. 그 방식의 문제는, 네트워크가 끊기면 UI가 조용히 망가진다는 걸 배포 후에야 알게 된다는 거였습니다.
PGlite를 쓰고 나서 달라진 건 딱 하나입니다. 데이터 레이어 자체가 클라이언트 안에 있으니, 네트워크 상태를 신경 쓰는 코드가 사라졌습니다.
PGlite는 ElectricSQL이 개발한 WebAssembly 기반 PostgreSQL 구현체입니다. gzip 압축 기준 약 3MB 크기의 TypeScript 라이브러리 하나로, 외부 서버나 별도 인프라 없이 브라우저·Node.js·Bun·Deno에서 완전한 PostgreSQL을 실행할 수 있습니다. JOIN, 트리거, CTE, 뷰, pgvector 같은 확장까지 — SQLite를 대체할 수 있는 Postgres 생태계를 로컬에서 그대로 쓸 수 있다는 점이 핵심입니다.
이 글에서는 PGlite의 내부 동작 원리부터 브라우저 스토리지 계층 선택, React와의 반응형 통합, 오프라인 퍼스트 아키텍처 설계까지 순서대로 살펴봅니다.
PGlite는 어떻게 브라우저에서 돌아가나
기존 "브라우저용 Postgres" 시도들은 대부분 Linux 가상 머신 전체를 에뮬레이션하는 방식을 썼습니다. 무겁고 느렸죠. PGlite는 다른 길을 택했습니다. PostgreSQL의 single-user mode — 원래 복구나 부트스트랩 목적으로 만들어진 모드 — 를 활용해 Emscripten으로 직접 WASM에 컴파일하고, JavaScript 환경과 I/O를 연결한 구조입니다.
프로세스 포크가 없는 단일 프로세스 구조라는 점이 핵심 제약이기도 하고, 동시에 브라우저 환경에 딱 맞는 이유이기도 합니다. 브라우저는 어차피 멀티 프로세스 DB 서버가 필요 없으니까요.
스토리지 계층: 무엇을 언제 선택할까
솔직히 처음 PGlite 문서를 보면서 "IndexedDB랑 OPFS 중 뭘 써야 하지?" 하고 한참 헤맸습니다. 가장 중요한 분기점은 실행 컨텍스트입니다.
| 스토리지 | 환경 | 특징 | 사용 시점 |
|---|---|---|---|
| 인메모리 | 모든 환경 | 새로고침 시 초기화 | 테스트, 임시 데모 |
| IndexedDB | 브라우저 메인 스레드 | Blob 단위 파일 저장, 영속성 있음 | 일반적인 브라우저 앱 |
| OPFS | Web Worker 전용 | 파일시스템 수준 성능 | 고성능 대용량 데이터 |
| 파일시스템 | Node/Bun/Deno | 직접 파일 읽기/쓰기 | 서버사이드 로컬 앱 |
OPFS가 Web Worker에서만 사용 가능하다는 점은 중요한 설계 제약입니다. 메인 스레드에서 opfs:// 프리픽스로 초기화하면 런타임 에러가 발생합니다. IndexedDB는 메인 스레드에서 바로 쓸 수 있지만, fsync 비용이 높아서 relaxedDurability 옵션을 켜두는 걸 권장합니다. 이 옵션은 WAL(Write-Ahead Log) 플러시 시점을 완화하는 것으로, 앱이 강제 종료되는 극단적 상황에서 최근 몇 건의 쓰기가 유실될 수 있습니다. 브라우저 탭을 정상적으로 닫는 일반적인 경우에는 영향이 없습니다.
브라우저 스토리지 용량 한도는 기기 여유 공간과 브라우저 정책에 따라 수십 MB에서 수 GB까지 가변적입니다. Chrome은 여유 디스크의 최대 60%, Firefox는 약 10%를 허용합니다. 고정 숫자로 외우기보다는 SELECT pg_database_size(current_database())로 앱 내에서 주기적으로 확인하는 편이 안전합니다.
Live Query: 반응형 데이터가 동작하는 방식
PGlite의 진짜 매력 중 하나는 Live Query API입니다. DB에 쓰기가 발생하는 순간 구독 중인 쿼리가 자동으로 재실행되어 UI를 업데이트합니다. PGlite는 내부적으로 테이블 레벨 변경 알림 메커니즘을 사용합니다. 쿼리가 참조하는 테이블에 INSERT·UPDATE·DELETE가 발생하면 해당 구독에 알림을 보내는 방식입니다. 폴링이 아닌 이벤트 기반이라 불필요한 재실행이 없습니다.
React를 쓴다면 useLiveQuery 훅으로 바로 연결됩니다.
import { useLiveQuery } from '@electric-sql/pglite-react'
function TaskList() {
const tasks = useLiveQuery<{ id: number; title: string; done: boolean }>(
'SELECT * FROM tasks WHERE done = false ORDER BY created_at DESC'
)
return (
<ul>
{tasks?.rows.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)
}SWR 같은 폴링 방식과 다른 점은, tasks 테이블에 변경이 실제로 발생한 순간에만 리렌더링이 트리거된다는 겁니다. WebSocket도 없고, 타이머도 없고, 그냥 로컬 이벤트입니다.
장단점과 도입 판단
장점 요약
| 항목 | 설명 |
|---|---|
| 완전한 PostgreSQL 호환 | JOIN, 트리거, CTE, 뷰, 제약조건 — 표준 Postgres 문법 전체 사용 가능 |
| 확장 지원 | pgvector, PostGIS, pg_trgm 등 SQLite에서는 불가능한 확장 생태계 |
| 반응형 API | Live Query로 DB 변경을 구독, 별도 상태 관리 없이 UI 자동 동기화 |
| ORM 재사용 | Drizzle ORM, Kysely 등 기존 Postgres ORM을 코드 변경 없이 활용 |
| 멀티 환경 지원 | 브라우저, Node.js, Bun, Deno에서 동일 API |
| 경량 번들 | gzip 기준 3MB, 서버 인프라 제로 |
한계와 주의사항
| 항목 | 설명 |
|---|---|
| 단일 연결 | 동시에 하나의 연결만 허용 — 멀티탭은 Worker 패턴으로 해결 |
| 스토리지 용량 | 기기 여유 공간·브라우저 정책에 따라 가변 — 대용량 데이터에 주의 필요 |
| OPFS는 Worker 전용 | 고성능 파일 I/O를 원하면 반드시 Web Worker 구조로 설계 필요 |
| IndexedDB 쓰기 지연 | fsync 비용이 높음 — relaxedDurability: true 사용 시 유실 가능성 인지 필요 |
| 브라우저 호환성 | WebAssembly + OPFS/IndexedDB 지원 최신 브라우저 필수 |
| 동기화는 별도 구현 | 서버 동기화는 자동이 아님 — ElectricSQL 또는 직접 구현 필요 |
| 단일 사용자 구조 | 동일 기기 내 여러 사용자 계정 분리 불가 |
PGlite가 맞는 상황인지 판단하기
실전 적용
설치와 첫 연결
pnpm add @electric-sql/pglite인메모리로 시작해서 구조를 잡고, 이후 영속성 레이어로 전환하는 방식이 좋습니다.
import { PGlite } from '@electric-sql/pglite'
// 인메모리 (테스트용 — 새로고침 시 초기화)
const db = new PGlite()
// IndexedDB 영속성 (브라우저 앱)
const db = new PGlite('idb://my-app-db', {
relaxedDurability: true, // fsync 완화 — 강제 종료 시 최근 쓰기 유실 가능
})
// OPFS (반드시 Web Worker 컨텍스트 안에서만)
const db = new PGlite('opfs://my-app-db')
// DDL은 exec() — 세미콜론으로 구분된 여러 문장 실행 가능
await db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`)
try {
// 파라미터 바인딩은 query() — $1 형태로 SQL 인젝션 방지 및 플랜 재사용
const result = await db.query<{ id: number; title: string }>(
'SELECT id, title FROM tasks WHERE done = $1',
[false]
)
console.log(result.rows)
} catch (err) {
// IndexedDB는 스토리지 쿼터 초과 등으로 실패할 수 있습니다
console.error('쿼리 실패:', err)
}Drizzle ORM 연결과 브라우저 마이그레이션
타입 안전성과 마이그레이션 관리를 위해 Drizzle ORM을 쓰는 조합이 현재 커뮤니티에서 가장 많이 쓰입니다. 서버 Postgres용으로 이미 Drizzle 스키마를 작성해뒀다면 클라이언트 PGlite에서도 그대로 재사용할 수 있습니다.
pnpm add drizzle-orm @electric-sql/pglite
pnpm add -D drizzle-kit// schema.ts
import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core'
export const tasks = pgTable('tasks', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
done: boolean('done').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
})// db.ts
import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import { migrate } from 'drizzle-orm/pglite/migrator'
import * as schema from './schema'
const client = new PGlite('idb://my-app-db', {
relaxedDurability: true,
})
export const db = drizzle(client, { schema })
// 브라우저 마이그레이션: 서버와 달리 drizzle-kit push 대신 런타임에 직접 실행합니다.
// migrations 폴더를 번들에 포함시키고 앱 초기화 시 한 번 호출합니다.
export async function initDb() {
try {
await migrate(db, { migrationsFolder: './drizzle' })
} catch (err) {
console.error('마이그레이션 실패:', err)
throw err
}
}브라우저 마이그레이션의 핵심은, 서버처럼 drizzle-kit migrate CLI를 실행하는 게 아니라 앱 시작 시 migrate() 함수를 인라인으로 호출한다는 점입니다. 마이그레이션 파일들은 번들러가 정적 자산으로 포함해야 하며, PGlite는 내부 __drizzle_migrations 테이블로 어디까지 실행됐는지 추적합니다. 새 스키마를 배포하면 다음 앱 로드 시 자동으로 적용됩니다.
// 사용 예시
import { db, initDb } from './db'
import { tasks } from './schema'
import { eq } from 'drizzle-orm'
await initDb() // 앱 진입점에서 한 번 호출
await db.insert(tasks).values({ title: '브라우저 DB 테스트' })
const pendingTasks = await db
.select()
.from(tasks)
.where(eq(tasks.done, false))
.orderBy(tasks.createdAt)
await db.update(tasks).set({ done: true }).where(eq(tasks.id, 1))멀티탭 환경 대응
PGlite는 기본적으로 단일 연결만 허용합니다. 탭 두 개에서 같은 DB를 열면 충돌이 납니다. @electric-sql/pglite/worker 패키지의 Worker 패턴으로 해결할 수 있습니다. 실제 원리는 SharedWorker 또는 BroadcastChannel을 통해 여러 탭이 단일 Worker 인스턴스와 메시지를 주고받는 구조입니다. 옵션 하나가 멀티탭 공유를 마법처럼 처리하는 게 아니라, Worker가 단일 DB 인스턴스를 소유하고 각 탭의 요청을 직렬화해서 처리합니다.
pnpm add @electric-sql/pglite-worker// worker.ts (별도 파일로 분리, 번들러가 별도 청크로 처리)
import { PGlite } from '@electric-sql/pglite'
import { worker } from '@electric-sql/pglite/worker'
// worker() 함수로 초기화 — PGliteWorker.init()은 구버전 API입니다.
worker({
async init() {
return new PGlite('idb://my-app-db', { relaxedDurability: true })
},
})// main.ts
import { PGliteWorker } from '@electric-sql/pglite/worker'
let db: PGliteWorker
export async function getDb() {
if (!db) {
try {
db = await PGliteWorker.create(
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
)
} catch (err) {
console.error('Worker DB 초기화 실패:', err)
throw err
}
}
return db
}
// 이후 db 사용법은 일반 PGlite와 동일
const db = await getDb()
await db.query('SELECT * FROM tasks')ElectricSQL과 오프라인-온라인 동기화
진짜 오프라인 퍼스트 앱은 "로컬에 저장"이 끝이 아닙니다. 네트워크가 돌아왔을 때 서버 Postgres와 어떻게 동기화할지가 핵심입니다.
ElectricSQL은 서버 측에서 PostgreSQL 논리 복제를 구독하고, 그 변경 사항을 HTTP 기반 Shape 프로토콜로 클라이언트에 스트리밍합니다. 클라이언트가 Postgres 논리 복제에 직접 연결하는 방식이 아니라, Electric Sync 서버가 중간에서 변환해서 HTTP로 내려주는 구조입니다.
pnpm add @electric-sql/pglite-syncimport { PGlite } from '@electric-sql/pglite'
import { electricSync } from '@electric-sql/pglite-sync'
const db = new PGlite('idb://my-app-db', {
relaxedDurability: true,
extensions: { electric: electricSync() },
})
try {
await db.electric.syncShapeToTable({
shape: {
url: 'https://your-electric-server.com/v1/shape',
params: { table: 'tasks' },
},
table: 'tasks',
primaryKey: ['id'],
})
} catch (err) {
// 네트워크 오류는 Electric이 내부적으로 재시도하지만,
// 초기 연결 자체가 실패하는 경우는 앱 레벨에서 처리해야 합니다.
console.error('동기화 설정 실패:', err)
}이후 tasks 테이블에 대한 읽기/쓰기는 로컬 PGlite에 즉시 반영되고, 네트워크가 연결된 순간 백그라운드에서 서버와 동기화됩니다. 사용자 입장에서는 네트워크 상태와 무관하게 앱이 항상 반응합니다.
보안: 민감 데이터 저장 시 주의할 점
IndexedDB는 OS 수준 보호만 제공합니다. 디바이스에 물리적으로 접근하면 데이터를 읽을 수 있습니다. 개인정보나 인증 토큰을 저장해야 한다면 Web Crypto API로 클라이언트 사이드에서 먼저 암호화한 뒤 저장하는 패턴을 사용합니다.
// AES-GCM으로 암호화 후 IndexedDB 저장하는 패턴 (의사코드 수준)
async function encryptAndStore(db: PGlite, plaintext: string, key: CryptoKey) {
const iv = crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
)
// 암호화된 바이너리를 base64로 직렬화해서 저장
const stored = btoa(String.fromCharCode(...new Uint8Array(ciphertext)))
await db.query(
'INSERT INTO secrets (iv, ciphertext) VALUES ($1, $2)',
[btoa(String.fromCharCode(...iv)), stored]
)
}
// 키는 IndexedDB가 아닌 Web Crypto Key Storage 또는 세션 메모리에 보관
// 기기에 평문 키를 저장하면 암호화 의미가 없습니다.암호화 키 자체는 IndexedDB에 평문으로 넣으면 의미가 없습니다. 키는 패스워드 기반 KDF(PBKDF2 등)로 사용자 입력에서 파생하거나, 서버 인증 후 세션 메모리에만 유지하는 방식을 씁니다.
심화: pgvector로 브라우저 안에서 벡터 검색
서버 API 없이 클라이언트 사이드에서 임베딩 기반 시맨틱 검색을 돌릴 수 있습니다. @xenova/transformers(또는 최신 @huggingface/transformers) 같은 라이브러리로 브라우저에서 직접 임베딩을 생성하고, 그 벡터를 PGlite pgvector에 저장하는 조합입니다.
import { PGlite } from '@electric-sql/pglite'
import { vector } from '@electric-sql/pglite/vector'
const db = new PGlite('idb://ai-app-db', {
extensions: { vector },
})
await db.exec(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(384)
);
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops);
`)
// @xenova/transformers로 클라이언트에서 임베딩 생성
// import { pipeline } from '@xenova/transformers'
// const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
// const output = await embedder('검색할 텍스트', { pooling: 'mean', normalize: true })
// const embedding = Array.from(output.data) // 384차원 float 배열
// 임베딩이 없을 때 테스트용 더미 벡터: Array.from({ length: 384 }, (_, i) => i * 0.001)
const dummyEmbedding = Array.from({ length: 384 }, (_, i) => i * 0.001)
try {
await db.query(
'INSERT INTO documents (content, embedding) VALUES ($1, $2::vector)',
['오프라인 퍼스트 앱 개발', JSON.stringify(dummyEmbedding)]
)
// 코사인 유사도 기반 검색
const results = await db.query<{ content: string; similarity: number }>(
`SELECT content, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5`,
[JSON.stringify(dummyEmbedding)]
)
console.log(results.rows)
} catch (err) {
console.error('벡터 검색 실패:', err)
}실무에서 자주 하는 실수
1. 메인 스레드에서 OPFS 시도
// ❌ 메인 스레드에서 실행하면 런타임 에러
const db = new PGlite('opfs://my-db')
// ✅ Web Worker 파일 안에서만 동작
// worker.ts 안에서:
const db = new PGlite('opfs://my-db')2. relaxedDurability 트레이드오프를 모르고 쓰는 경우
// ❌ fsync를 매 쓰기마다 기다려 성능이 느림
const db = new PGlite('idb://my-db')
// ✅ WAL 플러시 완화로 성능 개선 — 단, 앱 강제 종료 시 최근 쓰기 유실 가능
const db = new PGlite('idb://my-db', {
relaxedDurability: true,
})
// 금융 트랜잭션처럼 유실이 절대 허용 안 되는 데이터라면 false를 유지하세요.3. 브라우저 마이그레이션 순서 문제
// ❌ 마이그레이션 전에 쿼리 실행
const db = drizzle(client, { schema })
await db.select().from(tasks) // 테이블이 없을 수 있음
// ✅ 앱 진입점에서 마이그레이션을 먼저
await initDb() // migrate() 호출 포함
const results = await db.select().from(tasks)4. 멀티탭에서 PGlite 직접 사용
// ❌ 두 번째 탭에서 같은 IndexedDB를 열면 잠금 충돌
const db = new PGlite('idb://my-db') // 탭마다 독립 인스턴스 — 충돌
// ✅ Worker를 통해 단일 인스턴스를 모든 탭이 공유
const db = await PGliteWorker.create(new Worker(...))빠른 시작 3단계
1단계: pnpm add @electric-sql/pglite 설치 후 인메모리 모드로 SQL 쿼리 실행 확인
2단계: 'idb://앱이름'으로 IndexedDB 영속성 연결 후 새로고침해도 데이터가 남는지 확인
3단계: @electric-sql/pglite-react의 useLiveQuery로 DB 변경이 UI에 즉시 반영되는 반응형 패턴 구현
PGlite는 "브라우저에서도 PostgreSQL을 쓰고 싶다"는 니즈에 대한 현실적인 답입니다. 복잡한 캐싱 레이어나 로컬 스토리지 조합으로 해결하려 했던 오프라인 시나리오를 훨씬 깔끔하게 다룰 수 있습니다. 다만 단일 연결 제한과 브라우저 스토리지 용량은 설계 단계에서 반드시 고려해야 합니다. 그 경계를 알고 쓰는 것이 중요합니다.