NestJS + TypeORM Migration 운영

왜 마이그레이션을 수동이 아닌 코드로 관리해야 하는가

NestJS + TypeORM 프로젝트에서 synchronize: true는 개발 편의를 위한 옵션이지, 운영 환경에서 사용할 설정이 아니다. TypeORM 공식 문서는 이를 명확히 경고한다: “Be careful with this option and don’t use it in production — otherwise you can lose production data.” (typeorm.io, DataSource Options)

운영 환경에서는 스키마 변경을 마이그레이션 파일로 관리해야 한다. TypeORM은 migration:generate, migration:run, migration:revert 세 가지 CLI 명령을 제공하며, 이 글에서는 이 세 명령을 NestJS 프로젝트에서 CI/CD 파이프라인까지 안전하게 운용하는 실무 워크플로를 다룬다.

1. DataSource 설정 분리: 앱용 vs CLI용

NestJS의 TypeOrmModule.forRoot()에 전달하는 설정과, TypeORM CLI가 사용하는 DataSource 인스턴스는 분리하는 것이 운영 표준이다. TypeORM 0.3.x부터 CLI는 독립된 DataSource 파일을 요구한다.

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

export default new DataSource({
  type: 'mysql',
  host: process.env.DB_HOST || 'localhost',
  port: Number(process.env.DB_PORT) || 3306,
  username: process.env.DB_USER || 'root',
  password: process.env.DB_PASS || '',
  database: process.env.DB_NAME || 'myapp',
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
  synchronize: false,  // 반드시 false
});

package.json에 CLI 스크립트를 등록한다:

{
  "scripts": {
    "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts",
    "migration:generate": "npm run typeorm -- migration:generate",
    "migration:run": "npm run typeorm -- migration:run",
    "migration:revert": "npm run typeorm -- migration:revert"
  }
}

핵심: -d 플래그로 DataSource 파일 경로를 지정한다. TypeORM 0.3.x에서 ormconfig.json은 더 이상 자동 로드되지 않는다 (TypeORM 0.3 Breaking Changes).

2. migration:generate — 자동 diff 생성의 원리와 주의점

migration:generate은 현재 엔티티 코드와 실제 데이터베이스 스키마를 비교(diff)하여, 차이를 해소하는 SQL을 자동 생성한다.

# 사용법
npm run migration:generate -- src/migrations/AddUserEmailIndex

이 명령은 타임스탬프가 붙은 파일을 생성한다:

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

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

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`CREATE INDEX `IDX_user_email` ON `user` (`email`)`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX `IDX_user_email` ON `user``);
  }
}

generate 사용 시 반드시 지켜야 할 3가지 규칙

  1. 로컬 DB 스키마가 운영과 동기화된 상태에서 실행하라. generate는 “현재 DB”와 “엔티티 코드”의 diff를 만든다. 로컬 DB가 엉망이면 엉뚱한 마이그레이션이 생성된다.
  2. 생성된 SQL을 반드시 리뷰하라. 자동 생성이라고 맹신하면 안 된다. 특히 컬럼 이름 변경은 TypeORM이 “DROP + ADD”로 처리하므로 데이터 손실이 발생한다.
  3. 한 PR에 하나의 마이그레이션만 포함하라. 여러 스키마 변경을 하나의 마이그레이션에 묶으면 revert가 복잡해진다.

3. migration:run — 실행 순서와 멱등성

TypeORM은 migrations 테이블(기본명: typeorm_metadata가 아닌 migrations)에 이미 실행된 마이그레이션을 기록한다. migration:run은 이 테이블을 조회하여, 아직 실행되지 않은 마이그레이션만 타임스탬프 순서대로 실행한다.

# 운영 환경에서 실행
NODE_ENV=production npm run migration:run

CI/CD 파이프라인에서의 실행 패턴

마이그레이션을 앱 시작 시 자동 실행(migrationsRun: true)하는 방법과, 배포 단계에서 별도로 실행하는 방법이 있다.

방식 장점 단점 적합한 경우
migrationsRun: true 별도 단계 불필요 다중 인스턴스에서 race condition 위험 단일 인스턴스, 단순 배포
배포 Job에서 별도 실행 실행 시점 명확, 롤백 용이 파이프라인 복잡도 증가 다중 인스턴스, K8s 환경

Kubernetes 환경 권장 패턴: 배포 전 Job 또는 initContainer로 마이그레이션을 먼저 실행한 뒤, 앱 Pod를 롤아웃한다. 이렇게 하면 여러 Pod가 동시에 마이그레이션을 시도하는 문제를 방지할 수 있다.

# k8s Job 예시
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync
spec:
  template:
    spec:
      containers:
      - name: migrate
        image: myapp:latest
        command: ["npm", "run", "migration:run"]
        env:
        - name: DB_HOST
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: host
      restartPolicy: Never
  backoffLimit: 1

4. migration:revert — 롤백의 현실과 한계

migration:revert는 가장 최근에 실행된 마이그레이션 하나down() 메서드를 실행한다. 여러 단계를 되돌리려면 여러 번 실행해야 한다.

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

revert가 실패하는 흔한 상황

  • down() 메서드가 비어 있거나 부정확할 때: generate가 자동으로 down()을 생성하지만, 수동 마이그레이션에서는 개발자가 직접 작성해야 한다. 빈 down()은 “롤백 불가능”을 의미한다.
  • 데이터가 이미 변경되어 역변환이 불가능할 때: 예를 들어 컬럼 타입을 VARCHAR(100)에서 VARCHAR(50)으로 줄인 후, 50자를 초과하는 데이터가 입력되면 revert 시 제약 위반이 발생한다.
  • 외래 키 제약조건 순서 문제: 테이블 삭제 순서가 외래 키 의존성과 맞지 않으면 실패한다.

운영 원칙: revert는 “긴급 수술 도구”이지, 일상적인 롤백 수단이 아니다. 운영 환경에서는 문제가 생기면 새로운 마이그레이션으로 수정(forward-fix)하는 것이 더 안전하다.

5. 실무에서 자주 겪는 5가지 마이그레이션 실수와 대응

실수 1: synchronize를 끄지 않고 마이그레이션을 병행

synchronize: true와 마이그레이션을 동시에 사용하면, TypeORM이 엔티티 변경을 자동 반영하면서 마이그레이션 히스토리와 실제 스키마가 불일치한다. 둘 중 하나만 선택해야 한다.

실수 2: 컬럼 이름 변경을 엔티티에서 바로 수정

TypeORM의 generate는 컬럼 rename을 감지하지 못한다. firstNamefirst_name으로 바꾸면 “DROP firstName + ADD first_name”이 생성되어 데이터가 사라진다. 이름 변경은 수동 마이그레이션으로 ALTER TABLE ... RENAME COLUMN을 직접 작성해야 한다.

실수 3: 마이그레이션 파일을 수정하고 다시 실행

이미 실행된 마이그레이션 파일을 수정해도 TypeORM은 다시 실행하지 않는다. migrations 테이블에 이미 기록이 남아 있기 때문이다. 수정이 필요하면 revert 후 수정하거나, 새 마이그레이션을 만들어야 한다.

실수 4: 대형 테이블에 NOT NULL 컬럼을 기본값 없이 추가

수백만 행 테이블에 NOT NULL 컬럼을 기본값 없이 추가하면 마이그레이션이 실패하거나 장시간 락이 걸린다. 안전한 패턴: (1) NULL 허용 컬럼 추가 → (2) 데이터 백필 → (3) NOT NULL 제약 추가. 이를 3단계 마이그레이션으로 분리한다.

실수 5: 트랜잭션 내에서 DDL 실행 (MySQL)

MySQL(InnoDB)은 DDL 문에 implicit commit을 수행한다. 따라서 하나의 마이그레이션에 여러 DDL을 넣어도 트랜잭션으로 원자적 롤백이 되지 않는다. PostgreSQL과 달리 MySQL에서는 DDL 마이그레이션을 가능한 한 작은 단위로 분리해야 한다.

6. NestJS에서 마이그레이션 통합 설정 체크리스트

  1. 앱 모듈 설정에서 synchronize: false, migrationsRun: false (CI/CD 별도 실행 시)
  2. CLI용 DataSource 파일(src/data-source.ts)을 분리하여 엔티티·마이그레이션 경로 명시
  3. tsconfig에서 "emitDecoratorMetadata": true, "experimentalDecorators": true 확인
  4. .gitignore에 마이그레이션 파일을 포함하지 않음 — 마이그레이션은 반드시 버전 관리 대상
  5. PR 리뷰 체크리스트에 “마이그레이션 SQL 리뷰” 항목 추가
  6. 스테이징 환경에서 마이그레이션을 먼저 실행하고 검증한 뒤 프로덕션에 적용

마무리: 마이그레이션은 “스키마의 Git”이다

코드 변경을 Git으로 추적하듯, 스키마 변경은 마이그레이션으로 추적해야 한다. generate로 diff를 만들고, run으로 적용하고, revert는 비상용으로 남겨두되 평소에는 forward-fix를 선호하라. 이 세 명령의 역할과 한계를 정확히 이해하는 것이 TypeORM 운영의 시작이다.

본문의 모든 내용은 TypeORM 공식 문서(0.3.x DataSource API, Migrations 섹션)와 TypeORM GitHub 릴리즈 노트에 근거합니다.

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