왜 마이그레이션을 수동이 아닌 코드로 관리해야 하는가
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가지 규칙
- 로컬 DB 스키마가 운영과 동기화된 상태에서 실행하라. generate는 “현재 DB”와 “엔티티 코드”의 diff를 만든다. 로컬 DB가 엉망이면 엉뚱한 마이그레이션이 생성된다.
- 생성된 SQL을 반드시 리뷰하라. 자동 생성이라고 맹신하면 안 된다. 특히 컬럼 이름 변경은 TypeORM이 “DROP + ADD”로 처리하므로 데이터 손실이 발생한다.
- 한 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을 감지하지 못한다. firstName을 first_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에서 마이그레이션 통합 설정 체크리스트
- 앱 모듈 설정에서
synchronize: false,migrationsRun: false(CI/CD 별도 실행 시) - CLI용 DataSource 파일(
src/data-source.ts)을 분리하여 엔티티·마이그레이션 경로 명시 - tsconfig에서
"emitDecoratorMetadata": true,"experimentalDecorators": true확인 - .gitignore에 마이그레이션 파일을 포함하지 않음 — 마이그레이션은 반드시 버전 관리 대상
- PR 리뷰 체크리스트에 “마이그레이션 SQL 리뷰” 항목 추가
- 스테이징 환경에서 마이그레이션을 먼저 실행하고 검증한 뒤 프로덕션에 적용
마무리: 마이그레이션은 “스키마의 Git”이다
코드 변경을 Git으로 추적하듯, 스키마 변경은 마이그레이션으로 추적해야 한다. generate로 diff를 만들고, run으로 적용하고, revert는 비상용으로 남겨두되 평소에는 forward-fix를 선호하라. 이 세 명령의 역할과 한계를 정확히 이해하는 것이 TypeORM 운영의 시작이다.
본문의 모든 내용은 TypeORM 공식 문서(0.3.x DataSource API, Migrations 섹션)와 TypeORM GitHub 릴리즈 노트에 근거합니다.