MikroORM Migrations란? 데이터베이스 스키마 버전 관리의 핵심
애플리케이션이 발전하면 데이터베이스 스키마도 함께 변합니다. 컬럼 추가, 인덱스 생성, 테이블 분리, 데이터 타입 변경 — 이런 변경을 “누가, 언제, 어떤 순서로 적용했는지” 추적하고, 모든 환경(로컬, 스테이징, 프로덕션)에 동일하게 반영하는 것이 Migration의 역할입니다.
MikroORM의 Migration 시스템은 엔티티 정의와 실제 DB 스키마의 diff를 자동 감지하여 SQL을 생성합니다. 이 글에서는 Migration CLI 명령어부터 자동 생성 vs 수동 작성, 안전한 롤백 전략, 데이터 마이그레이션(DML), 프로덕션 배포 패턴, 그리고 Custom Types과 함께 사용할 때의 주의사항까지 운영 수준에서 완전히 다룹니다.
MikroORM Migration 설정
// mikro-orm.config.ts
import { defineConfig } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
export default defineConfig({
entities: ['./dist/entities/**/*.js'],
entitiesTs: ['./src/entities/**/*.ts'],
dbName: 'myapp',
extensions: [Migrator],
migrations: {
tableName: 'mikro_orm_migrations', // 마이그레이션 이력 테이블
path: './src/migrations', // 마이그레이션 파일 경로
pathTs: './src/migrations', // TypeScript 소스 경로
glob: '!(*.d).{js,ts}', // 파일 패턴
transactional: true, // 각 마이그레이션을 트랜잭션으로 감싸기
allOrNothing: true, // 하나라도 실패하면 전체 롤백
disableForeignKeys: true, // FK 제약 임시 비활성화
snapshot: true, // 스냅샷 파일 생성 (diff 속도 향상)
emit: 'ts', // TypeScript로 생성
},
});
마이그레이션 이력 테이블 구조
| 컬럼 | 타입 | 설명 |
|---|---|---|
| id | serial | 자동 증가 PK |
| name | varchar | 마이그레이션 클래스명 (Migration20260222_AddUserEmail) |
| executed_at | timestamp | 실행 시각 |
CLI 명령어 완전 정복
migration:create — 마이그레이션 자동 생성
# 엔티티 변경 사항을 감지하여 마이그레이션 자동 생성
$ npx mikro-orm migration:create
# 생성된 파일: src/migrations/Migration20260222230000.ts
# 엔티티와 현재 DB 스키마의 diff를 SQL로 변환
migration:create –blank — 빈 마이그레이션 (수동 작성용)
# 빈 마이그레이션 파일 생성 (데이터 마이그레이션 등)
$ npx mikro-orm migration:create --blank
# 생성된 파일에 직접 SQL 작성
migration:up — 미실행 마이그레이션 적용
# 모든 미실행 마이그레이션 순서대로 적용
$ npx mikro-orm migration:up
# 특정 마이그레이션까지만 적용
$ npx mikro-orm migration:up --to Migration20260222230000
# 하나만 적용
$ npx mikro-orm migration:up --only Migration20260222230000
migration:down — 롤백
# 마지막 마이그레이션 롤백
$ npx mikro-orm migration:down
# 특정 마이그레이션까지 롤백
$ npx mikro-orm migration:down --to Migration20260222200000
# 전체 롤백 (초기 상태로)
$ npx mikro-orm migration:down --to 0
migration:pending / migration:list — 상태 확인
# 미실행 마이그레이션 목록
$ npx mikro-orm migration:pending
# Migration20260222230000 - pending
# 전체 마이그레이션 이력
$ npx mikro-orm migration:list
# Migration20260220100000 - executed (2026-02-20 10:00:00)
# Migration20260221150000 - executed (2026-02-21 15:00:00)
# Migration20260222230000 - pending
migration:fresh — 전체 재생성 (개발 전용)
# 모든 테이블 DROP 후 마이그레이션 처음부터 재실행
$ npx mikro-orm migration:fresh
# ⚠️ 절대 프로덕션에서 사용하지 말 것!
# 모든 데이터가 삭제됩니다
schema:diff — diff만 확인 (적용하지 않음)
# 엔티티와 DB의 차이를 SQL로 출력 (적용 안 함)
$ npx mikro-orm schema:diff
자동 생성 마이그레이션: 엔티티 변경 → SQL 자동 감지
// 1. 엔티티 변경: email 컬럼 추가
@Entity()
export class User {
@PrimaryKey()
id!: number;
@Property()
name!: string;
@Property() // ← 새로 추가
email!: string;
@Property({ nullable: true }) // ← 새로 추가
bio?: string;
}
# 2. 마이그레이션 생성
$ npx mikro-orm migration:create
// 3. 자동 생성된 마이그레이션 파일
import { Migration } from '@mikro-orm/migrations';
export class Migration20260222230000 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "user" add column "email" varchar(255) not null;'
);
this.addSql(
'alter table "user" add column "bio" varchar(255) null;'
);
}
async down(): Promise<void> {
this.addSql('alter table "user" drop column "email";');
this.addSql('alter table "user" drop column "bio";');
}
}
핵심: MikroORM은 엔티티 메타데이터와 현재 DB 스키마(또는 스냅샷)을 비교하여 필요한 ALTER 문을 자동 생성합니다. down() 메서드도 자동으로 역방향 SQL을 생성합니다.
수동 마이그레이션: 데이터 마이그레이션(DML)
스키마 변경(DDL)뿐 아니라 데이터 변환(DML)이 필요한 경우가 있습니다:
// 시나리오: name 컬럼을 first_name + last_name으로 분리
import { Migration } from '@mikro-orm/migrations';
export class Migration20260222231000_SplitUserName extends Migration {
async up(): Promise<void> {
// 1. 새 컬럼 추가
this.addSql(
'alter table "user" add column "first_name" varchar(255);'
);
this.addSql(
'alter table "user" add column "last_name" varchar(255);'
);
// 2. 데이터 마이그레이션: 기존 name을 분리
this.addSql(`
update "user"
set "first_name" = split_part("name", ' ', 1),
"last_name" = coalesce(nullif(split_part("name", ' ', 2), ''), 'Unknown');
`);
// 3. NOT NULL 제약 추가
this.addSql(
'alter table "user" alter column "first_name" set not null;'
);
this.addSql(
'alter table "user" alter column "last_name" set not null;'
);
// 4. 기존 컬럼 삭제 (위험! 아래 '안전한 컬럼 삭제' 참조)
// this.addSql('alter table "user" drop column "name";');
}
async down(): Promise<void> {
// 역방향: first_name + last_name → name 복원
this.addSql(
'alter table "user" add column "name" varchar(255);'
);
this.addSql(`
update "user"
set "name" = "first_name" || ' ' || "last_name";
`);
this.addSql(
'alter table "user" alter column "name" set not null;'
);
this.addSql('alter table "user" drop column "first_name";');
this.addSql('alter table "user" drop column "last_name";');
}
}
EntityManager 활용: 복잡한 데이터 마이그레이션
import { Migration } from '@mikro-orm/migrations';
export class Migration20260222232000_EncryptEmails extends Migration {
async up(): Promise<void> {
// EntityManager로 복잡한 로직 실행
const knex = this.getKnex();
// 배치 처리: 1000건씩 처리
const batchSize = 1000;
let offset = 0;
let hasMore = true;
while (hasMore) {
const users = await knex('user')
.select('id', 'email')
.offset(offset)
.limit(batchSize);
if (users.length === 0) {
hasMore = false;
break;
}
for (const user of users) {
// 이메일 소문자 정규화
await knex('user')
.where({ id: user.id })
.update({ email: user.email.toLowerCase().trim() });
}
offset += batchSize;
}
// 정규화 후 유니크 인덱스 추가
this.addSql(
'create unique index "user_email_unique" on "user" ("email");'
);
}
async down(): Promise<void> {
this.addSql('drop index "user_email_unique";');
// 이메일 소문자 변환은 되돌릴 수 없음 (원본 데이터 유실)
}
}
스냅샷(Snapshot): diff 속도 최적화
snapshot: true 설정 시 MikroORM은 마지막 스키마 상태를 파일로 저장합니다:
src/migrations/
├── Migration20260220100000.ts
├── Migration20260221150000.ts
├── Migration20260222230000.ts
└── .snapshot-myapp.json ← 스냅샷 파일
| 모드 | diff 방식 | 장점 | 단점 |
|---|---|---|---|
| snapshot: false | 실제 DB 스키마와 비교 | 정확 | DB 연결 필요, 느림 |
| snapshot: true | 스냅샷 파일과 비교 | 빠름, DB 연결 불필요 | 수동 DB 변경 감지 불가 |
권장: 개발 환경에서는 snapshot: true로 빠르게 작업하고, CI/CD에서 실제 DB와 비교 검증하세요.
프로덕션 배포 전략: 안전한 마이그레이션
전략 1: 애플리케이션 시작 시 자동 실행
// NestJS에서 앱 시작 시 마이그레이션 자동 실행
import { MikroORM } from '@mikro-orm/core';
@Module({
imports: [
MikroOrmModule.forRootAsync({
useFactory: () => ({
// ... 설정
}),
}),
],
})
export class AppModule implements OnModuleInit {
constructor(private readonly orm: MikroORM) {}
async onModuleInit() {
const migrator = this.orm.getMigrator();
// pending 마이그레이션 확인
const pending = await migrator.getPendingMigrations();
if (pending.length > 0) {
console.log(`Applying ${pending.length} pending migrations...`);
await migrator.up();
console.log('Migrations applied successfully.');
}
}
}
⚠️ 주의: 여러 Pod가 동시에 시작되면 마이그레이션이 중복 실행될 수 있습니다. Advisory Lock을 사용하거나, Kubernetes init container로 분리하세요.
전략 2: Kubernetes Init Container (권장)
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
initContainers:
- name: migrate
image: myapp:latest
command: ["npx", "mikro-orm", "migration:up"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
containers:
- name: app
image: myapp:latest
command: ["node", "dist/main.js"]
Init container는 하나의 Pod에서 한 번만 실행되므로 중복 실행 문제가 없습니다. 마이그레이션이 실패하면 Pod가 시작되지 않아 문제를 즉시 감지할 수 있습니다.
전략 3: CI/CD 파이프라인에서 별도 실행
# GitHub Actions 예시
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx mikro-orm migration:up
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
deploy:
needs: migrate # 마이그레이션 성공 후에만 배포
runs-on: ubuntu-latest
steps:
- run: kubectl rollout restart deployment/myapp
안전한 스키마 변경: Zero-Downtime 패턴
패턴 1: 컬럼 추가 (안전)
// nullable 또는 기본값이 있는 컬럼 추가는 안전
@Property({ nullable: true })
nickname?: string;
@Property({ default: 'active' })
status!: string;
// PostgreSQL에서 NOT NULL + DEFAULT 추가는 테이블 잠금 없이 가능 (v11+)
패턴 2: 컬럼 삭제 (3단계 점진적)
// ❌ 위험: 한 번에 삭제하면 구 버전 앱이 에러
// Migration 1에서: alter table "user" drop column "legacy_field";
// ✅ 안전: 3단계 진행
// Step 1 (배포 1): 코드에서 컬럼 사용 중단
// - 엔티티에서 해당 필드 제거
// - 코드에서 해당 필드 참조 모두 제거
// - 이 시점에서는 DB에 컬럼 남아있음 (호환성 유지)
// Step 2 (배포 2): 컬럼 nullable로 변경
export class Migration_Step2 extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "user" alter column "legacy_field" drop not null;'
);
}
}
// Step 3 (배포 3, 충분한 시간 후): 컬럼 삭제
export class Migration_Step3 extends Migration {
async up(): Promise<void> {
this.addSql('alter table "user" drop column "legacy_field";');
}
}
패턴 3: 컬럼 이름 변경 (Expand-Contract)
// ❌ 위험: rename은 구 버전 앱을 즉시 깨뜨림
// alter table "user" rename column "name" to "full_name";
// ✅ 안전: Expand → Migrate → Contract
// Migration 1: Expand (새 컬럼 추가)
export class Migration_Expand extends Migration {
async up(): Promise<void> {
this.addSql(
'alter table "user" add column "full_name" varchar(255);'
);
// 기존 데이터 복사
this.addSql('update "user" set "full_name" = "name";');
// 트리거로 양방향 동기화 (선택)
this.addSql(`
create or replace function sync_user_name() returns trigger as $$
begin
if TG_OP = 'UPDATE' then
if NEW."name" is distinct from OLD."name" then
NEW."full_name" = NEW."name";
elsif NEW."full_name" is distinct from OLD."full_name" then
NEW."name" = NEW."full_name";
end if;
end if;
return NEW;
end;
$$ language plpgsql;
`);
}
}
// Migration 2: 코드가 full_name만 사용하도록 전환 후 → name 삭제
export class Migration_Contract extends Migration {
async up(): Promise<void> {
this.addSql('drop trigger if exists sync_user_name on "user";');
this.addSql('drop function if exists sync_user_name;');
this.addSql('alter table "user" drop column "name";');
}
}
인덱스 생성: CONCURRENTLY 옵션
export class Migration_AddIndex extends Migration {
async up(): Promise<void> {
// ❌ 대형 테이블에서 테이블 잠금 발생
// this.addSql('create index "user_email_idx" on "user" ("email");');
// ✅ CONCURRENTLY: 잠금 없이 인덱스 생성
// 주의: 트랜잭션 안에서 실행 불가!
this.addSql(
'create index concurrently "user_email_idx" on "user" ("email");'
);
}
// CONCURRENTLY 사용 시 transactional 비활성화 필요
isTransactional(): boolean {
return false; // 이 마이그레이션은 트랜잭션 밖에서 실행
}
async down(): Promise<void> {
this.addSql('drop index "user_email_idx";');
}
}
⚠️ 주의: CREATE INDEX CONCURRENTLY는 트랜잭션 내에서 실행할 수 없습니다. isTransactional()을 오버라이드하여 false를 반환해야 합니다.
테스트에서의 마이그레이션
// 테스트 설정: 마이그레이션 기반 스키마 생성
describe('OrderService', () => {
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init({
// 테스트 DB 설정
dbName: 'myapp_test',
// 방법 1: 마이그레이션 실행 (프로덕션과 동일한 스키마 보장)
});
const migrator = orm.getMigrator();
await migrator.up();
});
afterAll(async () => {
await orm.close(true);
});
beforeEach(async () => {
// 각 테스트 전 데이터 정리
await orm.em.execute('TRUNCATE TABLE "user" CASCADE;');
});
});
// 방법 2: schema:create (빠르지만 마이그레이션 검증 안 됨)
beforeAll(async () => {
const generator = orm.getSchemaGenerator();
await generator.refreshDatabase(); // DROP + CREATE
});
흔한 실수와 해결법
1. 자동 생성 SQL을 검토 없이 적용
// ❌ 자동 생성 마이그레이션을 바로 up
$ npx mikro-orm migration:create
$ npx mikro-orm migration:up # 검토 안 함!
// ✅ 반드시 생성된 SQL 검토 후 적용
$ npx mikro-orm migration:create
$ cat src/migrations/Migration20260222*.ts # SQL 확인!
# - DROP TABLE이 있는지?
# - 대형 테이블 ALTER에 LOCK이 걸리는지?
# - 데이터 손실 가능성은?
$ npx mikro-orm migration:up
2. down() 메서드 미작성
// ❌ 롤백 불가
async down(): Promise<void> {
// 빈 down → 롤백 시 아무것도 안 됨
}
// ✅ 역방향 SQL 작성 (자동 생성 활용)
async down(): Promise<void> {
this.addSql('alter table "user" drop column "email";');
}
3. 마이그레이션 파일 수정
// ❌ 이미 실행된 마이그레이션을 수정하면
// 다른 환경에서 다른 스키마가 됨!
// ✅ 새 마이그레이션으로 수정사항 추가
$ npx mikro-orm migration:create # 수정 사항을 새 파일로
4. 대형 테이블에서 NOT NULL 추가
// ❌ 대형 테이블 전체 스캔 + 잠금
this.addSql('alter table "orders" add column "status" varchar(20) not null;');
// ✅ 3단계: nullable 추가 → 데이터 채우기 → NOT NULL 설정
this.addSql('alter table "orders" add column "status" varchar(20);');
this.addSql("update "orders" set "status" = 'pending' where "status" is null;");
this.addSql('alter table "orders" alter column "status" set not null;');
this.addSql("alter table "orders" alter column "status" set default 'pending';");
5. 마이그레이션 순서 충돌 (팀 협업)
// 두 개발자가 동시에 마이그레이션 생성 시
// Migration20260222230001 (A의 변경)
// Migration20260222230001 (B의 변경) ← 타임스탬프 충돌!
// ✅ 해결: PR 머지 후 마이그레이션 재생성
// 또는 수동으로 타임스탬프 조정
NestJS 통합: 프로그래밍 방식 마이그레이션 실행
// migration.service.ts
@Injectable()
export class MigrationService {
constructor(private readonly orm: MikroORM) {}
async runMigrations(): Promise<string[]> {
const migrator = this.orm.getMigrator();
const pending = await migrator.getPendingMigrations();
if (pending.length === 0) {
return [];
}
const executed = await migrator.up();
return executed.map(m => m.name);
}
async getPending(): Promise<string[]> {
const migrator = this.orm.getMigrator();
const pending = await migrator.getPendingMigrations();
return pending.map(m => m.name);
}
async getExecuted(): Promise<string[]> {
const migrator = this.orm.getMigrator();
const executed = await migrator.getExecutedMigrations();
return executed.map(m => m.name);
}
}
이 서비스는 MikroORM Custom Types를 사용하는 엔티티의 마이그레이션도 동일하게 처리합니다.
정리: MikroORM Migrations 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 자동 생성 SQL을 반드시 검토 후 적용 | ☐ |
| down() 메서드에 역방향 SQL 작성 | ☐ |
| 이미 실행된 마이그레이션 파일 수정 금지 | ☐ |
| 컬럼 삭제는 3단계 점진적 진행 | ☐ |
| 대형 테이블 인덱스는 CONCURRENTLY + isTransactional=false | ☐ |
| NOT NULL 추가는 nullable → 데이터 채우기 → NOT NULL 순서 | ☐ |
| 프로덕션은 Init Container 또는 CI/CD에서 마이그레이션 실행 | ☐ |
| 데이터 마이그레이션은 배치 처리 (대형 테이블) | ☐ |
| snapshot: true로 diff 속도 최적화 | ☐ |
| 마이그레이션 파일은 Git에 커밋하여 버전 관리 | ☐ |
MikroORM Migrations는 엔티티 변경을 DB 스키마에 안전하게 반영하는 버전 관리 시스템입니다. 자동 생성의 편리함을 활용하되, 반드시 SQL을 검토하고, 프로덕션에서는 zero-downtime 패턴(Expand-Contract, CONCURRENTLY, 점진적 삭제)을 적용해야 합니다. 특히 대형 테이블에서의 스키마 변경은 “빠르게 한 번에”가 아니라 “안전하게 여러 단계로”가 철칙입니다.