🗄️ TypeORM6. Migration & Schema03 · migration:generate 메커니즘

03 · migration:generate 메커니즘과 왜 자동 진실이 아닌가

질문: typeorm migration:generateentity만 보면 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.executeQueriesSchemaBuilder.log()두 모델을 비교한다. 비교 결과는 변경 목록이다:

변경 종류예시
addedColumnsemail: varchar(255)
removedColumnsname (entity에 없음)
changedColumnsage: int → bigint
addedForeignKeysFK_user_team
removedIndicesIDX_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: username

generate가 만드는 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:generatemigration:create
DB 연결 필요?아니오
SQL 자동 생성 (초안)아니오 (빈 파일)
Entity와 동기아니오 — 수동
데이터 변환거의 못함명시적으로 쓴다
권장 사용DDL 위주 변경데이터 마이그레이션, 복잡한 보정

두 명령을 상호 보완적으로 쓴다 — DDL은 generate, 데이터 변환은 create.


What — generate 결과를 어떻게 보정하나

보정 절차

  1. generate로 초안 생성.
  2. 생성된 SQL 문장을 한 줄씩 읽는다.
  3. rename 의도가 있었던 컬럼DROP + ADDRENAME COLUMN으로 바꾼다.
  4. FK/Index 이름해시면, entity에 명시적 이름을 박고 다시 generate.
  5. enum 변경이라면, down()완전성을 확인.
  6. down()역순으로 정확한지 검토.
  7. 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:generateRails의 마이그레이션을 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:generateentity 메타와 DB 스키마의 diff추측해서 SQL로 뱉는 초안 생성기다. 컬럼 renameDROP + ADD로 나오고, enum 변경방언별로 깨지며, FK/Index 이름환경 간에 불일치한다. generate 결과는 진실이 아니라 초안이고, 사람이 git diff처럼 읽고 보정한 commit이 진실이다. 보정 규칙은 단순하다 — 의도를 SQL에 명시적으로 담아라. down()까지 직접 검토하고, FK/Index 이름은 entity에 명시하여 환경 일관성을 확보한다. 다음: 04-migration-run-and-revertgenerate된 그 파일이 어떻게 실행되고 되돌아가는가.