TypeORM Migration 운영 전략

TypeORM 마이그레이션이란?

TypeORM 마이그레이션은 데이터베이스 스키마 변경을 코드로 버전 관리하는 메커니즘입니다. synchronize: true는 개발에서만 사용하고, 프로덕션에서는 반드시 마이그레이션 파일로 스키마를 제어해야 합니다. 실수로 테이블이 삭제되거나 컬럼이 변경되는 사고를 방지합니다.

프로젝트 설정

DataSource 분리

NestJS 앱용 DataSource와 CLI용 DataSource를 분리해야 마이그레이션 CLI가 정상 동작합니다.

// src/database/data-source.ts — CLI 전용
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config(); // .env 로드

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/database/migrations/*.ts'],
  migrationsTableName: 'typeorm_migrations',
});

// app.module.ts — 앱 전용 (synchronize: false!)
TypeOrmModule.forRoot({
  type: 'postgres',
  host: process.env.DB_HOST,
  // ...
  entities: [__dirname + '/**/*.entity{.ts,.js}'],
  synchronize: false,  // 프로덕션 필수
  migrationsRun: true, // 앱 시작 시 자동 실행 (선택)
  migrations: [__dirname + '/database/migrations/*{.ts,.js}'],
})

package.json 스크립트

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

마이그레이션 생성 방법

1. generate — 엔티티 diff 기반 자동 생성

# 엔티티 변경 후 diff 기반 마이그레이션 생성
npm run migration:generate src/database/migrations/AddPhoneToUser

# 생성 결과
export class AddPhoneToUser1709942400000 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "user" ADD "phone" character varying`
    );
    await queryRunner.query(
      `CREATE INDEX "IDX_user_phone" ON "user" ("phone")`
    );
  }

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

2. create — 빈 마이그레이션 수동 작성

# 데이터 마이그레이션, 시드 데이터 등 수동 작성이 필요할 때
npm run migration:create src/database/migrations/SeedDefaultRoles

// 수동 작성
export class SeedDefaultRoles1709942500000 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      INSERT INTO "role" (name, description) VALUES
        ('admin', '시스템 관리자'),
        ('manager', '매니저'),
        ('user', '일반 사용자')
      ON CONFLICT (name) DO NOTHING
    `);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DELETE FROM "role" WHERE name IN ('admin', 'manager', 'user')
    `);
  }
}

실전 마이그레이션 패턴

무중단 컬럼 추가

// 1단계: nullable 컬럼 추가 (무중단)
export class AddEmailVerifiedAt1709943000000 implements MigrationInterface {
  async up(qr: QueryRunner): Promise<void> {
    await qr.query(
      `ALTER TABLE "user" ADD "email_verified_at" TIMESTAMP`
    );
  }
  async down(qr: QueryRunner): Promise<void> {
    await qr.query(`ALTER TABLE "user" DROP COLUMN "email_verified_at"`);
  }
}

// 2단계: 데이터 백필 (별도 마이그레이션)
export class BackfillEmailVerified1709943100000 implements MigrationInterface {
  async up(qr: QueryRunner): Promise<void> {
    // 배치 처리로 Lock 최소화
    await qr.query(`
      UPDATE "user" SET email_verified_at = created_at
      WHERE email_verified_at IS NULL AND is_verified = true
    `);
  }
  async down(qr: QueryRunner): Promise<void> {
    // 데이터 백필은 롤백 불필요
  }
}

// 3단계: NOT NULL 제약 추가 (앱 코드에서 이미 처리 확인 후)
export class MakeEmailVerifiedNotNull1709943200000 implements MigrationInterface {
  async up(qr: QueryRunner): Promise<void> {
    await qr.query(`
      ALTER TABLE "user"
      ALTER COLUMN "email_verified_at" SET DEFAULT NOW(),
      ALTER COLUMN "email_verified_at" SET NOT NULL
    `);
  }
  async down(qr: QueryRunner): Promise<void> {
    await qr.query(`
      ALTER TABLE "user" ALTER COLUMN "email_verified_at" DROP NOT NULL
    `);
  }
}

무중단 컬럼 이름 변경

// 위험: ALTER TABLE RENAME COLUMN → 앱이 즉시 실패
// 안전한 3단계 접근:

// 1단계: 새 컬럼 추가 + 트리거로 동기화
export class AddFullNameColumn1709944000000 implements MigrationInterface {
  async up(qr: QueryRunner): Promise<void> {
    await qr.query(`ALTER TABLE "user" ADD "full_name" VARCHAR`);
    await qr.query(`UPDATE "user" SET full_name = name`);
    // 양방향 동기화 트리거
    await qr.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();
    `);
  }
  async down(qr: QueryRunner): Promise<void> {
    await qr.query(`DROP TRIGGER IF EXISTS trg_sync_user_name ON "user"`);
    await qr.query(`DROP FUNCTION IF EXISTS sync_user_name`);
    await qr.query(`ALTER TABLE "user" DROP COLUMN "full_name"`);
  }
}

// 2단계: 앱 코드에서 full_name 사용으로 전환
// 3단계: 트리거 제거 + name 컬럼 삭제

트랜잭션 제어

// TypeORM은 기본적으로 각 마이그레이션을 트랜잭션으로 실행
// DDL 트랜잭션 미지원 DB(MySQL)에서는 주의

// 트랜잭션 비활성화가 필요한 경우
export class CreateIndexConcurrently1709945000000 implements MigrationInterface {
  // PostgreSQL CONCURRENTLY는 트랜잭션 안에서 실행 불가
  transaction = false;  // 트랜잭션 비활성화

  async up(qr: QueryRunner): Promise<void> {
    await qr.query(`
      CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_order_customer_created"
      ON "order" ("customer_id", "created_at" DESC)
    `);
  }

  async down(qr: QueryRunner): Promise<void> {
    await qr.query(`DROP INDEX CONCURRENTLY IF EXISTS "IDX_order_customer_created"`);
  }
}

CI/CD 통합

# GitHub Actions 예시
- name: Run migrations
  run: |
    npm run migration:run
  env:
    DB_HOST: ${{ secrets.DB_HOST }}
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

# Docker entrypoint에서 실행
# entrypoint.sh
#!/bin/sh
echo "Running migrations..."
npx typeorm-ts-node-commonjs migration:run -d dist/database/data-source.js
echo "Starting app..."
node dist/main.js

Kubernetes Job으로 분리

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
  annotations:
    argocd.argoproj.io/hook: PreSync  # ArgoCD에서 앱 배포 전 실행
spec:
  template:
    spec:
      containers:
        - name: migration
          image: my-api:latest
          command: ["npx", "typeorm-ts-node-commonjs", "migration:run", "-d", "dist/database/data-source.js"]
          envFrom:
            - secretRef:
                name: db-credentials
      restartPolicy: Never
  backoffLimit: 3

롤백 전략

# 마지막 마이그레이션 롤백
npm run migration:revert

# 적용된 마이그레이션 상태 확인
npm run migration:show
# [X] AddPhoneToUser1709942400000       ← 적용됨
# [X] SeedDefaultRoles1709942500000     ← 적용됨
# [ ] AddFullNameColumn1709944000000    ← 미적용

운영 팁

  • generate 후 반드시 검토: 자동 생성된 SQL이 의도와 일치하는지 확인 — 컬럼 순서 변경으로 불필요한 DROP/ADD 발생 가능
  • down() 작성 필수: 롤백 불가능한 마이그레이션은 장애 대응을 어렵게 함
  • 큰 테이블 ALTER: QueryBuilder로 배치 처리하거나 pt-online-schema-change 사용
  • 인덱스 생성: PostgreSQL에서는 CONCURRENTLY로 테이블 잠금 방지
  • 테스트 DB: Testcontainers로 마이그레이션 포함 통합 테스트
  • 마이그레이션 파일명: 타임스탬프 기반으로 순서 보장 — 수동 변경 금지

정리

TypeORM 마이그레이션은 프로덕션 스키마 관리의 핵심입니다. generate로 엔티티 diff를 자동 생성하고, 무중단 배포를 위한 다단계 마이그레이션 패턴을 적용하며, CI/CD 파이프라인에 통합해 안전하게 운영합니다. synchronize: true를 프로덕션에서 사용하는 것은 시한폭탄 — 마이그레이션이 유일한 정답입니다.

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