junyeokk
Blog
database·2025. 08. 01

TypeORM Migration

애플리케이션을 개발하다 보면 데이터베이스 스키마가 끊임없이 변한다. 새로운 테이블이 추가되고, 컬럼이 바뀌고, 인덱스가 생긴다. 개발 환경에서는 synchronize: true 옵션으로 엔티티 변경을 자동으로 DB에 반영할 수 있지만, 프로덕션에서 이 옵션을 켜면 데이터가 날아갈 수 있다. TypeORM의 synchronize는 엔티티와 DB 스키마의 차이를 감지해서 테이블을 드롭하고 다시 만들기도 하기 때문이다.

Migration은 이 문제를 해결한다. 스키마 변경을 코드로 관리하고, 순서대로 실행하고, 필요하면 되돌릴 수 있게 만든다. Git이 코드 변경 이력을 관리하듯, migration은 데이터베이스 스키마의 변경 이력을 관리한다.

synchronize vs migration

두 방식의 차이를 명확히 짚고 넘어가자.

항목synchronizemigration
동작 방식엔티티 메타데이터와 DB 스키마를 비교해서 자동 동기화개발자가 작성한 SQL/코드를 순서대로 실행
데이터 안전성컬럼 삭제 시 데이터 유실 가능개발자가 직접 제어하므로 안전
롤백불가능down() 메서드로 가능
적용 이력없음migrations 테이블에 기록
사용 환경개발/테스트 전용프로덕션 포함 모든 환경

개발 환경에서는 synchronize: true로 빠르게 개발하고, 프로덕션에서는 synchronize: false + migrationsRun: true로 migration을 통해 스키마를 관리하는 것이 일반적이다.

typescript
// 개발 환경
{
  synchronize: true,
  migrationsRun: false,
}

// 프로덕션 환경
{
  synchronize: false,
  migrationsRun: true,
  migrations: [`${__dirname}/migration/*.{js,ts}`],
}

DataSource 설정

Migration을 사용하려면 TypeORM CLI가 DataSource 인스턴스에 접근할 수 있어야 한다. 보통 프로젝트 루트에 dataSource.ts 파일을 만든다.

typescript
import { DataSource } from 'typeorm';

export const AppDataSource = new DataSource({
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: 'password',
  database: 'my_database',
  entities: [`${__dirname}/src/**/*.entity.{js,ts}`],
  migrations: [`${__dirname}/src/database/migration/*.{js,ts}`],
});

entities는 엔티티 파일 경로, migrations는 마이그레이션 파일 경로를 glob 패턴으로 지정한다. CLI는 이 DataSource를 기반으로 현재 DB 상태를 파악하고, 마이그레이션을 생성하거나 실행한다.

MigrationInterface

모든 마이그레이션 파일은 MigrationInterface를 구현한다. 이 인터페이스는 딱 두 개의 메서드를 요구한다.

typescript
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUser1619999999998 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 스키마를 "앞으로" 변경하는 로직
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    // up()의 변경을 되돌리는 로직
  }
}
  • up(): 마이그레이션을 실행할 때 호출된다. 테이블 생성, 컬럼 추가, 인덱스 생성 등의 작업을 수행한다.
  • down(): 마이그레이션을 되돌릴 때 호출된다. up()에서 수행한 작업의 정확한 역순을 구현해야 한다.

클래스 이름에 붙는 타임스탬프(1619999999998)는 마이그레이션의 실행 순서를 결정한다. TypeORM은 이 숫자를 기준으로 오름차순 정렬해서 아직 실행되지 않은 마이그레이션을 순서대로 적용한다.

QueryRunner

QueryRunner는 마이그레이션 내에서 데이터베이스 작업을 수행하는 핵심 객체다. 직접 SQL을 실행할 수도 있고, TypeORM의 스키마 빌더 API를 사용할 수도 있다.

Raw SQL 방식

가장 직관적인 방법이다. SQL을 직접 작성하므로 정확히 어떤 쿼리가 실행되는지 한눈에 보인다.

typescript
export class CreatePost1700000000001 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE post (
        id int NOT NULL AUTO_INCREMENT,
        title varchar(255) NOT NULL,
        content text NOT NULL,
        created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
        user_id int NOT NULL,
        PRIMARY KEY (id),
        KEY FK_post_user (user_id),
        CONSTRAINT FK_post_user FOREIGN KEY (user_id)
          REFERENCES user (id) ON DELETE CASCADE ON UPDATE CASCADE
      );
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query('DROP TABLE post;');
  }
}

Schema Builder 방식

TypeORM이 제공하는 Table, TableColumn, TableForeignKey 등의 클래스를 사용해서 DB 독립적으로 스키마를 정의할 수 있다.

typescript
import { MigrationInterface, QueryRunner, Table } from 'typeorm';

export class CreatePost1700000000001 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        name: 'post',
        columns: [
          {
            name: 'id',
            type: 'int',
            isPrimary: true,
            isGenerated: true,
            generationStrategy: 'increment',
          },
          {
            name: 'title',
            type: 'varchar',
            length: '255',
          },
          {
            name: 'content',
            type: 'text',
          },
        ],
      }),
      true, // ifNotExists
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable('post');
  }
}

두 방식 중 어느 것이 더 좋다고 단정하기는 어렵다. Raw SQL은 복잡한 제약조건이나 DB 고유 기능을 사용할 때 유리하고, Schema Builder는 DB 종류에 상관없이 동작하는 마이그레이션을 만들 때 유리하다. 실무에서는 Raw SQL을 더 많이 쓰는 편이다. 어떤 쿼리가 실행되는지 명확하고, MySQL 전용 기능이나 복잡한 ALTER 문을 쓸 때 Schema Builder로는 표현하기 어려운 경우가 있기 때문이다.

마이그레이션 CLI 명령어

TypeORM CLI를 사용해서 마이그레이션을 생성하고 실행한다.

자동 생성 (generate)

엔티티 변경 사항을 감지해서 마이그레이션 파일을 자동 생성한다.

bash
# TypeORM CLI
npx typeorm migration:generate src/database/migration/CreatePost -d dataSource.ts

# ts-node 사용 시
npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate src/database/migration/CreatePost -d dataSource.ts

이 명령은 현재 엔티티의 메타데이터와 DB의 실제 스키마를 비교해서, 차이를 맞추기 위한 SQL을 up()down()에 자동으로 채워준다. 편리하지만, 생성된 SQL을 반드시 검토해야 한다. 의도하지 않은 변경이 포함될 수 있기 때문이다.

수동 생성 (create)

빈 마이그레이션 파일을 생성한다. 개발자가 직접 up()down()을 작성한다.

bash
npx typeorm migration:create src/database/migration/CreatePost

DB 초기 셋업이나, generate로 잡히지 않는 작업(데이터 마이그레이션, VIEW 생성 등)을 할 때 사용한다.

실행 (run)

아직 적용되지 않은 마이그레이션을 순서대로 실행한다.

bash
npx typeorm migration:run -d dataSource.ts

TypeORM은 migrations 테이블을 자동으로 생성하고, 이미 실행된 마이그레이션의 이름과 타임스탬프를 기록한다. run 명령을 실행하면 이 테이블을 확인해서 아직 실행되지 않은 것만 순서대로 적용한다.

되돌리기 (revert)

가장 최근에 실행된 마이그레이션 하나를 되돌린다.

bash
npx typeorm migration:revert -d dataSource.ts

down() 메서드가 호출되고, migrations 테이블에서 해당 레코드가 삭제된다. 여러 개를 되돌리려면 명령을 여러 번 실행한다.

상태 확인 (show)

어떤 마이그레이션이 실행되었고, 어떤 것이 대기 중인지 확인한다.

bash
npx typeorm migration:show -d dataSource.ts

migrationsRun 옵션

CLI로 수동 실행하는 대신, 앱이 시작될 때 자동으로 마이그레이션을 실행할 수도 있다.

typescript
{
  migrationsRun: true,
  migrations: [`${__dirname}/migration/*.{js,ts}`],
}

migrationsRun: true로 설정하면 DataSource가 초기화될 때(DataSource.initialize()) 자동으로 pending migration을 실행한다. 서버가 뜰 때마다 자동 적용되므로, 별도의 배포 스크립트 없이 마이그레이션을 관리할 수 있다.

다만 주의할 점이 있다. 여러 인스턴스가 동시에 시작되는 환경(예: 쿠버네티스 멀티 레플리카)에서는 마이그레이션이 동시에 실행되면서 충돌할 수 있다. 이 경우에는 배포 파이프라인에서 마이그레이션을 먼저 실행하고 앱을 시작하는 방식이 더 안전하다.

환경별 설정 분리

개발 환경과 프로덕션 환경에서 다른 전략을 사용하는 것이 일반적이다.

typescript
function getDatabaseConfig(env: string) {
  const isDev = env === 'LOCAL' || env === 'DEV';
  const isTest = env === 'TEST';

  return {
    type: 'mysql' as const,
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: isTest ? `test_db_${process.env.JEST_WORKER_ID}` : process.env.DB_NAME,
    entities: [`${__dirname}/**/*.entity.{js,ts}`],
    synchronize: isDev || isTest,
    migrations: [`${__dirname}/migration/*.{js,ts}`],
    migrationsRun: !(isDev || isTest),
    logging: isDev,
  };
}

이 패턴의 핵심은 다음과 같다:

  • 개발(LOCAL/DEV): synchronize: true로 빠르게 개발. 엔티티 변경이 즉시 DB에 반영된다. migrationsRun: false이므로 마이그레이션은 실행되지 않는다.
  • 테스트(TEST): synchronize: true. 워커별로 별도 DB(test_db_1, test_db_2, ...)를 사용해서 병렬 테스트 간 격리를 보장한다.
  • 프로덕션: synchronize: false, migrationsRun: true. 오직 마이그레이션으로만 스키마를 변경한다.

마이그레이션 파일 네이밍

TypeORM의 generate/create 명령은 기본적으로 {타임스탬프}-{이름}.ts 형식으로 파일을 생성한다.

1619999999993-CreateAdmin.ts 1619999999994-CreateRss.ts 1746169800168-CreateComment.ts 1752060032219-RenameLikeForeignKey.ts

타임스탬프는 밀리초 단위의 Unix 시간이다. 이 숫자가 마이그레이션의 실행 순서를 결정하기 때문에, 초기 테이블 생성 → FK 의존성 순서 → 이후 변경 순으로 타임스탬프가 증가해야 한다. 초기 테이블들의 타임스탬프가 인위적으로 연속된 숫자(예: 99993, 99994, ...)인 경우도 흔한데, 이는 프로젝트 초반에 수동으로 순서를 지정한 것이다.

파일 이름은 의미 있게 짓는 것이 좋다. 어떤 변경인지 한눈에 파악할 수 있어야 한다:

  • CreateUser — 테이블 생성
  • AddEmailToUser — 컬럼 추가
  • RenameLikeForeignKey — FK 이름 변경
  • UpdateFeedViewWithCommentCount — VIEW 업데이트

실전 패턴: ALTER 마이그레이션

테이블을 처음 만드는 것뿐 아니라, 기존 테이블을 변경하는 마이그레이션도 자주 작성하게 된다.

FK 이름 변경

외래 키 제약조건의 이름을 변경해야 할 때가 있다. 예를 들어 초기에 수동으로 붙인 이름을 TypeORM의 자동 생성 규칙에 맞추고 싶을 때.

typescript
1619999999993-CreateAdmin.ts
1619999999994-CreateRss.ts
1746169800168-CreateComment.ts
1752060032219-RenameLikeForeignKey.ts

down()up()의 정확한 역순이라는 점을 주목하자. 이것이 롤백 가능한 마이그레이션의 핵심이다.

컬럼 추가/변경

기존 테이블에 컬럼을 추가하거나 타입을 변경하는 마이그레이션이다.

typescript
export class RenameForeignKey1700000000002 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 기존 FK 삭제
    await queryRunner.query('ALTER TABLE likes DROP FOREIGN KEY FK_like_post;');
    await queryRunner.query('ALTER TABLE likes DROP FOREIGN KEY FK_like_user;');
    await queryRunner.query('ALTER TABLE likes DROP INDEX UQ_likes_user_post;');

    // 새 이름으로 FK 재생성
    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT FK_85b0dbd1e7836d0f8 FOREIGN KEY (post_id) REFERENCES post(id) ON DELETE CASCADE ON UPDATE CASCADE;',
    );
    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT FK_3f519ed95f775c781 FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE;',
    );
    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT IDX_0be1d6ca115f56ed7 UNIQUE (user_id, post_id);',
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    // 역순으로 되돌림
    await queryRunner.query('ALTER TABLE likes DROP FOREIGN KEY FK_85b0dbd1e7836d0f8;');
    await queryRunner.query('ALTER TABLE likes DROP FOREIGN KEY FK_3f519ed95f775c781;');
    await queryRunner.query('ALTER TABLE likes DROP INDEX IDX_0be1d6ca115f56ed7;');

    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT FK_like_post FOREIGN KEY (post_id) REFERENCES post(id) ON DELETE CASCADE ON UPDATE CASCADE;',
    );
    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT FK_like_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE;',
    );
    await queryRunner.query(
      'ALTER TABLE likes ADD CONSTRAINT UQ_likes_user_post UNIQUE (user_id, post_id);',
    );
  }
}

Nullable 변경

NOT NULL 컬럼을 NULL 허용으로 바꾸는 것도 마이그레이션으로 관리한다.

typescript
export class AlterPostAddViewCount1700000000003 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      'ALTER TABLE post ADD COLUMN view_count int NOT NULL DEFAULT 0;',
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query('ALTER TABLE post DROP COLUMN view_count;');
  }
}

migrations 테이블

TypeORM은 마이그레이션 실행 이력을 migrations라는 테이블에 저장한다. 이 테이블은 자동으로 생성되며, 구조는 다음과 같다:

idtimestampname
11619999999993CreateAdmin1619999999993
21619999999994CreateRss1619999999994
31746169800168CreateComment1746169800168

migration:run을 실행하면 이 테이블에 없는 마이그레이션만 실행하고, migration:revert를 실행하면 가장 마지막 레코드를 삭제하면서 해당 마이그레이션의 down()을 호출한다.

이 테이블 덕분에 여러 환경(개발, 스테이징, 프로덕션)에서 각각 어디까지 마이그레이션이 적용되었는지 독립적으로 추적할 수 있다.

주의사항

1. 이미 실행된 마이그레이션은 수정하지 마라

한번 프로덕션에 적용된 마이그레이션 파일을 수정하면, 다른 환경에서 다른 결과가 나올 수 있다. 잘못된 마이그레이션은 새로운 마이그레이션으로 수정해야 한다.

2. down()을 반드시 구현하라

롤백이 필요 없을 것 같아도 down()을 구현하는 습관을 들이자. 프로덕션에서 문제가 발생했을 때, 되돌릴 수 있는 것과 없는 것의 차이는 크다.

3. 데이터 마이그레이션은 별도로

스키마 변경과 데이터 변환을 하나의 마이그레이션에 넣으면 디버깅이 어려워진다. 스키마 변경 마이그레이션 → 데이터 마이그레이션 → 스키마 정리 마이그레이션 순서로 분리하는 것이 좋다.

4. 트랜잭션 주의

MySQL의 DDL(CREATE TABLE, ALTER TABLE 등)은 암묵적으로 커밋된다. 즉, DDL 문은 트랜잭션 안에서 롤백할 수 없다. TypeORM은 기본적으로 각 마이그레이션을 트랜잭션으로 감싸지만, MySQL에서는 DDL 문이 나오는 순간 트랜잭션이 자동 커밋되므로 사실상 의미가 없다. PostgreSQL에서는 DDL도 트랜잭션 안에서 롤백 가능하므로, DB 엔진에 따라 동작이 다르다는 점을 알아두자.

정리

  • up/down 쌍으로 스키마 변경 이력을 코드로 관리하고, migrations 테이블이 환경별 적용 상태를 추적한다
  • 개발은 synchronize, 프로덕션은 migrationsRun — 환경별 전략을 분리하고 generate 결과는 반드시 검토한다
  • 이미 적용된 마이그레이션은 수정하지 말고 새 마이그레이션으로 고치며, MySQL DDL은 트랜잭션 롤백이 안 되는 점을 감안한다

관련 문서