02 · Migration 파일 해부 — up · down · QueryRunner
질문: TypeORM의 migration 파일은 어떻게 생겼고, 어떻게 동작하나? 한 줄 답: 클래스 한 개 +
up/down두 메서드 +QueryRunner인자 = 그게 전부다 — 타임스탬프 prefix가 실행 순서를 결정하고,QueryRunner가 한 트랜잭션 안에서 SQL을 실행한다.
Pyramid Top
마이그레이션 파일은 세 가지 약속으로 구성된다.
- 파일명:
{timestamp}-{Name}.ts— 타임스탬프가 전 세계 단일 정렬 키다. - 클래스 모양:
MigrationInterface를 구현 —up(qr)과down(qr)두 메서드. 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> {
// 변경을 *되돌림*
}
}up과 down은 반대 방향의 같은 일이다 — 두 함수를 한 묶음으로 두기 위해 클래스를 쓴다. (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 모음
| 변경 | up | down |
|---|---|---|
| 컬럼 추가 | ALTER TABLE x ADD COLUMN y ... | ALTER TABLE x DROP COLUMN y |
| 컬럼 제거 | ALTER TABLE x DROP COLUMN y | ALTER TABLE x ADD COLUMN y ... (이전 타입 정확히) |
| 컬럼 rename | ALTER TABLE x RENAME COLUMN a TO b | ALTER TABLE x RENAME COLUMN b TO a |
| 타입 변경 | ALTER COLUMN y TYPE int USING y::int | ALTER 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에서 거기서 복구하거나,
down에throw 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/AddEmailToUserCLI는 현재 시각의 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의 메커니즘과 그것이 자동 진실이 아닌 이유.