🗄️ TypeORM6. Migration & Schema02 · 마이그레이션 파일 해부

02 · Migration 파일 해부 — up · down · QueryRunner

질문: TypeORM의 migration 파일은 어떻게 생겼고, 어떻게 동작하나? 한 줄 답: 클래스 한 개 + up/down 두 메서드 + QueryRunner 인자 = 그게 전부다 — 타임스탬프 prefix가 실행 순서를 결정하고, QueryRunner한 트랜잭션 안에서 SQL을 실행한다.


Pyramid Top

마이그레이션 파일은 세 가지 약속으로 구성된다.

  1. 파일명: {timestamp}-{Name}.ts — 타임스탬프가 전 세계 단일 정렬 키다.
  2. 클래스 모양: MigrationInterface를 구현 — up(qr)down(qr) 두 메서드.
  3. QueryRunner: TypeORM이 연결을 잡고 트랜잭션을 연 채로 전달하는 객체 — qr.query()로 raw SQL을 실행한다.

이 문서는 그 세 약속이 왜 그렇게 생겼는지, 그리고 up/down을 어떻게 쓰는지의 패턴을 다룬다.


사고 흐름


Why — 왜 클래스 + up/down + QueryRunner인가

① 클래스인 이유 — 상태메서드 두 개를 한 묶음으로

export class AddEmailToUser1700000000000 implements MigrationInterface {
  name = 'AddEmailToUser1700000000000';
 
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 변경을 *적용*
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    // 변경을 *되돌림*
  }
}

updown반대 방향의 같은 일이다 — 두 함수를 한 묶음으로 두기 위해 클래스를 쓴다. (Rails 마이그레이션도 같은 구조다.)

② 타임스탬프 prefix인 이유 — 전 세계 단일 정렬 키

1700000000000-AddEmailToUser.ts
1700000060000-AddIndexOnEmail.ts
1700001000000-RemoveOldUsername.ts

여러 개발자가 서로 다른 브랜치에서 동시에 migration을 만들면, 둘 다 머지된 시점어느 게 먼저 돌아야 하는가가 결정되어야 한다.

방식문제
정수 시퀀스 (1, 2, 3…)동시 브랜치에서 같은 번호가 생김 → 머지 충돌
타임스탬프 (epoch ms)충돌 거의 없음 — 생성 시각이 다르면 다른 prefix
파일명 (알파벳)의미와 무관한 정렬

TypeORM은 Unix epoch ms를 택했다. 13자리 정수라 1년에 수십억까지 다 받는다.

QueryRunner인 이유 — 트랜잭션을 명시적으로 잡기

public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`ALTER TABLE "user" ADD COLUMN "email" varchar`);
  await queryRunner.query(`CREATE INDEX "IDX_user_email" ON "user" ("email")`);
}

QueryRunner연결 한 개 + 그 위의 트랜잭션 한 개를 나타낸다. TypeORM은 migration을 돌릴 때 자동으로 qr.startTransaction()을 호출하고, up이 끝나면 commit, 예외가 나면 rollback한다.

getRepository(User).save() 같은 ORM 메서드도 안에서 쓸 수 있다queryRunner.manager.getRepository(User)같은 트랜잭션에 묶인다. 단 대부분의 migration은 raw SQL이 더 깔끔하다 — entity 모양이 과거 시점과 다를 수 있기 때문.


How — migration 파일 한 예제 끝까지

시작: 생성 명령

npx typeorm migration:create src/migrations/AddEmailToUser
# → src/migrations/1700000000000-AddEmailToUser.ts 생성 (빈 up/down)

up — 변경 적용

import { MigrationInterface, QueryRunner } from 'typeorm';
 
export class AddEmailToUser1700000000000 implements MigrationInterface {
  name = 'AddEmailToUser1700000000000';
 
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "user" ADD COLUMN "email" varchar(255) NOT NULL DEFAULT ''`,
    );
    await queryRunner.query(
      `CREATE UNIQUE INDEX "IDX_user_email" ON "user" ("email")`,
    );
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_user_email"`);
    await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "email"`);
  }
}

down — 되돌림

down은 up의 거울이다. up이 생성한 순서역순으로 DROP한다. FK는 같은 순서로 안 풀린다 — 항상 역순이라고 외운다.

흔히 잘못 짜는 down

// ❌ 잘못된 down
public async down(queryRunner: QueryRunner): Promise<void> {
  // 비어있음 — "down 안 쓸 거니까"
}

언젠가 프로덕션에서 revert가 필요한 순간이 온다 — 그때 복구 SQL을 그 자리에서 쓰는 건 너무 위험하다. down그 변경을 머지하는 시점에 쓰는 게 가장 정확하다 — 코드와 함께 맥락을 갖고 있기 때문.


What — 패턴별 up/down 모음

변경updown
컬럼 추가ALTER TABLE x ADD COLUMN y ...ALTER TABLE x DROP COLUMN y
컬럼 제거ALTER TABLE x DROP COLUMN yALTER TABLE x ADD COLUMN y ... (이전 타입 정확히)
컬럼 renameALTER TABLE x RENAME COLUMN a TO bALTER TABLE x RENAME COLUMN b TO a
타입 변경ALTER COLUMN y TYPE int USING y::intALTER COLUMN y TYPE varchar USING y::varchar
인덱스 추가CREATE INDEX i ON x (y)DROP INDEX i
FK 추가ADD CONSTRAINT fk ... FOREIGN KEY ...DROP CONSTRAINT fk
테이블 추가CREATE TABLE ...DROP TABLE ...
데이터 마이그레이션UPDATE x SET y = ...원본을 어떻게 복구할까 — 어렵다

데이터 마이그레이션은 down이 불가능한 경우가 많다. 그럴 때는 up에서 백업 테이블을 만들어 두고 down에서 거기서 복구하거나, downthrow new Error('Not reversible')명시적으로 막는다.


What — QueryRunner.manager vs qr.query()

public async up(qr: QueryRunner): Promise<void> {
  // 방식 1: raw SQL
  await qr.query(`UPDATE "user" SET "status" = 'active' WHERE "deleted" = false`);
 
  // 방식 2: ORM 메서드
  await qr.manager.getRepository(User).update(
    { deleted: false },
    { status: 'active' },
  );
}
raw SQL (qr.query)ORM (qr.manager)
시점 안전성과거 시점의 SQL을 그대로 보존entity 모양이 현재 — 과거 migration이 깨질 수 있음
가독성SQL에 익숙하면 좋음TS 안전성
권장DDL과 단순 UPDATE복잡한 비즈니스 로직 데이터 변환

DDL은 항상 raw SQL로 쓴다 — entity 모양이 미래에 바뀌면 과거 migration이 깨진다. 시간의 증인변하지 않는 SQL이어야 한다.


What — 타임스탬프와 왜 직접 만들지 말아야 하나

# ❌ 직접 파일을 만들지 마라
touch src/migrations/0001-AddEmailToUser.ts
 
# ✅ CLI를 써라
npx typeorm migration:create src/migrations/AddEmailToUser

CLI는 현재 시각의 epoch ms를 prefix로 붙인다. 직접 1, 2, 3으로 만들면 다른 개발자와 충돌한다.


What-if — migration 파일을 수정하면?

한 번 commit되어 다른 환경에 적용된 migration은 영원히 수정 금지. 왜?

같은 migration 이름인데 다른 환경에 다른 SQL이 적용된다 — 환경이 영원히 어긋난다. 그래서 수정이 필요하면 새 migration을 추가한다.


흥미로운 이야기

“down() 메서드는 써놓고 거의 안 돌아간다

실제 프로덕션에서 migration:revert를 돌리는 일은 극히 드물다. 일단 적용된 migration은 보통 그대로 두고 다음 migration으로 보정한다. 그런데도 down을 안 써두면 — 그 드문 한 번의 revert가 오는 순간, 복구 SQL을 처음부터 쓰는 일이 된다. *5분 분량의 down()*이 2시간의 incident response를 절약한다. down보험이다 — 거의 안 쓰지만 안 들고 있으면 그날 후회한다. up을 쓸 때 down을 같이 쓰는 습관production-grade engineer의 최소 조건이다.


Insight — migration 파일의 세 약속시간에서 비롯된다

일반 코드는 현재 정답만 갖고 있어도 된다. migration 파일은 시간이라는 차원을 명시적으로 다뤄야 해서 — 순서 키, 왕복 가능성, 원자성이라는 세 가지 추가 약속을 갖는다.


한 단락 요약

마이그레이션 파일은 클래스 한 개 + up(qr)/down(qr) 두 메서드 + 타임스탬프 prefix가 전부다. QueryRunner한 트랜잭션 안에서 raw SQL을 실행하는 도구고, TypeORM이 자동으로 트랜잭션을 열고 닫는다. DDL은 raw SQL로, 데이터 변환은 가끔 qr.manager.getRepository(). down거의 안 쓰지만 안 쓰면 후회하는 보험이다. 한 번 commit된 migration은 영원히 수정 금지 — 환경 간 일관성의 유일한 보장. 다음: 03-migration-generate수동으로 짜는 게 지겨워서 등장한 migration:generate의 메커니즘과 그것이 자동 진실이 아닌 이유.