Electron에서 SQLite 쓰기 — better-sqlite3 vs sql.js 비교

게시일: 2025년 7월 25일 · 13분 읽기

LocalStorage의 5MB 제한에 걸렸을 때

우리 Electron 앱은 로컬 데이터 저장소로 LocalStorage를 썼다. 처음엔 충분했지만, 사용자가 늘면서 5MB 제한에 자꾸 걸렸다. 더 이상 데이터를 저장할 수 없다는 에러가 나기 시작했다.

SQLite로 옮겼다. 이제 GB 단위의 데이터를 저장할 수 있다.

better-sqlite3 (Native) vs 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 단위의 데이터를 효율적으로 다루고 있다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.