🗄️ TypeORM6. Migration & Schema04 · migration:run & revert

04 · migration:run & revert — 적용과 되돌림의 메커니즘

질문: migration:run어떻게 migration 파일을 골라 실행하고, 어떻게 거꾸로 되돌리나? 한 줄 답: migrations 테이블이 어디까지 적용됐는지를 기록하고, 각 migration은 기본적으로 transaction 안에서 돈다 — DDL이 트랜잭션을 깨면 반쯤 적용된 상태가 남는다.


Pyramid Top

migration:run세 단계다.

  1. migrations 테이블에서 마지막으로 적용된 timestamp 조회.
  2. 파일 시스템에서 그보다 큰 timestamp 파일만 순서대로 수집.
  3. 각 파일을 한 트랜잭션에 넣고 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 (중복)

트랜잭션이 rollbackemail 컬럼도 추가 안 된 상태로 되돌아감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:runmigrations 테이블파일 시스템을 비교해 미적용 파일만 timestamp 순서로 적용한다. 각 migration은 기본 트랜잭션 안에서 돌고 — *INSERT INTO migrations*도 같은 트랜잭션에 들어가 원자성을 보장한다. up이 실패하면 rollback되어 재시도 가능. 트랜잭션이 안 듣는 DDL(CONCURRENTLY 등)은 *transaction: false*로 비활성화하고 멱등적 SQL로 보호한다. migration:revert한 개씩만 되돌린다. migrations 테이블은 production data 급으로 보호해야 — 그것이 시간의 증인. 다음: 05-production-strategy큰 테이블의 스키마 변경을 다운타임 없이 하는 expand-contract.