Electron에서 SQLite 쓰기 — better-sqlite3 vs sql.js 비교
LocalStorage의 5MB 제한에 걸렸을 때
우리 Electron 앱은 로컬 데이터 저장소로 LocalStorage를 썼다. 처음엔 충분했지만, 사용자가 늘면서 5MB 제한에 자꾸 걸렸다. 더 이상 데이터를 저장할 수 없다는 에러가 나기 시작했다.
SQLite로 옮겼다. 이제 GB 단위의 데이터를 저장할 수 있다.
better-sqlite3 (Native) vs sql.js (WASM)
두 가지 선택지가 있다:
- better-sqlite3: Native 모듈, 매우 빠름, Node.js 필요
- sql.js: WASM, 느리지만 어디서나 작동
Electron은 Node.js를 포함하니까, better-sqlite3를 추천한다.
better-sqlite3 설정
<?xml version="1.0"?>
$ npm install better-sqlite3
$ npm install -D @types/better-sqlite3
// db.ts (메인 프로세스)
import Database from 'better-sqlite3'
import path from 'path'
import { app } from 'electron'
let db: Database.Database | null = null
export function initializeDatabase() {
const dbPath = path.join(app.getPath('userData'), 'app.db')
db = new Database(dbPath)
// 테이블 생성
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`)
return db
}
export function getDatabase() {
if (!db) {
throw new Error('Database not initialized')
}
return db
}
IPC를 통한 데이터베이스 접근
<?xml version="1.0"?>
// main.ts
import { ipcMain } from 'electron'
import { getDatabase } from './db'
// 쿼리 실행
ipcMain.handle('db:query', async (event, sql: string, params: any[]) => {
const db = getDatabase()
const stmt = db.prepare(sql)
return stmt.all(...params)
})
// 단일 행 조회
ipcMain.handle('db:get', async (event, sql: string, params: any[]) => {
const db = getDatabase()
const stmt = db.prepare(sql)
return stmt.get(...params)
})
// 삽입/수정
ipcMain.handle('db:run', async (event, sql: string, params: any[]) => {
const db = getDatabase()
const stmt = db.prepare(sql)
const result = stmt.run(...params)
return { lastID: result.lastInsertRowid, changes: result.changes }
})
// 트랜잭션
ipcMain.handle('db:transaction', async (event, callback) => {
const db = getDatabase()
const transaction = db.transaction(() => {
// callback 실행
})
return transaction()
})
렌더러 프로세스에서 사용
<?xml version="1.0"?>
// preload.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('database', {
query: (sql: string, params: any[] = []) =>
ipcRenderer.invoke('db:query', sql, params),
get: (sql: string, params: any[] = []) =>
ipcRenderer.invoke('db:get', sql, params),
run: (sql: string, params: any[] = []) =>
ipcRenderer.invoke('db:run', sql, params),
})
// React 컴포넌트에서
async function loadUsers() {
const users = await window.database.query(
'SELECT * FROM users WHERE created_at > ?',
['2025-01-01']
)
return users
}
async function createUser(name: string, email: string) {
const result = await window.database.run(
'INSERT INTO users (name, email) VALUES (?, ?)',
[name, email]
)
return result.lastID
}
성능 최적화**
<?xml version="1.0"?>
// db.ts
import Database from 'better-sqlite3'
export function initializeDatabase() {
const db = new Database(dbPath)
// 성능 최적화
db.pragma('journal_mode = WAL') // Write-Ahead Logging
db.pragma('synchronous = NORMAL') // 빠른 쓰기
db.pragma('cache_size = 10000') // 캐시 크기
db.pragma('foreign_keys = ON') // 외래키 제약
// 인덱스 생성 (자주 조회하는 열)
db.exec(`
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id)
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)
`)
return db
}
백업 및 마이그레이션
<?xml version="1.0"?>
// 백업
export function backupDatabase() {
const db = getDatabase()
const backupPath = path.join(
app.getPath('userData'),
`backup-${Date.now()}.db`
)
const backup = new Database(backupPath)
db.backup(backup)
backup.close()
return backupPath
}
// LocalStorage에서 마이그레이션
export function migrateFromLocalStorage() {
const db = getDatabase()
const localStorage = require('electron-store')
const data = localStorage.store
const insertUser = db.prepare(
'INSERT INTO users (name, email) VALUES (?, ?)'
)
for (const key in data) {
const user = data[key]
insertUser.run(user.name, user.email)
}
}
트랜잭션 예제
<?xml version="1.0"?>
// 복잡한 작업은 트랜잭션으로
ipcMain.handle('db:create-post', async (event, { userId, title, content }) => {
const db = getDatabase()
const transaction = db.transaction(() => {
const postId = db.prepare(
'INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)'
).run(userId, title, content).lastInsertRowid
// 사용자의 마지막 게시 시간 업데이트
db.prepare(
'UPDATE users SET last_post_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(userId)
return postId
})
// 실행 (모두 성공하거나 모두 실패)
return transaction()
})
SQL.js로 대체 (필요시)**
<?xml version="1.0"?>
// WASM 필요한 경우
import initSqlJs from 'sql.js'
let db: any = null
export async function initializeSqlJs() {
const SQL = await initSqlJs()
db = new SQL.Database()
db.run(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)
`)
return db
}
export function querySqlJs(sql: string, params: any[] = []) {
const stmt = db.prepare(sql)
stmt.bind(params)
const result = []
while (stmt.step()) {
result.push(stmt.getAsObject())
}
stmt.free()
return result
}
// 저장 (필요시)
export function saveSqlJsDb(filePath: string) {
const data = db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(filePath, buffer)
}
마무리
better-sqlite3는 Electron 앱의 로컬 데이터 저장에 최적화되어 있다. LocalStorage의 5MB 제한을 벗어날 수 있고, SQL로 복잡한 쿼리도 가능하다.
설정은 간단하고, IPC를 통해 안전하게 렌더러 프로세스에서 접근할 수 있다. 지금 우리 앱은 GB 단위의 데이터를 효율적으로 다루고 있다.