본문 바로가기

Capacitor 및 SQLite에서 Kysely 사용하는 방법

Capacitor와 SQLite에서 Kysely 사용하기: Fast SQL용 작은 Kysely dialect를 작성하고, 테이블에 타입을 지정한 뒤 쿼리, 트랜잭션, 인앱 마이그레이션을 실행하세요.

Capacitor 로고, Capacitor 및 SQLite에서 Kysely를 사용하는 가이드

Kysely는 플랫폼에서 SQL 문자열과 매개변수를 보내고 행을 객체로 반환하는 드라이버만 필요하기 때문에 Capacitor 및 SQLite와 잘 동작합니다. 이 가이드는 해당 드라이버를 @capgo/capacitor-fast-sql구현한 후, iOS, Android 및 웹에서 앱 내부에서 실행되는 타입 지정 테이블, CRUD, 조인, 트랜잭션 및 마이그레이션을 다룹니다.

code 생성 없이, 무거운 런타임 없이 컴파일 타임에 체크되는 SQL을 쓸 수 있습니다.

Kysely가 모바일 SQLite에 적합한 이유

Kysely는 ORM이 아니라 쿼리 빌더입니다. SQL처럼 읽히는 코드를 작성하면 TypeScript가 모든 테이블과 컬럼명을 검사하고 결과 타입을 추론합니다.

  • 데코레이터나 리플렉션이 없어 Vite, esbuild에서 바로 동작합니다.
  • code 생성 단계가 없습니다. 여러분의 Database interface가 곧 스키마 컨트랙트입니다.
  • 트리 쉐이킹이 잘 되는 작은 런타임이라 앱 번들 크기와 live update를 다운로드 크기에 중요합니다.
  • 드라이버 인터페이스는 아주 작습니다. Kysely에 이미 SQLite 쿼리 컴파일러, 어댑터, 인트로스펙터가 들어 있어 트랜스포트만 작성하면 됩니다.

아직 앱에 SQLite를 설정하지 않았다면, 다음에서 시작하세요. Capacitor 앱에서 SQLite를 사용하는 방법. Fast SQL에 필요한 네이티브 구성을 다룹니다 (NSAllowsLocalNetworking iOS 설정과 Android의 localhost cleartext 규칙).

설치

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

이 가이드에서는 Kysely 0.29와 Fast SQL 8.2를 사용합니다. Kysely 0.29에서는 마이그레이션 클래스가 kysely/migration 엔트리포인트로 이동했습니다.

Fast SQL 다이얼렉트 작성

Kysely Dialect 팩토리가 4개 있습니다. 그중 3개는 Kysely의 SQLite 지원에서 그대로 가져온 것이고, 4번째는 저희가 만든 드라이버입니다.

// src/db/fast-sql-dialect.ts
import {
  CompiledQuery,
  SqliteAdapter,
  SqliteIntrospector,
  SqliteQueryCompiler,
  type DatabaseConnection,
  type DatabaseIntrospector,
  type Dialect,
  type DialectAdapter,
  type Driver,
  type Kysely,
  type QueryCompiler,
  type QueryResult,
} from 'kysely';
import {
  FastSQL,
  type SQLConnection,
  type SQLConnectionOptions,
  type SQLValue,
} from '@capgo/capacitor-fast-sql';

// Kysely passes JS values as-is. Normalize them to what SQLite stores.
function toSqlValue(value: unknown): SQLValue {
  if (value === undefined || value === null) return null;
  if (typeof value === 'boolean') return value ? 1 : 0;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'bigint') return Number(value);
  if (value instanceof Uint8Array) return value;
  if (typeof value === 'object') return JSON.stringify(value);
  return value as SQLValue;
}

class FastSqlConnection implements DatabaseConnection {
  constructor(readonly db: SQLConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const result = await this.db.execute(compiled.sql, compiled.parameters.map(toSqlValue));
    return {
      rows: result.rows as R[],
      numAffectedRows: result.rowsAffected != null ? BigInt(result.rowsAffected) : undefined,
      insertId: result.insertId != null ? BigInt(result.insertId) : undefined,
    };
  }

  // eslint-disable-next-line require-yield
  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported by the Fast SQL driver');
  }
}

// One native connection per database, so serialize access to it.
class ConnectionMutex {
  #pending: Promise<void> = Promise.resolve();
  #release?: () => void;

  async lock(): Promise<void> {
    const previous = this.#pending;
    let release!: () => void;
    this.#pending = new Promise<void>((resolve) => (release = resolve));
    await previous;
    this.#release = release;
  }

  unlock(): void {
    const release = this.#release;
    this.#release = undefined;
    release?.();
  }
}

class FastSqlDriver implements Driver {
  #connection?: FastSqlConnection;
  readonly #mutex = new ConnectionMutex();

  constructor(private readonly options: SQLConnectionOptions) {}

  async init(): Promise<void> {
    const db = await FastSQL.connect(this.options);
    this.#connection = new FastSqlConnection(db);
  }

  async acquireConnection(): Promise<DatabaseConnection> {
    await this.#mutex.lock();
    return this.#connection!;
  }

  async releaseConnection(): Promise<void> {
    this.#mutex.unlock();
  }

  async beginTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.beginTransaction();
  }

  async commitTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.commit();
  }

  async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.rollback();
  }

  async destroy(): Promise<void> {
    if (this.#connection) {
      await FastSQL.disconnect(this.options.database);
      this.#connection = undefined;
    }
  }
}

export class FastSqlDialect implements Dialect {
  constructor(private readonly options: SQLConnectionOptions) {}

  createDriver(): Driver {
    return new FastSqlDriver(this.options);
  }

  createQueryCompiler(): QueryCompiler {
    return new SqliteQueryCompiler();
  }

  createAdapter(): DialectAdapter {
    return new SqliteAdapter();
  }

  createIntrospector(db: Kysely<any>): DatabaseIntrospector {
    return new SqliteIntrospector(db);
  }
}

각 부분의 역할:

  • toSqlValue 부울을 1/0로, 날짜를 ISO 문자열로, 일반 객체를 JSON으로 변환합니다. SQLite에는 부울이나 날짜 타입이 없고, Android에서는 플러그인이 읽기 쿼리 매개변수를 텍스트로 바인딩하므로 true 값은 문자열 'true' 로 도착해 1.
  • FastSqlConnection.executeQuery 호출합니다 execute() 과 절대 일치하지 않습니다. Fast SQL 연결은 iOS와 Android에서는 플러그인의 로컬 HTTP 채널을, 웹에서는 SQLite Wasm을 통해 이루어집니다. 행은 이미 컬럼 이름을 키로 하는 객체로 반환되는데, 이는 Kysely가 기대하는 형식과 정확히 일치합니다.
  • ConnectionMutex 가 중요합니다. Fast SQL은 데이터베이스당 네이티브 연결을 하나만 유지합니다. 락이 없으면 동시에 실행되는 두 Kysely 트랜잭션이 하나의 연결에서 statement를 서로 뒤섞어 실행하게 됩니다. Kysely 자체의 SQLite 드라이버도 동일한 패턴을 사용합니다.
  • 트랜잭션 플러그인의 네이티브 beginTransaction, commit 및 rollback raw 대신 begin SQL을 사용하므로 플러그인이 두 플랫폼 모두에서 트랜잭션 상태를 올바르게 추적합니다.
  • Savepoints 는 구현되어 있지 않습니다. savepoint() 를 제어형 트랜잭션에 호출하면 Kysely가 The savepoint method is not supported by this driver. 추가하세요 savepoint, rollbackToSavepoint 및 releaseSavepoint 메서드를 제공합니다. 필요 시 컴파일된 savepoint SQL을 실행합니다.

테이블 설명

이 Database 인터페이스는 테이블 이름을 행(row) 타입에 매핑합니다. 데이터베이스가 채우는 내용을 설명하려면 Kysely의 헬퍼 타입을 사용하세요.

// src/db/schema.ts
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';

export interface ProjectTable {
  id: Generated<number>;
  name: string;
  created_at: ColumnType<string, string | undefined, never>;
}

export interface TaskTable {
  id: Generated<number>;
  project_id: number;
  title: string;
  done: ColumnType<number, number | boolean | undefined, number | boolean>;
  due_at: string | null;
}

export interface Database {
  projects: ProjectTable;
  tasks: TaskTable;
}

export type Task = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;

Generated<number> 은(는) id insert 시 선택 사항입니다. ColumnType<Select, Insert, Update> 컬럼이 한 타입으로 읽히고 다른 타입을 받아들이게 합니다. 여기서는 done 숫자로 읽히지만 쓰기 시 boolean을 받아들이고, 드라이버가 이를 변환합니다. 1/0. never update 시 이 컬럼은 변경할 수 없습니다.

Kysely 인스턴스 생성

// src/db/index.ts
import { Kysely } from 'kysely';
import { FastSqlDialect } from './fast-sql-dialect';
import type { Database } from './schema';
import { runMigrations } from './migrations';

let instance: Promise<Kysely<Database>> | null = null;

export function getDb(): Promise<Kysely<Database>> {
  if (!instance) {
    instance = (async () => {
      const db = new Kysely<Database>({
        dialect: new FastSqlDialect({
          database: 'tasks',
          walMode: true,
          performancePresets: true, // also enables foreign_keys
        }),
      });
      await runMigrations(db);
      return db;
    })();
  }
  return instance;
}

Kysely는 드라이버의 init() 를 첫 쿼리에서 지연 호출하므로, 인스턴스 생성 비용이 낮습니다.

앱 내부 마이그레이션

Kysely의 FileMigrationProvider 는 Node의 fs로 폴더를 읽는데, 이는 WebView에 존재하지 않습니다. 대신 code로 마이그레이션을 제공하세요. Migrator 는 적용된 이름을 kysely_migration 테이블을 조회하고 대기 중인 마이그레이션을 순서대로 실행합니다.

// src/db/migrations.ts
import { sql, type Kysely } from 'kysely';
import { Migrator, type Migration, type MigrationProvider } from 'kysely/migration';

const migrations: Record<string, Migration> = {
  '0001_init': {
    async up(db: Kysely<any>) {
      await db.schema
        .createTable('projects')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('name', 'text', (col) => col.notNull())
        .addColumn('created_at', 'text', (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
        .execute();

      await db.schema
        .createTable('tasks')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('project_id', 'integer', (col) =>
          col.notNull().references('projects.id').onDelete('cascade'),
        )
        .addColumn('title', 'text', (col) => col.notNull())
        .addColumn('done', 'integer', (col) => col.notNull().defaultTo(0))
        .execute();
    },
  },
  '0002_due_date': {
    async up(db: Kysely<any>) {
      await db.schema.alterTable('tasks').addColumn('due_at', 'text').execute();
      await db.schema.createIndex('idx_tasks_project').on('tasks').column('project_id').execute();
    },
  },
};

class InCodeMigrationProvider implements MigrationProvider {
  async getMigrations(): Promise<Record<string, Migration>> {
    return migrations;
  }
}

export async function runMigrations(db: Kysely<any>): Promise<void> {
  const migrator = new Migrator({ db, provider: new InCodeMigrationProvider() });
  const { error, results } = await migrator.migrateToLatest();

  results?.forEach((r) => {
    if (r.status === 'Error') console.error(`Migration ${r.migrationName} failed`);
  });
  if (error) throw error;
}

참고:

  • 이름은 알파벳 순으로 정렬되므로, 0으로 채운 접두사를 사용하세요.
  • migrateToLatest() 예외를 던지지 않습니다. 대신 { error, results }을 반환하므로 error 직접 확인하거나, 앱이 마이그레이션이 절반만 끝난 스키마로 시작하게 됩니다.
  • 매 실행 시 호출하는 비용은 낮습니다. 대기 중인 항목이 없으면 마이그레이션 테이블을 읽고 반환합니다.
  • SQLite는 트랜잭션 기반 DDL을 지원하므로, Migrator는 실행을 트랜잭션으로 감쌉니다. 이는 위의 드라이버와 호환됩니다.
  • 마이그레이션을 작성할 때는 Kysely<any>을 기준으로 작성하고, 현재 Database 타입을 기준으로 하지 마세요. 스키마가 진화한 후에도 기존 마이그레이션은 컴파일되어야 합니다.

쿼리

Insert

const db = await getDb();

const result = await db
  .insertInto('projects')
  .values({ name: 'Groceries' })
  .executeTakeFirst();

const projectId = Number(result.insertId);

await db
  .insertInto('tasks')
  .values([
    { project_id: projectId, title: 'Milk' },
    { project_id: projectId, title: 'Bread', done: true },
  ])
  .execute();

insertId 은(는) bigint Kysely의(의) Number() 입니다. 일반 row id로 변환하려면

피하세요 .returning() 를(을) 사용하세요. 현재 Fast SQL 버전으로 Android를 타겟팅하는 경우: Android는 SELECT, PRAGMA 또는 EXPLAIN, so INSERT ... RETURNING 는 실행되지만 아무것도 반환하지 않습니다. iOS와 web은 row를 반환합니다. insertId 를(을) 읽는 것은 모든 환경에서 작동합니다.

선택

const openTasks = await db
  .selectFrom('tasks')
  .select(['id', 'title', 'due_at'])
  .where('project_id', '=', projectId)
  .where('done', '=', 0)
  .orderBy('id')
  .execute();
// openTasks: { id: number; title: string; due_at: string | null }[]

const project = await db
  .selectFrom('projects')
  .selectAll()
  .where('id', '=', projectId)
  .executeTakeFirst(); // undefined if not found

Joins 및 aggregates

const summary = await db
  .selectFrom('projects as p')
  .leftJoin('tasks as t', 't.project_id', 'p.id')
  .select((eb) => [
    'p.id',
    'p.name',
    eb.fn.count<number>('t.id').as('task_count'),
    eb.fn.sum<number>('t.done').as('done_count'),
  ])
  .groupBy('p.id')
  .execute();

Kysely는 join 후 어떤 컬럼이 존재하는지 알고 있으며, 별칭(task_count, done_count)이 결과 타입에 포함됩니다. 행이 컬럼 이름을 키로 하는 객체로 반환되므로, 선택한 모든 컬럼에 고유한 이름을 부여해야 합니다. 두 컬럼이 모두 id 라고 이름이 같으면 행 객체에서 서로 덮어쓰게 되며, 객체를 반환하는 모든 SQLite 드라이버에서 마찬가지입니다.

JSON 헬퍼를 사용한 중첩 결과

import { jsonArrayFrom } from 'kysely/helpers/sqlite';

const projects = await db
  .selectFrom('projects')
  .select((eb) => [
    'projects.id',
    'projects.name',
    jsonArrayFrom(
      eb.selectFrom('tasks')
        .select(['tasks.id', 'tasks.title'])
        .whereRef('tasks.project_id', '=', 'projects.id'),
    ).as('tasks'),
  ])
  .execute();

이것은 SQLite의 JSON 함수에 의존합니다. iOS, 웹 빌드, 최신 Android 릴리스에는 내장되어 있지만, 사용 중인 minSdkVersion 버전이 오래되었으니 실행 SELECT json('{}') 을 실행하세요.

업데이트 및 삭제

await db.updateTable('tasks').set({ done: true }).where('id', '=', taskId).execute();

const deleted = await db.deleteFrom('tasks').where('done', '=', 1).executeTakeFirst();
console.log(Number(deleted.numDeletedRows));

트랜잭션

await db.transaction().execute(async (trx) => {
  const { insertId } = await trx
    .insertInto('projects')
    .values({ name: 'Trip' })
    .executeTakeFirstOrThrow();

  await trx
    .insertInto('tasks')
    .values(['Passport', 'Tickets', 'Charger'].map((title) => ({
      project_id: Number(insertId),
      title,
    })))
    .execute();
});

콜백에서 예외가 발생하면 드라이버가 롤백합니다. 콜백 내에서 trx, 아니라 db을 사용하세요. db 트랜잭션이 점유하고 있는 뮤텍스를 기다리므로, 트랜잭션이 끝날 때까지 데드락 상태가 됩니다.

필요할 때 Raw SQL 사용

import { sql } from 'kysely';

const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);

이 sql tag는 보간된 값을 파라미터로 바인딩하므로 사용자 입력과 함께 사용해도 안전합니다. sql.raw() 신뢰할 수 있는 프래그먼트에만 사용하세요.

커뮤니티 플러그인을 대신 사용하는 경우

를 사용 중이라면 @capacitor-community/sqlite, dialect, adapter, compiler, mutex는 유지하고 connection만 교체하세요:

import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

class CommunityConnection implements DatabaseConnection {
  constructor(readonly db: SQLiteDBConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const params = compiled.parameters.map(toSqlValue);
    if (/^\s*(select|pragma|with)\b/i.test(compiled.sql)) {
      const res = await this.db.query(compiled.sql, params);
      return { rows: (res.values ?? []) as R[] };
    }
    const res = await this.db.run(compiled.sql, params, false, 'all');
    return {
      rows: (res.changes?.values ?? []) as R[],
      numAffectedRows: BigInt(res.changes?.changes ?? 0),
      insertId: res.changes?.lastId != null ? BigInt(res.changes.lastId) : undefined,
    };
  }

  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported');
  }
}

전달하세요: false 의 세 번째 인자로 run(). 기본값은 모든 문장을 개별 트랜잭션으로 감싸 Kysely 트랜잭션을 깨뜨립니다. 플러그인의 beginTransaction(), commitTransaction() 및 rollbackTransaction() 드라이버에 있습니다.

문제 해결

Cannot find module 'kysely/migration'. 0.29 이전 버전의 Kysely를 사용 중이며, 해당 버전에서는 Migrator 이 kysely 에서 직접 export됩니다. 업데이트하거나 import를 변경하세요.

쿼리가 무한 대기에 빠집니다. 사용한 db 내부에서 db.transaction().execute(). 다음을 사용하세요: trx 인자.

done = true Android에서는 절대 일치하지 않습니다. Boolean 값이 변환 없이 플러그인에 전달되었습니다. 모든 값이 다음을 거치도록 하세요 toSqlValue, 포함: sql 템플릿 리터럴 내의 값.

no such table: kysely_migration 첫 실행 시 에러가 발생합니다. Migrator가 자체 테이블을 직접 생성하므로, 이는 보통 다음을 의미합니다: init() 이전에 실패한 것입니다. 네이티브 설정(iOS 로컬 네트워킹, Android cleartext localhost 규칙)을 확인하고 첫 번째 에러를 로그로 남기세요.

타입은 컬럼이 있다고 하지만 SQLite는 없다고 합니다. 이 Database interface와 마이그레이션이 어긋났습니다. interface가 데이터베이스에서 생성되지 않으므로, 두 가지를 같은 커밋에서 업데이트하세요.

요약

Kysely를 Capacitor에서 실행하려면 약 80줄의 glue 코드가 필요합니다: 호출하는 connection execute(), mutex, 그리고 transaction 훅. 그 대가로 iOS와 Android의 Fast SQL 네이티브 SQLite, 웹의 SQLite Wasm 위에 완전히 타입화된 SQL을 사용할 수 있습니다.

관련 글: Capacitor와 함께 Drizzle ORM 스키마 우선 정의 방식을 선호한다면, Capacitor를 사용하는 TypeORM 데코레이터와 리포지토리를 위해, 그리고 Fast SQL 플러그인 문서 모든 연결 옵션에 대해.

Capacitor 앱용 Live updates

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

블로그 최신 글

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