TypeORM Migration 운영 전략

TypeORM Migration이란? 왜 중요한가

스키마 변경은 프로덕션에서 가장 위험한 작업 중 하나다. TypeORM Migration은 데이터베이스 스키마를 버전 관리하고 안전하게 적용·롤백하는 메커니즘이다. synchronize: true는 개발에서만 허용되며, 프로덕션에서는 반드시 Migration을 통해 스키마를 변경해야 한다.

이 글에서는 TypeORM Migration의 생성부터 CI/CD 파이프라인 통합, 무중단 마이그레이션 패턴, 롤백 전략, 데이터 마이그레이션까지 실전 운영 수준으로 다룬다.

Migration CLI 설정

TypeORM CLI를 사용하려면 DataSource 설정 파일이 필요하다.

// src/data-source.ts
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config(); // .env 로드

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10),
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
  migrationsTableName: 'typeorm_migrations',
  // 프로덕션에서 절대 true 금지
  synchronize: false,
});

package.json에 스크립트를 추가한다:

{
  "scripts": {
    "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/data-source.ts",
    "migration:create": "typeorm-ts-node-commonjs migration:create",
    "migration:run": "typeorm-ts-node-commonjs migration:run -d src/data-source.ts",
    "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts",
    "migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts"
  }
}

generate vs create: 올바른 선택

명령 동작 사용 시점
migration:generate Entity와 DB 스키마를 비교하여 자동 생성 Entity 변경 후 DDL 자동 추출
migration:create 빈 Migration 파일 생성 데이터 마이그레이션, 수동 SQL 필요 시
# Entity 변경 후 자동 생성
npm run migration:generate -- src/migrations/AddOrderStatus

# 데이터 마이그레이션용 빈 파일 생성
npm run migration:create -- src/migrations/BackfillOrderStatus

자동 생성된 Migration 분석

// src/migrations/1711100400000-AddOrderStatus.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddOrderStatus1711100400000 implements MigrationInterface {
  name = 'AddOrderStatus1711100400000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    // 1. 새 enum 타입 생성
    await queryRunner.query(
      `CREATE TYPE "order_status_enum" AS ENUM('pending', 'confirmed', 'shipped', 'cancelled')`
    );

    // 2. 컬럼 추가 (기본값 포함 — 기존 행에 영향 없음)
    await queryRunner.query(
      `ALTER TABLE "order" ADD "status" "order_status_enum" NOT NULL DEFAULT 'pending'`
    );

    // 3. 인덱스 추가
    await queryRunner.query(
      `CREATE INDEX "IDX_order_status" ON "order" ("status")`
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_order_status"`);
    await queryRunner.query(`ALTER TABLE "order" DROP COLUMN "status"`);
    await queryRunner.query(`DROP TYPE "order_status_enum"`);
  }
}

핵심 원칙: up()down()은 반드시 대칭이어야 한다. up에서 추가한 것을 down에서 정확히 제거해야 롤백이 안전하다.

무중단 마이그레이션 패턴

프로덕션에서 스키마 변경 시 서비스 중단 없이 적용하려면 Expand-Contract 패턴을 따른다.

// Phase 1: Expand — 새 컬럼 추가 (nullable, 기본값)
export class ExpandAddEmail1711100500000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // nullable로 추가 → 기존 코드 영향 없음
    await queryRunner.query(
      `ALTER TABLE "user" ADD "email_verified" boolean DEFAULT false`
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "user" DROP COLUMN "email_verified"`
    );
  }
}

// Phase 2: 애플리케이션 코드 배포 (새 컬럼 사용)
// → 이 단계는 Migration이 아닌 코드 배포

// Phase 3: Contract — 제약 조건 강화
export class ContractEmailNotNull1711100600000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 먼저 기존 null 데이터 채우기
    await queryRunner.query(
      `UPDATE "user" SET "email_verified" = false WHERE "email_verified" IS NULL`
    );
    // NOT NULL 제약 추가
    await queryRunner.query(
      `ALTER TABLE "user" ALTER COLUMN "email_verified" SET NOT NULL`
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "user" ALTER COLUMN "email_verified" DROP NOT NULL`
    );
  }
}

컬럼 이름 변경: 안전한 3단계

컬럼 이름 변경(RENAME COLUMN)은 즉시 적용하면 구버전 코드가 깨진다. 안전한 방법:

  1. Migration 1: 새 컬럼 추가 + 트리거로 양방향 동기화
  2. 코드 배포: 새 컬럼만 사용하도록 변경
  3. Migration 2: 구 컬럼 제거 + 트리거 삭제
// Step 1: 새 컬럼 + 동기화 트리거
export class RenameNameToFullName1711100700000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 새 컬럼 추가
    await queryRunner.query(
      `ALTER TABLE "user" ADD "full_name" varchar(255)`
    );

    // 기존 데이터 복사
    await queryRunner.query(
      `UPDATE "user" SET "full_name" = "name"`
    );

    // 양방향 동기화 트리거
    await queryRunner.query(`
      CREATE OR REPLACE FUNCTION sync_user_name() RETURNS TRIGGER AS $$
      BEGIN
        IF TG_OP = 'INSERT' OR NEW."name" IS DISTINCT FROM OLD."name" THEN
          NEW."full_name" = NEW."name";
        END IF;
        IF TG_OP = 'INSERT' OR NEW."full_name" IS DISTINCT FROM OLD."full_name" THEN
          NEW."name" = NEW."full_name";
        END IF;
        RETURN NEW;
      END;
      $$ LANGUAGE plpgsql;

      CREATE TRIGGER trg_sync_user_name
      BEFORE INSERT OR UPDATE ON "user"
      FOR EACH ROW EXECUTE FUNCTION sync_user_name();
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TRIGGER IF EXISTS trg_sync_user_name ON "user"`);
    await queryRunner.query(`DROP FUNCTION IF EXISTS sync_user_name`);
    await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "full_name"`);
  }
}

데이터 마이그레이션: 대량 업데이트

export class BackfillOrderTotal1711100800000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 배치 처리로 대량 업데이트 — 락 최소화
    const BATCH_SIZE = 1000;
    let affected = BATCH_SIZE;

    while (affected === BATCH_SIZE) {
      const result = await queryRunner.query(`
        UPDATE "order" SET "total_amount" = (
          SELECT COALESCE(SUM(oi.price * oi.quantity), 0)
          FROM "order_item" oi WHERE oi."orderId" = "order"."id"
        )
        WHERE "id" IN (
          SELECT "id" FROM "order"
          WHERE "total_amount" IS NULL
          LIMIT ${BATCH_SIZE}
        )
      `);
      affected = result[1] ?? 0;

      // 각 배치 사이 짧은 대기 — DB 부하 분산
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `UPDATE "order" SET "total_amount" = NULL`
    );
  }
}

CI/CD 파이프라인 통합

# .github/workflows/migration.yml
name: Database Migration
on:
  push:
    branches: [main]
    paths: ['src/migrations/**']

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Migration Dry Run (스테이징)
        run: |
          npm run migration:show
          npm run migration:run
        env:
          DB_HOST: ${{ secrets.STAGING_DB_HOST }}
          DB_NAME: ${{ secrets.STAGING_DB_NAME }}

      - name: Migration (프로덕션)
        if: success()
        run: npm run migration:run
        env:
          DB_HOST: ${{ secrets.PROD_DB_HOST }}
          DB_NAME: ${{ secrets.PROD_DB_NAME }}

운영 안티패턴과 체크리스트

안티패턴 위험 올바른 방법
프로덕션 synchronize: true 예측 불가 스키마 변경, 데이터 손실 항상 Migration 사용
down() 미구현 롤백 불가 up/down 항상 대칭 구현
한 Migration에 DDL + DML 혼합 트랜잭션 범위 불명확 스키마/데이터 마이그레이션 분리
NOT NULL 컬럼 즉시 추가 기존 행 INSERT 실패, 서비스 장애 Expand-Contract 패턴 적용
대량 UPDATE 한 번에 실행 테이블 락, 타임아웃 배치 처리 + 적절한 대기

마무리

TypeORM Migration은 단순한 스키마 변경 도구가 아니라 프로덕션 데이터베이스의 안전망이다. generate로 자동화하되 반드시 생성된 SQL을 리뷰하고, Expand-Contract 패턴으로 무중단 변경을 보장하며, CI/CD에 통합하여 휴먼 에러를 방지해야 한다. TypeORM Spatial PostGIS 같은 특수 컬럼 타입이나 TypeORM Subscriber 트랜잭션 훅과 결합하면 더 복잡한 마이그레이션 시나리오도 안정적으로 처리할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux