04 · migration:run & revert — 적용과 되돌림의 메커니즘
질문:
migration:run은 어떻게 migration 파일을 골라 실행하고, 어떻게 거꾸로 되돌리나? 한 줄 답:migrations테이블이 어디까지 적용됐는지를 기록하고, 각 migration은 기본적으로 transaction 안에서 돈다 — DDL이 트랜잭션을 깨면 반쯤 적용된 상태가 남는다.
Pyramid Top
migration:run은 세 단계다.
migrations테이블에서 마지막으로 적용된 timestamp 조회.- 파일 시스템에서 그보다 큰 timestamp 파일만 순서대로 수집.
- 각 파일을 한 트랜잭션에 넣고 up() 실행 — 성공이면
migrations테이블에 row 추가, commit. 실패면 rollback.
migration:revert는 그 역순 — 마지막에 적용된 한 개만 *down()*으로 되돌린다.
이 문서는 그 흐름의 디테일과 transaction이 안 듣는 경우(CREATE INDEX CONCURRENTLY 등)의 처리법을 다룬다.
사고 흐름
Why — 왜 migrations 테이블이 필요한가
① 같은 migration을 두 번 적용하면 안 된다
ALTER TABLE ADD COLUMN x 를 두 번 돌리면 두 번째는 실패한다 (이미 컬럼이 있어서). TypeORM은 어디까지 적용됐는지를 DB 자신에 기록한다.
② 파일 시스템은 증인이 아니다
migration 파일이 디스크에 있다는 사실이 적용됐다는 사실과 같지 않다. 다른 환경에서는 같은 파일들이 일부만 적용되어 있을 수 있다. 진실은 DB 안의 migrations 테이블에만 있다.
③ migrations 테이블의 모양
CREATE TABLE "migrations" (
"id" SERIAL PRIMARY KEY,
"timestamp" bigint NOT NULL,
"name" varchar NOT NULL
);| 필드 | 의미 |
|---|---|
id | 적용 순서 (auto-increment) |
timestamp | 파일명의 prefix와 동일 |
name | 클래스 이름 (AddEmailToUser1700000000000) |
migrations테이블의 한 row = 영원히 박제된 사실. 그 row를 지우거나 수정하면 migration 시스템 전체가 불일치에 빠진다.
How — migration:run의 실제 동작
단계 1 — migrations 테이블 확인 (없으면 생성)
SELECT * FROM "migrations" ORDER BY "id" ASC;테이블이 없으면 자동으로 생성한다. 이는 프로젝트의 첫 migration 실행 시 한 번 일어난다.
단계 2 — 적용할 파일 목록 계산
// 단순화된 의사 코드
const appliedTimestamps = new Set(
rows.map((r) => r.timestamp),
);
const pending = allMigrationFiles
.filter((f) => !appliedTimestamps.has(f.timestamp))
.sort((a, b) => a.timestamp - b.timestamp);appliedTimestamps에 없는 파일만 timestamp 오름차순으로 정렬해서 순서대로 적용한다.
단계 3 — 각 migration을 한 트랜잭션에서 실행
for (const migration of pending) {
const qr = dataSource.createQueryRunner();
await qr.startTransaction();
try {
await migration.up(qr);
await qr.query(
`INSERT INTO "migrations"(timestamp, name) VALUES ($1, $2)`,
[migration.timestamp, migration.name],
);
await qr.commitTransaction();
} catch (err) {
await qr.rollbackTransaction();
throw err;
} finally {
await qr.release();
}
}핵심: INSERT INTO migrations도 같은 트랜잭션에 들어간다. up()이 실패하면 migrations 테이블에도 기록이 안 남는다 — 같은 migration을 재시도할 수 있다.
단계 4 — migration:revert
// 마지막에 적용된 *한 개*만
const last = rows[rows.length - 1];
const migration = findByTimestamp(last.timestamp);
const qr = dataSource.createQueryRunner();
await qr.startTransaction();
try {
await migration.down(qr);
await qr.query(`DELETE FROM "migrations" WHERE timestamp = $1`, [last.timestamp]);
await qr.commitTransaction();
} catch (err) {
await qr.rollbackTransaction();
throw err;
}항상 한 개만 되돌린다. N개를 되돌리려면 N번 migration:revert를 호출한다.
What — 명령어 표
| 명령 | 동작 |
|---|---|
typeorm migration:run | 미적용 migration 전부 적용 |
typeorm migration:revert | 가장 최근 적용된 1개만 down |
typeorm migration:show | 적용/미적용 목록 출력 |
typeorm migration:create | 빈 migration 파일 생성 |
typeorm migration:generate <name> -d <ds> | entity와 DB 비교해 초안 생성 |
migration:revert가 한 개씩만 도는 이유 — 여러 개 한 번에 되돌리는 건 위험. 어디서 멈출지 사람이 눈으로 결정해야 한다.
What-if — 트랜잭션이 안 듣는 DDL
PostgreSQL의 일부 DDL은 트랜잭션 안에서 실행할 수 없다.
| DDL | 트랜잭션 OK? |
|---|---|
ALTER TABLE | ✅ |
CREATE TABLE | ✅ |
CREATE INDEX | ✅ |
CREATE INDEX CONCURRENTLY | ❌ |
VACUUM | ❌ |
CREATE DATABASE | ❌ |
CREATE INDEX CONCURRENTLY는 프로덕션의 큰 테이블에서 table lock 없이 인덱스를 만드는 유일한 안전한 길이다. 그런데 트랜잭션 안에서 못 돈다.
TypeORM의 해법: transaction: false
export class AddBigIndex1700000000000 implements MigrationInterface {
name = 'AddBigIndex1700000000000';
transaction = false; // ← 명시적으로 트랜잭션 비활성화
public async up(qr: QueryRunner): Promise<void> {
await qr.query(`CREATE INDEX CONCURRENTLY "IDX_..." ON "user" ("email")`);
}
public async down(qr: QueryRunner): Promise<void> {
await qr.query(`DROP INDEX CONCURRENTLY "IDX_..."`);
}
}주의:
transaction: false인 migration은 중간에 실패하면 반쯤 적용된 상태가 남는다. 그래서 각 SQL이 멱등적이거나, 그 자체로 안전해야 한다.CREATE INDEX IF NOT EXISTS처럼.
MySQL은 더 심하다
MySQL은 대부분의 DDL이 트랜잭션에서 implicit commit을 일으킨다. 즉 DDL을 만나는 순간 트랜잭션이 끝난다. MySQL에서는 트랜잭션 안의 DDL 보호가 거의 의미가 없다 — 마이그레이션을 작은 단위로 쪼개는 게 중요하다.
What — 흔한 실패 시나리오와 회복
시나리오 1 — up 도중 SQL 한 줄이 실패
ALTER TABLE "user" ADD COLUMN "email" varchar NOT NULL ← OK
ALTER TABLE "user" ADD CONSTRAINT "UQ_email" UNIQUE ("email") ← FAIL (중복)트랜잭션이 rollback → email 컬럼도 추가 안 된 상태로 되돌아감 → migrations 테이블에도 기록 없음.
회복: 데이터의 중복을 제거하고 다시 migration:run.
시나리오 2 — transaction: false 인 migration 중간 실패
transaction = false;
// 1단계: ALTER TABLE ADD COLUMN ← 성공
// 2단계: CREATE INDEX CONCURRENTLY ← 실패1단계는 적용된 채로 남는다. migrations 테이블에는 기록 없음 → 다음 migration:run이 처음부터 다시 적용 시도 → 1단계가 이미 있어서 실패.
회복: SQL을 멱등적으로 짠다 (IF NOT EXISTS), 또는 수동으로 부분 정리.
시나리오 3 — 손으로 DB를 건드림
가장 무서운 시나리오. migrations 테이블의 row만 지우거나, DB 자체를 직접 ALTER하면 — migrations 테이블의 사실과 실제 DB가 어긋난다.
예방: production DB에는 마이그레이션 외 어떤 ALTER도 직접 하지 않는다. 응급 상황에서 직접 ALTER 했다면 — 바로 migrations 테이블에 대응 row를 INSERT하여 정합성을 맞춘다.
What — migration:show 의 활용
$ npx typeorm migration:show -d src/data-source.ts
[X] AddUserTable1700000000000
[X] AddEmailToUser1700000060000
[ ] AddIndexOnEmail1700001000000 ← 미적용배포 전에 어느 migration이 production에 빠져 있는지 확인. CI에서 fail-fast로 검사할 수도 있다.
흥미로운 이야기
“
migrations테이블을 지우면 마이그레이션 시스템 전체가 깨진다”한 회사의 신입 엔지니어가 production DB를
pg_dump로 복사한 dev 환경에서 *TRUNCATE migrations*를 실행했다 — 모든 테이블 리셋용이라고 생각하고. 다음날 *migration:run*을 돌리는 순간 — 모든 migration이 처음부터 적용되려 했고, *첫 번째CREATE TABLE "user"*에서 이미 존재한다고 실패. 데이터는 안 사라졌지만 그날 하루 모든 환경의 마이그레이션 시스템 신뢰가 무너졌다. 회복은 *손으로INSERT INTO migrations*로 과거 적용 사실을 다시 적는 작업이었다. 교훈:migrations테이블은 production data와 동급으로 보호해야 한다 — 그 테이블이 시간의 증인이기 때문.
Insight — migrations 테이블은 시간의 분기점이다
파일이 있다는 적용됐다가 아니다. 코드와 DB가 서로의 증인이 되어야 — 한쪽만 보고는 어디까지 적용됐는지 알 수 없다. migrations 테이블은 DB가 자기 자신에 대해 보내는 메모 — 사람이 손대지 않을 때만 의미를 갖는다.
한 단락 요약
migration:run은migrations테이블과 파일 시스템을 비교해 미적용 파일만 timestamp 순서로 적용한다. 각 migration은 기본 트랜잭션 안에서 돌고 — *INSERT INTO migrations*도 같은 트랜잭션에 들어가 원자성을 보장한다. up이 실패하면 rollback되어 재시도 가능. 트랜잭션이 안 듣는 DDL(CONCURRENTLY 등)은 *transaction: false*로 비활성화하고 멱등적 SQL로 보호한다.migration:revert는 한 개씩만 되돌린다.migrations테이블은 production data 급으로 보호해야 — 그것이 시간의 증인. 다음:05-production-strategy— 큰 테이블의 스키마 변경을 다운타임 없이 하는 expand-contract.