본문 바로가기

Capacitor 앱에서 SQLite 사용하는 방법 (2026 가이드)

Capacitor 앱에서 SQLite 사용법을 알아보세요: 플러그인 선택, 데이터베이스 열기, 마이그레이션 실행, 트랜잭션, 암호화, 웹 지원을 다루고, 작동하는 code도 제공합니다.

Capacitor 로고, Capacitor 앱에서 SQLite 사용 가이드

Capacitor 앱에서 SQLite를 사용하려면 다음과 같은 네이티브 SQLite 플러그인을 설치하세요. @capgo/capacitor-fast-sql앱 시작 시 데이터베이스를 한 번만 열고, 버전 관리된 마이그레이션으로 스키마를 생성한 후, 매개변수화된 SQL로 읽기/쓰기를 수행합니다. 이 가이드는 iOS, Android 및 웹에서 Capacitor 8을 대상으로 트랜잭션, 암호화, 파일 위치 및 가장 자주 발생하는 오류를 포함한 전체 설정 과정을 안내합니다.

단순히 몇 가지 설정값만 저장한다면 SQLite는 과할 수 있습니다. 하지만 리스트, 레코드, 또는 필터링, 정렬, 동기화가 필요한 데이터를 저장한다면 SQLite가 정답입니다.

SQLite가 적합한 저장소일 때

Capacitor 앱에는 데이터를 저장할 여러 공간이 있습니다. 각 공간은 서로 다른 문제를 해결합니다:

스토리지 적합한 용도 제한 사항
localStorage / IndexedDB 휘발성 캐시 WebView 안에 저장되고 OS가 삭제할 수 있음, localStorage에서는 제대로 된 쿼리 불가
@capacitor/preferences 소량의 설정값 (테마, 언어 등) 문자열 값만 가능, 대용량 데이터용 아님, 암호화되지 않음
Keychain / Keystore 저장소 토큰, 암호화 키 작은 시크릿만
파일 (@capacitor/filesystem) 이미지, PDF, 내보내기 쿼리 불가
SQLite 레코드, 오프라인 데이터, 동기화 큐, 검색 스키마와 마이그레이션 관리

오프라인 우선 데이터, 수백 건 이상의 레코드, 필터 및 정렬, 테이블 간 관계, 원자적 다단계 쓰기, 또는 구조화 데이터의 암호화가 필요할 때 SQLite를 선택하세요.

2026년 Capacitor SQLite 플러그인 선택하기

Capacitor 8에서 네이티브 SQLite를 위한 현실적인 옵션은 세 가지입니다:

@capgo/capacitor-fast-sql @capacitor-community/sqlite @capgo/capacitor-data-storage-sqlite
목적 범용 SQL 데이터베이스 범용 SQL 데이터베이스 SQLite 기반 문자열 키-값 저장소
플랫폼 iOS, Android, Web iOS, Android, Web, Electron iOS, Android, Web, Electron
웹 엔진 공식 SQLite Wasm + OPFS jeep-sqlite (sql.js + IndexedDB) IndexedDB
데이터 전송 로컬 HTTP 채널, 브리지 우회 Capacitor 브리지 Capacitor 브리지
암호화 선택적 SQLCipher (iOS, Android) SQLCipher 항상 번들링 SQLCipher, 설정에 패스프레이즈
행 형식 컬럼 키를 가진 객체 컬럼 키를 가진 객체 문자열
ORM 지원 커스텀 드라이버 (Kysely, Drizzle) TypeORM 내장 드라이버 없음

이 가이드는 Fast SQL을 사용합니다. Capgo에서 유지 관리하며, Capacitor 주요 버전을 추적합니다(Capacitor 8용 플러그인 v8). SQLCipher는 선택적으로 사용할 수 있으며, 브리지를 통해 큰 결과 집합을 직렬화하는 것을 피합니다. 이미 커뮤니티 플러그인을 사용 중이라면, 우리의 비교 및 마이그레이션 가이드를 읽어보세요 먼저.

Fast SQL 설치

bun add @capgo/capacitor-fast-sql
bunx cap sync

Fast SQL은 다음에 바인딩된 작은 HTTP 서버를 시작합니다 localhost 그리고 연결별 bearer token을 사용해 SQL을 전송합니다. 두 플랫폼 모두 기본적으로 cleartext HTTP를 차단하므로, loopback에만 허용하세요.

iOS: 로컬 네트워킹 허용

다음에 추가하세요 ios/App/App/Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

NSAllowsLocalNetworking loopback 및 로컬 주소만 커버합니다. 실제 API 호출에 대한 App Transport Security를 완화하지 않습니다.

Android: localhost로 cleartext 허용

생성 android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">localhost</domain>
        <domain includeSubdomains="false">127.0.0.1</domain>
    </domain-config>
</network-security-config>

다음에서 참조하세요 android/app/src/main/AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

이미 네트워크 보안 설정(예: SSL pinning)이 있다면, 두 번째 파일을 만들지 말고 domain-config 블록을 추가하세요.

데이터베이스를 한 번만 열기

연결을 소유하는 단일 모듈을 만드세요. 같은 데이터베이스를 반복적으로 여는 것은 비효율적이며, 마이그레이션을 실행할 단일 위치가 필요합니다.

// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { migrate } from './migrate';

let dbPromise: Promise<SQLConnection> | null = null;

export function getDb(): Promise<SQLConnection> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const db = await FastSQL.connect({
        database: 'notes',
        walMode: true, // PRAGMA journal_mode = WAL
        performancePresets: true, // synchronous=NORMAL, busy_timeout, foreign_keys=ON
      });
      await migrate(db);
      return db;
    })().catch((error) => {
      dbPromise = null; // let the next call retry instead of reusing the failure
      throw error;
    });
  }
  return dbPromise;
}

walMode 및 performancePresets 플러그인 버전 8.0.49부터 사용할 수 있습니다. performancePresets 또한 활성화합니다 foreign_keys, SQLite는 기본적으로 비활성화되어 있으므로 ON DELETE CASCADE 실제로 작동합니다.

호출하세요 getDb() 데이터베이스가 필요한 모든 곳에서 사용할 수 있습니다. 첫 번째 호출 시 열리고 마이그레이션이 수행되며, 이후 호출은 동일한 promise를 재사용합니다. 열기 또는 마이그레이션이 실패하면 다음 호출 시 다시 시도합니다.

PRAGMA user_version을 통한 스키마 마이그레이션

Fast SQL은 마이그레이션 프레임워크가 아닌 원시 SQL을 제공합니다. 이는 SQLite 파일 헤더에 이미 버전 슬롯이 있으므로 문제가 되지 않습니다: PRAGMA user_version. 순서대로 정렬된 단계 배열을 유지하고 누락된 항목만 적용하세요.

// src/db/migrate.ts
import type { SQLConnection } from '@capgo/capacitor-fast-sql';

// Index 0 = version 1, index 1 = version 2, ...
// Never edit a step after it shipped. Add a new one.
const steps: string[][] = [
  [
    `CREATE TABLE IF NOT EXISTS notes (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       title TEXT NOT NULL,
       body TEXT,
       created_at INTEGER NOT NULL
     )`,
  ],
  [
    `ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`,
    `CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes (created_at)`,
  ],
];

export async function migrate(db: SQLConnection): Promise<void> {
  const rows = await db.query('PRAGMA user_version');
  const current = Number(rows[0]?.user_version ?? 0);

  for (let version = current; version < steps.length; version++) {
    await db.transaction(async (tx) => {
      for (const statement of steps[version]) {
        await tx.execute(statement);
      }
      // PRAGMA does not accept bound parameters, the value is a trusted integer
      await tx.execute(`PRAGMA user_version = ${version + 1}`);
    });
  }
}

이를 안전하게 유지하기 위한 규칙:

  • SQL 문 하나당 execute() call. 두 네이티브 구현 모두 문자열의 첫 번째 문장만 준비하므로 "CREATE ...; CREATE ..." 그중 하나를 조용히 실행합니다.
  • 각 버전은 트랜잭션으로 실행되므로, 중간에 크래시가 발생하면 데이터베이스는 이전 버전으로 유지되고 다음 시작 시 해당 단계가 다시 실행됩니다.
  • 추가형 변경(새 테이블, 새 nullable 컬럼)을 권장합니다. SQLite는 ALTER TABLE ... DROP COLUMN 및 RENAME COLUMN 최근 버전에서 이를 지원하지만, 구형 Android 릴리스의 시스템 SQLite는 버전이 낮을 수 있어 테이블 구조를 변경하는 가장 호환성 좋은 방법은 새 테이블 생성, 데이터 복사, 기존 테이블 삭제, 이름 변경입니다.

마이그레이션과 라이브 업데이트

JavaScript 변경 사항을 Capgo Live Update 사용하기로 배포하면, 마이그레이션 code도 번들과 함께 배포됩니다. 스키마 변경에 스토어 릴리스가 필요 없으므로 편리합니다. 하지만 롤백 시 더 새로운 스키마 위에 더 오래된 번들이 올라갈 수도 있습니다. 마이그레이션은 하위 호환성을 유지해야 하며(컬럼 추가는 가능하지만, 사용을 중단하는 같은 릴리스에서 컬럼을 이름 변경하거나 삭제하지 마세요) 그래야 오래된 번들도 계속 작동합니다.

데이터 읽기 및 쓰기

SQLConnection 항상 사용할 네 가지 메서드를 제공합니다:

메서드 반환값 용도
query(sql, params) SQLRow[] SELECT 및 PRAGMA 읽기
run(sql, params) { rowsAffected, insertId } INSERT, UPDATE, DELETE
execute(sql, params) { rows, rowsAffected, insertId } 모든 것, 전체 결과
executeBatch(ops) SQLResult[] 하나의 왕복으로 여러 문장 실행

항상 값은 ? 플레이스홀더로 바인딩하세요. 사용자 입력으로 문자열 연결을 통해 SQL을 구성하지 마세요.

// src/db/notes.ts
import { getDb } from './index';

export interface Note {
  id: number;
  title: string;
  body: string | null;
  created_at: number;
  pinned: number;
}

export async function addNote(title: string, body: string | null): Promise<number> {
  const db = await getDb();
  const { insertId } = await db.run(
    'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
    [title, body, Date.now()],
  );
  if (insertId == null) throw new Error('Insert did not return an id');
  return insertId;
}

export async function listNotes(search = ''): Promise<Note[]> {
  const db = await getDb();
  const rows = await db.query(
    'SELECT id, title, body, created_at, pinned FROM notes WHERE title LIKE ? ORDER BY pinned DESC, created_at DESC',
    [`%${search}%`],
  );
  return rows as unknown as Note[];
}

export async function setPinned(id: number, pinned: boolean): Promise<void> {
  const db = await getDb();
  await db.run('UPDATE notes SET pinned = ? WHERE id = ?', [pinned ? 1 : 0, id]);
}

export async function deleteNote(id: number): Promise<number> {
  const db = await getDb();
  const { rowsAffected } = await db.run('DELETE FROM notes WHERE id = ?', [id]);
  return rowsAffected;
}

행은 컬럼 이름을 키로 하는 일반 객체로 반환되므로 columns/values 매핑 단계가 없습니다. SQLite에는 boolean 타입이 없으므로 1 및 0 를 저장하고 데이터 레이어의 경계에서 setPinned 플러그인이 처리합니다.

트랜잭션 및 배치 쓰기

여러 단계의 쓰기 작업은 모두 트랜잭션으로 감싸세요. 데이터 일관성을 유지할 뿐 아니라, SQLite가 문장마다가 아니라 커밋마다 한 번씩 디스크에 동기화하므로 훨씬 빠릅니다.

import { getDb } from './index';

export async function importNotes(items: { title: string; body: string }[]) {
  const db = await getDb();
  const now = Date.now();

  await db.transaction(async (tx) => {
    await tx.executeBatch(
      items.map((item) => ({
        statement: 'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
        params: [item.title, item.body, now],
      })),
    );
  });
}

transaction() 콜백이 resolve되면 커밋하고, throw되면 롤백합니다. 수동 제어가 필요하면 beginTransaction(), commit() 및 rollback() 도 연결에 사용할 수 있습니다.

모든 호출은 데이터베이스당 단일 네이티브 연결을 공유합니다. 트랜잭션이 열려 있는 동안, 같은 데이터베이스에 쓰는 다른 code도 그 트랜잭션 안에서 실행됩니다. 트랜잭션은 짧게 유지하고, 내부에서 네트워크 요청을 await하지 마세요.

바이너리 데이터 저장

를 전달하면 Uint8Array 을(를) 반환받습니다. Uint8Array 플러그인은 전송을 위해 base64 인코딩하고, 실제 BLOB로 저장합니다.

const db = await getDb();
await db.execute('CREATE TABLE IF NOT EXISTS thumbnails (note_id INTEGER PRIMARY KEY, data BLOB)');
await db.run('INSERT OR REPLACE INTO thumbnails (note_id, data) VALUES (?, ?)', [noteId, bytes]);

const [row] = await db.query('SELECT data FROM thumbnails WHERE note_id = ?', [noteId]);
const image = row?.data as Uint8Array | undefined;

대용량 미디어(사진, 동영상)는 파일로 유지하고 SQLite에는 경로만 저장하세요. BLOB은 썸네일, 작은 첨부 파일, 임베딩에는 적합합니다.

데이터베이스 암호화

Fast SQL은 iOS와 Android에서 SQLCipher를 지원합니다. 선택 사항이므로, 필요하지 않은 앱에는 포함되지 않습니다:

const db = await FastSQL.connect({
  database: 'vault',
  encrypted: true,
  encryptionKey: keyFromSecureStorage,
});

Android에서는 net.zetetic:sqlcipher-android 앱의 build.gradle에 추가합니다. iOS에서는 pod를 CapgoCapacitorFastSql/SQLCipher subspec으로 전환합니다. 키 생성, Keychain 또는 Keystore에 저장, 기존 평문 데이터베이스 암호화, 키 로테이션은 Capacitor에서 SQLite 데이터베이스 암호화하는 방법에서 단계별로 설명합니다. 웹에서는 암호화를 사용할 수 없습니다.

웹 지원

웹에서는 Fast SQL이 공식 @sqlite.org/sqlite-wasm 빌드를 사용하고 파일을 Origin Private File System(OPFS)에 저장합니다. 메모리에 복사해 두었다가 IndexedDB에 쓰는 것이 아니라 실제 SQLite 파일입니다.

OPFS는 cross-origin isolation이 필요하므로, 개발 서버와 프로덕션 호스트 모두 두 개의 헤더를 전송해야 합니다:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Vite 사용 시:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

헤더가 없으면 플러그인이 경고를 남기고 비영구 데이터베이스로 폴백합니다. 리로드하기 전까지는 모든 것이 정상 동작합니다. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) 첫 실행 전에 기본값을 변경할 수 있게 합니다 connect(), 네이티브에서는 아무 동작도 하지 않습니다.

require-corp CORP 또는 CORS 헤더를 보내지 않는 크로스 오리진 이미지와 스크립트도 차단하므로, 활성화하면 서드파티 임베드를 테스트하세요.

데이터베이스 파일 저장 위치

플랫폼 위치 참고 사항
iOS Documents/<name>.db 기본적으로 iCloud 및 기기 백업에 포함됩니다
Android files/<name>.db 앱 내부 저장소에 Android 자동 백업 규칙 적용
웹 페이지 오리진용 OPFS 사용자가 사이트 데이터를 지우면 삭제됨

실용적인 결과가 두 가지 있습니다. 첫째, 앱을 삭제하면 모바일 플랫폼 모두에서 파일이 삭제됩니다. 둘째, 백업으로 새 기기에 데이터베이스 파일을 복원할 수 있습니다. 보통 이것이 원하는 동작이지만, 백업과 함께 이동하지 않는 키로 데이터베이스가 암호화되어 있으면 복원된 파일을 열 수 없습니다. 암호화 가이드에서 이를 처리하는 방법을 다룹니다.

성능 체크리스트

  • 켜기 walMode. 리더가 더 이상 라이터를 차단하지 않습니다.
  • 트랜잭션으로 배치 쓰기를 수행합니다. executeBatch. 이는 가져오기 및 동기화에서 가장 큰 성능 개선입니다.
  • 필터링하거나 정렬하는 컬럼에 인덱스를 추가한 후, 다음으로 확인하세요. EXPLAIN QUERY PLAN.
  • 필요한 컬럼만 선택하세요. 큰 TEXT 및 BLOB 컬럼은 HTTP 채널을 통해 전송해도 전송 시간을 증가시킵니다.
  • 큰 WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 값 대신 OFFSET values.
  • 대량의 데이터를 삭제한 후 파일 크기를 줄이기 위해 VACUUM 를(을) 주기적으로 실행하세요. 전체 데이터베이스를 다시 작성하므로 유휴 시간에 실행하세요.

문제 해결

SQL execution failed iOS에서는 연결 직후에 실행하세요. 로컬 HTTP 요청이 차단되었습니다. NSAllowsLocalNetworking 이(가) 올바른 위치에 있는지 확인하세요. Info.plist 네이티브 앱을 다시 빌드해야 하며, 라이브 리로드만으로는 충분하지 않습니다.

CLEARTEXT communication to localhost not permitted Android에서. 네트워크 보안 설정이 없거나 매니페스트에서 참조되지 않았습니다.

첫 번째 문만 실행되었습니다. 여러 문장을 하나의 문자열로 전달했습니다. 분리하거나 executeBatch.

no such table 업데이트 후. 마이그레이션이 실행되지 않았거나 다른 데이터베이스 이름에 대해 실행되었습니다. 로그를 PRAGMA user_version 시작할 때 확인하고 steps 배열과 비교하세요.

boolean 또는 계산된 값에 대한 필터는 Android에서 절대 일치하지 않습니다. 읽기 쿼리의 경우 Android 구현체는 파라미터를 text로 바인딩합니다. INTEGER affinity가 있는 컬럼과의 비교는 SQLite가 text를 변환하므로 여전히 동작하지만, 표현식과의 비교는 (예를 들어 json_extract(...) = ? 또는 count(*) > ?) text와 숫자를 비교합니다. 대신 1/0 를 전달하고 true/false 대신 CAST(? AS INTEGER) 를 표현식과 비교할 때 사용하세요.

INSERT ... RETURNING Android에서는 행을 반환하지 않습니다. 현재 플러그인 버전에서 Android는 다음으로 시작하는 문에 대해서만 행을 반환합니다: SELECT, PRAGMA 또는 EXPLAIN. 사용하세요 insertId 과 run() 그 후 해당 행을 선택합니다. ~로 시작하는 쿼리에도 동일하게 적용됩니다. WITH: ~로 시작하도록 감싸서 SELECT, 또는 Android에서 미리 테스트하세요.

브라우저에서 새로고침 시 데이터가 사라집니다. OPFS가 사용 불가능한 경우, 대부분 배포된 호스트에 COOP/COEP 헤더가 누락되어 있기 때문입니다.

Encryption is not available in this build iOS에서. 전달한 encrypted: true SQLCipher subspec으로 전환하지 않았습니다.

더 깊이 알아보기

소규모 앱에는 Raw SQL이 충분합니다. 테이블이 몇 개 이상이라면 타입이 있는 레이어가 도움이 됩니다:

플러그인 레퍼런스는 여기 있습니다 Fast SQL 문서 및 플러그인 페이지. 빠른 SQL은 추가하거나 설정을 변경할 때 네이티브 리빌드가 필요합니다. 이를 위해 Mac을 유지하고 싶지 않다면, Capgo 빌드 클라우드에서 서명된 iOS 및 Android 바이너리를 생성할 수 있습니다. 2026년 4월부터 App Store 업로드는 Xcode 26 이상으로 빌드해야 하므로, 다음 릴리스 전에 빌드 머신을 확인하세요.

Capacitor 앱용 Live updates

웹 레이어 버그가 라이브 상태라면, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상 리뷰 절차를 그대로 따르는 반면, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 정말 프로페셔널한 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.