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)은 즉시 적용하면 구버전 코드가 깨진다. 안전한 방법:
- Migration 1: 새 컬럼 추가 + 트리거로 양방향 동기화
- 코드 배포: 새 컬럼만 사용하도록 변경
- 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 트랜잭션 훅과 결합하면 더 복잡한 마이그레이션 시나리오도 안정적으로 처리할 수 있다.