03 · migration:generate 메커니즘과 왜 자동 진실이 아닌가
질문:
typeorm migration:generate는 entity만 보면 migration 파일을 만들어준다 — 그게 정답인가? 한 줄 답: 아니다 —generate는 entity와 현재 DB 스키마의 diff를 추측해서 SQL로 뱉는 초안 생성기다. rename·enum·FK·index 이름 등에서 틀린 결정을 자주 한다. 사람이 git diff처럼 읽고 보정한 commit이 진실이다.
Pyramid Top
migration:generate는 두 스냅샷의 diff를 만든다 — *entity 메타데이터(미래)*와 현재 DB 스키마(과거). 그 diff를 SQL로 번역한다.
이 메커니즘은 대부분의 단순 변경에서 잘 동작한다. 그런데 의미를 모르는 자동 diff라서 — 컬럼 rename은 DROP + ADD로 (synchronize와 동일한 함정), enum 변경은 방언별로 깨지고, FK 이름이 매번 다르게 만들어진다.
이 문서는 내부적으로 무엇이 일어나는지, 언제 잘못된 SQL이 나오는지, 어떻게 보정하는지를 다룬다.
사고 흐름
Why — 내부적으로 무엇이 일어나나
① Entity → 메타데이터 수집
TypeORM은 entity 클래스를 import하고, 데코레이터가 박아둔 메타데이터를 getMetadataArgsStorage()에서 꺼낸다. 결과는 컬럼 목록 + 타입 + 제약조건 + 관계의 완전한 객체 그래프다.
// 메타 예시 (단순화)
{
name: 'user',
columns: [
{ propertyName: 'id', type: 'int', isPrimary: true },
{ propertyName: 'email', type: 'varchar', length: 255, isUnique: true },
],
foreignKeys: [...],
indices: [...],
}② DB → information_schema 조회
PostgreSQL의 경우:
SELECT column_name, data_type, character_maximum_length, is_nullable
FROM information_schema.columns
WHERE table_name = 'user';
SELECT constraint_name, ... FROM information_schema.table_constraints WHERE ...;
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'user';TypeORM은 같은 정보를 DB가 보고하는 모양으로 모은다.
③ SchemaBuilder가 diff를 계산
PostgresQueryRunner.executeQueries나 SchemaBuilder.log()가 두 모델을 비교한다. 비교 결과는 변경 목록이다:
| 변경 종류 | 예시 |
|---|---|
addedColumns | email: varchar(255) |
removedColumns | name (entity에 없음) |
changedColumns | age: int → bigint |
addedForeignKeys | FK_user_team |
removedIndices | IDX_user_oldname |
④ SQL 직렬화
각 변경을 방언별 SQL 문자열로 만든다.
// PostgreSQL의 경우
`ALTER TABLE "user" ADD COLUMN "email" character varying(255) NOT NULL`방언이 다르면 완전히 다른 문자열이 나온다. MySQL은 varchar, PostgreSQL은 character varying처럼.
⑤ 파일로 출력
export class AddEmail1700000000000 implements MigrationInterface {
name = 'AddEmail1700000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" ADD COLUMN ...`);
// ...
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 역순 SQL
}
}down도 자동 생성된다 — 다만 역순으로 거꾸로 뒤집은 단순 SQL이다.
How — 명령어 모음
# 1. data-source.ts가 entity·migration 경로를 알고 있어야 함
# {
# entities: ['src/entities/*.ts'],
# migrations: ['src/migrations/*.ts'],
# }
# 2. generate (실행되는 DB가 있어야 함 — 그래야 diff 가능)
npx typeorm-ts-node-commonjs migration:generate \
src/migrations/AddEmail \
-d src/data-source.ts
# 3. 빈 파일을 만들고 싶을 때 (수동 작성)
npx typeorm-ts-node-commonjs migration:create \
src/migrations/CustomDataFix
# 4. generate 결과를 PR로 commit → 사람이 검토
generate는 실제 DB가 필요하다 — entity와 현재 스키마를 비교하기 위해. CI에서 돌리려면 DB가 이전 migration까지 적용된 상태여야 한다.
What — generate가 틀리는 대표 케이스
케이스 1 — 컬럼 rename
// Before: name
// After: usernamegenerate가 만드는 SQL:
ALTER TABLE "user" DROP COLUMN "name";
ALTER TABLE "user" ADD COLUMN "username" varchar NOT NULL;사람의 의도:
ALTER TABLE "user" RENAME COLUMN "name" TO "username";generate된 SQL을 그대로 commit하면 모든 데이터를 잃는다. 반드시 수정한다.
케이스 2 — Enum 추가/제거
@Column({ type: 'enum', enum: ['active', 'inactive', 'banned'] })
status!: string;generate가 만드는 SQL (PostgreSQL):
-- 새 enum 만들고
CREATE TYPE "user_status_enum_new" AS ENUM ('active', 'inactive', 'banned');
-- 임시 변환
ALTER TABLE "user" ALTER COLUMN "status" TYPE "user_status_enum_new"
USING "status"::text::"user_status_enum_new";
-- 옛 enum 삭제
DROP TYPE "user_status_enum";
-- 새 이름으로 rename
ALTER TYPE "user_status_enum_new" RENAME TO "user_status_enum";복잡하다. 게다가 잘 만들어진 generate는 데이터를 보존하지만, enum value를 제거하는 경우에는 기존 데이터가 cast 실패로 마이그레이션 자체가 실패한다.
enum은 추가만 하고 제거는 피하라 — 마이그레이션의 흔한 격언.
케이스 3 — FK 이름
TypeORM은 FK 이름을 해시로 자동 생성한다 — FK_5a3b.... Entity를 다른 환경에서 generate하면 같은 FK가 다른 이름으로 나올 수 있다 — 그러면 diff에 같은 FK가 두 번 나타나며 깨진다.
해결: @JoinColumn({ foreignKeyConstraintName: 'FK_user_team' })로 명시.
케이스 4 — Index 이름
@Index()만 쓰면 해시 이름이 자동 부여된다. 같은 이유로 환경 간 불일치가 생긴다. @Index('IDX_user_email')로 명시하라.
케이스 5 — default: 'now()' 같은 함수형 디폴트
@CreateDateColumn() createdAt!: Date;방언에 따라 함수 이름이 다르다 — PostgreSQL은 now(), MySQL은 CURRENT_TIMESTAMP. generate가 현재 방언으로 SQL을 만들지만, 방언이 바뀐 환경에서 그 SQL이 깨진다. migrations는 방언과 묶여 있다 — 한 프로젝트가 MySQL과 PostgreSQL 둘 다를 지원하려면 방언별 migration 폴더를 두거나, *raw SQL을 분기해야 한다.
What — generate vs create 비교
| 축 | migration:generate | migration:create |
|---|---|---|
| DB 연결 필요? | 예 | 아니오 |
| SQL 자동 생성 | 예 (초안) | 아니오 (빈 파일) |
| Entity와 동기 | 예 | 아니오 — 수동 |
| 데이터 변환 | 거의 못함 | 명시적으로 쓴다 |
| 권장 사용 | DDL 위주 변경 | 데이터 마이그레이션, 복잡한 보정 |
두 명령을 상호 보완적으로 쓴다 — DDL은
generate, 데이터 변환은create.
What — generate 결과를 어떻게 보정하나
보정 절차
generate로 초안 생성.- 생성된 SQL 문장을 한 줄씩 읽는다.
- rename 의도가 있었던 컬럼은
DROP + ADD→RENAME COLUMN으로 바꾼다. - FK/Index 이름이 해시면, entity에 명시적 이름을 박고 다시 generate.
- enum 변경이라면,
down()의 완전성을 확인. down()이 역순으로 정확한지 검토.- PR로 올린다 — 리뷰어가 SQL을 읽는다.
좋은 보정의 예
// 보정 전 (generate 결과)
public async up(qr: QueryRunner): Promise<void> {
await qr.query(`ALTER TABLE "user" DROP COLUMN "name"`);
await qr.query(`ALTER TABLE "user" ADD COLUMN "username" varchar NOT NULL DEFAULT ''`);
}
// 보정 후
public async up(qr: QueryRunner): Promise<void> {
// *의도: name 컬럼을 username으로 rename — 데이터 보존*
await qr.query(`ALTER TABLE "user" RENAME COLUMN "name" TO "username"`);
}
public async down(qr: QueryRunner): Promise<void> {
await qr.query(`ALTER TABLE "user" RENAME COLUMN "username" TO "name"`);
}보정의 핵심은 의도를 SQL에 담는 것이다 —
generate는 의도를 모른다, 사람만 안다.
What-if — generate 결과를 그대로 commit하면
| 케이스 | 결과 |
|---|---|
| 컬럼 rename | 모든 데이터 손실 |
| enum value 제거 | 기존 row의 cast 실패 → migration 자체가 fail |
| 환경 간 FK 이름 불일치 | staging은 적용됐는데 production은 실패 |
| 함수형 default가 다른 방언 | DB가 다르면 깨짐 |
| down이 비어있음 | revert 불가능 |
흥미로운 이야기
“
migration:generate는 Rails의 마이그레이션을 ORM이 자동으로 적을 수 있을까에서 출발했다”Rails의 마이그레이션은 언제나 사람이 적었다 —
add_column :users, :email, :string같이 DSL로. TypeORM은 데코레이터에 이미 모든 정보가 있다고 보고, entity와 DB의 diff를 자동으로 적자는 길을 갔다. 처음엔 기적처럼 보였다. 그런데 컬럼 rename이라는 가장 흔한 변경 한 줄이 비가역 데이터 손실을 만든다는 사실이 프로덕션 사고로 알려지면서 — *“generate는 도와주지만 책임지지 않는다”*는 암묵적 규칙이 자리 잡았다. Prisma의prisma migrate dev도 같은 함정을 안고 있고, rename은 명시적 명령으로만 가능하다는 더 안전한 default로 갔다. TypeORM의generate는 역사적으로 너무 영리한 자동화가 해롭게 동작한 사례다 — 그 교훈은 “diff는 의미를 모른다”.
Insight — generate는 복사·붙여넣기 도우미다
generate는 코드 스니펫 자동 완성과 비슷한 도구다. 큰 도움이 되지만 최종 책임은 사람이다. generate를 100% 신뢰하는 것은 Copilot이 제안한 코드를 그대로 머지하는 것과 같다.
한 단락 요약
migration:generate는 entity 메타와 DB 스키마의 diff를 추측해서 SQL로 뱉는 초안 생성기다. 컬럼 rename은 DROP + ADD로 나오고, enum 변경은 방언별로 깨지며, FK/Index 이름이 환경 간에 불일치한다.generate결과는 진실이 아니라 초안이고, 사람이 git diff처럼 읽고 보정한 commit이 진실이다. 보정 규칙은 단순하다 — 의도를 SQL에 명시적으로 담아라.down()까지 직접 검토하고, FK/Index 이름은 entity에 명시하여 환경 일관성을 확보한다. 다음:04-migration-run-and-revert— generate된 그 파일이 어떻게 실행되고 되돌아가는가.