TypeORM은 Capacitor 앱에서 내장된 capacitor driver를 통해 동작하며, 모든 쿼리를 @capacitor-community/sqlite. 다음을 구성합니다. DataSource 플러그인의 SQLiteConnection, 데코레이터로 엔티티를 정의하고 서버에서와 동일하게 레포지토리와 트랜잭션을 사용합니다. 이 가이드는 TypeORM 1.x와 Capacitor 8의 전체 설정 과정을 다룹니다. 특히 대부분의 튜토리얼에서 생략하는 두 가지 핵심 내용, 즉 Vite를 위한 명시적 컬럼 타입 설정과 CLI이 휴대폰과 직접 통신할 수 없음에도 마이그레이션을 생성하는 방법을 포함합니다.
TypeORM이 적합한 경우
TypeORM은 엔티티, 관계, 레포지토리 및 마이그레이션 시스템을 갖춘 데이터 매퍼(data-mapper) ORM입니다. 모바일 환경에서 다음과 같은 경우 사용하면 좋습니다:
- 이미 백엔드에서 TypeORM을 사용 중이며 앱에서도 동일한 엔티티 스타일을 유지하고 싶은 경우,
- 데이터 모델에 관계(relation)가 많고,
relations: { posts: true }직접 조인(join) 쿼리를 작성하는 대신, - 팀에서 스키마 객체보다 클래스와 데코레이터를 선호하는 경우.
쿼리 빌더보다는 무겁습니다. 번들 크기와 앱 시작 속도가 가장 중요하거나, @capgo/capacitor-fast-sql를 사용하고 싶다면, Kysely를 살펴보세요 또는 Drizzle 대신 사용하세요.
설치
bun add typeorm @capacitor-community/sqlite
bunx cap sync
TypeORM 1.x는 스스로 reflect-metadata 임포트하므로, 파일 상단에 별도 임포트가 필요 없습니다 main.ts 기존 가이드에서 보여주듯이. 커뮤니티 플러그인은 암호화 여부와 상관없이 iOS와 Android에 SQLCipher를 번들링하며, 이는 App Store 수출 규정 준수 질문에 답할 때 중요합니다.
TypeScript 설정
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
experimentalDecorators TypeORM의 레거시 데코레이터를 활성화합니다. useDefineForClassFields: false 클래스 필드 초기화자가 TypeORM이 설정한 값을 덮어쓰지 않도록 하며, 이는 target 가 ES2022 이상일 때 중요합니다.
Vite와 esbuild에서 동작하는 Entity
대부분의 Capacitor 앱은 Vite로 빌드되며, esbuild를 통해 TypeScript를 컴파일합니다. esbuild는 데코레이터를 지원하지만 emitDecoratorMetadata를 구현하지 않아, TypeORM이 런타임에 속성 타입을 읽을 수 없습니다. 명시적인 타입이 없으면 ColumnTypeUndefinedError가 발생합니다. 모든 컬럼에 타입을 지정하세요:
// src/db/entities/author.ts
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Post } from './post';
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn()
id!: number;
@Column('text')
name!: string;
@Column('text', { unique: true })
email!: string;
@CreateDateColumn()
createdAt!: Date;
@OneToMany(() => Post, (post) => post.author)
posts!: Relation<Post[]>;
}
// src/db/entities/post.ts
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Author } from './author';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn()
id!: number;
@Column('text')
title!: string;
@Column('boolean', { default: false })
published!: boolean;
@Index()
@Column('integer')
authorId!: number;
@ManyToOne(() => Author, (author) => author.posts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'authorId' })
author!: Relation<Author>;
}
참고:
Relation<T>는 타입 전용 래퍼입니다. 이를 통해author.ts및post.ts간의 순환 참조 문제를 방지할 수 있습니다 (예: CLI 또는 테스트 컴파일 시).- 명시적인
authorId컬럼을 사용하면 작성자를 로드하지 않고도 ID로 필터링 및 삽입이 가능합니다. - SQLite에는 boolean 타입이 없습니다. TypeORM은
boolean컬럼을0/1로 저장한 뒤 다시 변환합니다.
DataSource 설정하기
// src/db/data-source.ts
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite';
import { DataSource } from 'typeorm';
import { Author } from './entities/author';
import { Post } from './entities/post';
import { Init1790000000000 } from './migrations/1790000000000-Init';
export const sqliteConnection = new SQLiteConnection(CapacitorSQLite);
export const AppDataSource = new DataSource({
type: 'capacitor',
driver: sqliteConnection,
database: 'app',
mode: 'no-encryption',
entities: [Author, Post],
migrations: [Init1790000000000],
synchronize: false,
migrationsRun: false,
journalMode: 'WAL',
logging: ['error'],
});
옵션별 상세 설명:
driver: 플러그인의SQLiteConnection. TypeORM 드라이버는 이를 사용하여 연결을 생성 및 오픈한 뒤,PRAGMA foreign_keys어떤onDelete: 'CASCADE'기능을 활성화합니다.database: 플러그인이SQLite.db접미사를 추가하여 파일명이appSQLite.db.mode:no-encryption로 지정되거나, 플러그인 암호화 모드 중 하나인 (secret,encryption"). 규칙은 Capacitor에서 SQLite 암호화.synchronize: false: 프로덕션 환경에서는 반드시 사용하세요. Sync 사용 시 엔티티와 맞추기 위해 컬럼이 삭제될 수 있으며, 이는 기기 내 사용자 데이터를 삭제할 수 있습니다.migrationsRun: false: 플러그인 문서에서는 capacitor 타입을 요구합니다. 마이그레이션은 실행 직후 직접 수행하세요.initialize(), 아래 예시와 같습니다.journalMode: 'WAL': 선택 사항입니다. 읽기/쓰기 간의 동시성을 높여줍니다.migrations: glob이 아닌 클래스 참조를 사용해야 합니다. WebView에서는 파일 시스템 스캔이 불가능합니다.
다음과 같은 entities: ['src/**/*.entity.ts'] Glob 패턴은 같은 이유로 앱 내에서 작동하지 않습니다. 클래스를 직접 import 하세요.
앱 시작 시 초기화하세요
// src/db/index.ts
import { Capacitor } from '@capacitor/core';
import { AppDataSource, sqliteConnection } from './data-source';
let ready: Promise<typeof AppDataSource> | null = null;
export function initDatabase() {
if (!ready) {
ready = (async () => {
if (Capacitor.getPlatform() === 'web') {
// Needs the jeep-sqlite element in the DOM, see "Web support"
await sqliteConnection.initWebStore();
}
// Clean up connections left over from a hot reload
await sqliteConnection.checkConnectionsConsistency();
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
await AppDataSource.runMigrations({ transaction: 'each' });
if (Capacitor.getPlatform() === 'web') {
await sqliteConnection.saveToStore('app');
}
return AppDataSource;
})();
}
return ready;
}
호출하세요 await initDatabase() 데이터를 읽는 화면이 렌더링되기 전에 완료해야 합니다. transaction: 'each' 각 마이그레이션을 개별 트랜잭션으로 감싸므로, 실패 시 이전 마이그레이션은 유지되고 실패한 항목만 롤백됩니다.
Repository: insert, select, update, delete
import { initDatabase } from './db';
import { Author } from './db/entities/author';
import { Post } from './db/entities/post';
const ds = await initDatabase();
const authors = ds.getRepository(Author);
const posts = ds.getRepository(Post);
// Insert
const ada = await authors.save(authors.create({ name: 'Ada', email: 'ada@example.com' }));
await posts.save([
posts.create({ title: 'Draft', authorId: ada.id }),
posts.create({ title: 'Live', authorId: ada.id, published: true }),
]);
// Select
const published = await posts.find({
where: { authorId: ada.id, published: true },
order: { id: 'DESC' },
});
const one = await authors.findOneBy({ email: 'ada@example.com' });
// Load a relation
const withPosts = await authors.findOne({
where: { id: ada.id },
relations: { posts: true },
});
// Update without loading
await posts.update({ authorId: ada.id }, { published: true });
// Delete
await posts.delete({ published: false });
save() 기본 키(primary key) 설정 여부에 따라 insert 또는 update를 수행하며, 생성된 ID가 포함된 엔티티를 반환합니다. 엔티티 반환이 필요 없는 벌크 쓰기(bulk writes)의 경우, insert() 추가적인 조회 과정을 생략하므로 더 빠릅니다.
복잡한 쿼리를 위한 Query builder
const stats = await ds
.getRepository(Author)
.createQueryBuilder('a')
.leftJoin('a.posts', 'p')
.select('a.id', 'id')
.addSelect('a.name', 'name')
.addSelect('COUNT(p.id)', 'postCount')
.groupBy('a.id')
.getRawMany<{ id: number; name: string; postCount: number }>();
Join과 집계 작업은 모바일에서 ORM이 시간을 절약해 주는 핵심 요소입니다. 생성된 SQL은 .getSql() 개발 중에 확인하고, 다음에서 사용되는 컬럼에는 인덱스를 추가하세요: WHERE 및 JOIN.
Transactions
await ds.transaction(async (manager) => {
const grace = await manager.save(Author, { name: 'Grace', email: 'grace@example.com' });
await manager.save(Post, { title: 'Hello', authorId: grace.id });
});
트레이드오프를 평가하려면 manager 트랜잭션 내의 모든 쿼리에 대해 콜백으로 전달됩니다. 콜백에서 에러가 발생하면 TypeORM이 롤백합니다.
Capacitor 드라이버는 단일 연결을 사용하므로 트랜잭션과 다른 작업 간의 격리가 없습니다. 트랜잭션이 열려 있는 동안 ds 또는 다른 repository를 통해 실행된 쿼리는 동일한 연결에서 실행되며, 트랜잭션과 함께 커밋되거나 롤백됩니다. 트랜잭션이 완료될 때까지 관련 없는 DB 작업을 시작하지 마세요. 트랜잭션은 짧게 유지하고, 내부에서 네트워크 요청을 기다리지 마세요.
Migrations: 로컬 sqljs DataSource로 생성하세요
TypeORM CLI은 폰이 아닌 컴퓨터에서 실행되므로 기기의 데이터베이스를 직접 검사할 수 없습니다. 플러그인 문서에 따르면 capacitor 타입의 migration은 직접 작성해야 합니다. 그래도 자동 생성된 migration을 얻을 수는 있습니다: TypeORM의 sqljs 드라이버가 동일한 SQLite dialect를 사용하므로, 기기에서 그대로 실행 가능한 SQL을 생성합니다.
1. CLI 전용 DataSource를 추가합니다.
bun add -d sql.js
// typeorm.cli.ts (project root, not bundled into the app)
import { DataSource } from 'typeorm';
import { Author } from './src/db/entities/author';
import { Post } from './src/db/entities/post';
export default new DataSource({
type: 'sqljs',
location: '.typeorm/schema.sqlite',
autoSave: true,
entities: [Author, Post],
migrations: ['src/db/migrations/*.ts'],
});
추가 .typeorm/ 대상 .gitignore. 이 파일은 “모든 기존 migration이 적용된 후의 schema 상태”를 복사한 임시 파일입니다.
2. 임시 데이터베이스를 최신 상태로 업데이트한 후, diff를 실행합니다.
mkdir -p .typeorm
bun ./node_modules/typeorm/cli.js migration:run -d ./typeorm.cli.ts
bun ./node_modules/typeorm/cli.js migration:generate -d ./typeorm.cli.ts src/db/migrations/AddTags
Bun는 TypeScript data source와 entities를 직접 실행합니다. 첫 번째 명령은 기존 migration을 임시 데이터베이스에 적용합니다. 두 번째 명령은 이를 entities와 비교하여 새로운 migration class를 작성합니다. 예시는 다음과 같습니다. src/db/migrations/1790000123456-AddTags.ts. 그런 다음 해당 class를 다음 항목에 추가합니다. migrations array 안에 data-source.ts.
생성된 초기 migration은 다음과 같은 형태입니다:
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class Init1790000000000 implements MigrationInterface {
name = 'Init1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "authors" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL, "email" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_authors_email" UNIQUE ("email"))`,
);
await queryRunner.query(
`CREATE TABLE "posts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "published" boolean NOT NULL DEFAULT (0), "authorId" integer NOT NULL, CONSTRAINT "FK_posts_author" FOREIGN KEY ("authorId") REFERENCES "authors" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
);
await queryRunner.query(`CREATE INDEX "IDX_posts_authorId" ON "posts" ("authorId")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_posts_authorId"`);
await queryRunner.query(`DROP TABLE "posts"`);
await queryRunner.query(`DROP TABLE "authors"`);
}
}
배포 전 생성된 모든 migration을 반드시 검토하세요. SQLite의 경우, TypeORM은 종종 테이블을 다음과 같은 방식으로 변경합니다. temporary_<table>, 행을 복사하고, 기존 테이블을 삭제한 뒤 이름을 변경하는 방식입니다. 이는 올바른 동작이지만, 대규모 테이블의 경우 모바일 기기에서 시간이 걸릴 수 있으므로 로딩 상태를 표시하며 실행하세요.
수동 마이그레이션 작성하기
마이그레이션을 직접 작성할 때는 모든 구문을 대문자 키워드로 시작하세요. capacitor query runner는 첫 단어의 대소문자를 구분하여 플러그인 호출 방식을 결정합니다: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER 및 DROP 로 이동 execute, INSERT, UPDATE 및 DELETE 로 이동 run, 나머지는 모두 로 이동 query. 소문자 create table 는 쿼리로 전송되는데, 이는 DDL 작업 시 의도와 다를 수 있습니다.
마이그레이션 및 Live Update
Migration 클래스는 JavaScript bundle의 일부입니다. Capgo Live Update 사용하기 스토어 심사 없이도 스키마 변경 사항을 사용자에게 즉시 적용할 수 있습니다. 롤백 시 최신 스키마에 이전 code를 적용해야 할 수도 있으므로, 가급적 테이블 추가나 nullable 컬럼 추가와 같은 additive migration 방식을 사용하고, 기존 컬럼 삭제는 다음 릴리스로 미루는 것이 좋습니다.
Web 지원
커뮤니티 플러그인은 다음을 통해 웹에서 동작합니다: jeep-sqlite web component (sql.js를 IndexedDB에 저장). 브라우저 개발 시 유용합니다:
bun add jeep-sqlite
cp node_modules/sql.js/dist/sql-wasm.wasm public/assets/
// main.ts, before initDatabase()
import { defineCustomElements as jeepSqlite } from 'jeep-sqlite/loader';
import { Capacitor } from '@capacitor/core';
if (Capacitor.getPlatform() === 'web') {
jeepSqlite(window);
const el = document.createElement('jeep-sqlite');
document.body.appendChild(el);
await customElements.whenDefined('jeep-sqlite');
}
웹 환경에서 데이터베이스는 메모리에 상주하며, 호출 시 IndexedDB에 기록됩니다. sqliteConnection.saveToStore('app'). 보존이 필요한 쓰기 작업 직후에 호출하세요. 그렇지 않으면 새로고침 시 데이터가 손실됩니다.
문제 해결
Column type for Post#title is not defined and cannot be guessed. esbuild에서 메타데이터가 생성되지 않았습니다. 다음 항목에 명시적인 타입을 추가하세요: @Column 데코레이터.
Capacitor package has not been found installed. 이 driver option이 누락되었거나 정의되지 않았습니다. 다음을 전달하세요: new SQLiteConnection(CapacitorSQLite).
새로고침 시 “connection already exists” 에러가 발생합니다. 초기화가 두 번 실행되었습니다. 단일 ready promise를 유지하고 호출하세요. checkConnectionsConsistency() 첫 번째 제출이 승인되기 initialize().
No metadata for "Post" was found. 엔티티 클래스가 entities array에 없거나 glob 문자열을 사용한 경우입니다. 클래스를 import 하세요.
개발 중 앱을 업데이트할 때마다 데이터가 초기화됩니다. synchronize: true 켜져 있거나 마이그레이션이 테이블을 삭제하고 다시 생성합니다. sync를 끄고 생성된 SQL을 확인하세요.
마이그레이션이 CLI에서는 실행됐지만 디바이스에서는 실행되지 않았습니다. 새 클래스가 migrations array 안에 data-source.ts없습니다. 앱은 폴더를 스캔하지 않습니다.
마무리
Capacitor에서 TypeORM은 세 가지로 구성됩니다. 드라이버 역할을 하는 커뮤니티 SQLite 플러그인, 명시적 컬럼 타입을 가진 엔티티, 그리고 로컬 sqljs 데이터베이스에 대해 생성되어 클래스로 임포트되는 마이그레이션입니다. 이 구성이 갖춰지면 리포지토리, 관계, 트랜잭션이 백엔드와 동일하게 동작합니다.
더 가벼운 설정을 원한다면 @capgo/capacitor-fast-sql, 참고: Capacitor와 함께 사용하는 Kysely. 플러그인 기초는 다음에서 시작하세요. Capacitor 앱에서 SQLite를 사용하는 방법. 네이티브 측이 변경되면, Capgo 빌드 클라우드에서 iOS 및 Android 앱을 다시 빌드하고 서명할 수 있습니다.