04 — Decorator and Metadata
이 문서가 답하는 질문:
@Entity@Column한 줄로 SQL이 만들어지는 마법의 정체는 무엇인가?reflect-metadata를 빼면 왜 모든 게 깨지는가? 한 줄 답 (Pyramid Top): “데코레이터는 그냥 함수다 —Reflect.defineMetadata로 클래스 prototype에 메타 객체를 박고, TypeScript가emitDecoratorMetadata로 타입 정보까지 자동으로 끼워준다. TypeORM은 그 메타를 런타임에 읽어 SQL을 만든다.”
한 문장 답 (Pyramid Top)
‘데코레이터 ORM’이라는 표현 안에는 세 개의 분리된 메커니즘이 들어 있다 — ① 데코레이터 함수 (
@Column()= 함수 호출), ②Reflect.defineMetadata(메타를 prototype에 박는 polyfill), ③emitDecoratorMetadata(TS 컴파일러가 타입을 자동으로 metadata에 넣어주는 옵션). 셋 다 동시에 켜져 있어야 TypeORM이 동작한다 — 하나라도 빠지면 조용히 모든 엔티티가 망가진다.
챕터 지도 (Mermaid)
세 가지가 동시에 성립해야 *한 줄의 @Column()*이 의미를 갖는다.
Why — 왜 이 메커니즘이 중요한가
TypeORM에서 가장 흔한 “왜 안 돼?” 의 90%는 데코레이터-메타데이터 파이프라인 어딘가가 조용히 끊겨 있을 때다.
| 증상 | 원인 |
|---|---|
Cannot read property 'metadata' of undefined | reflect-metadata import 누락 |
| 엔티티는 인식되는데 모든 컬럼이 사라짐 | emitDecoratorMetadata: false |
| SWC/esbuild로 옮기니 전체가 깨짐 | 트랜스파일러가 데코레이터 메타를 지원 안 함 |
| 같은 코드가 테스트 환경에서만 안 됨 | jest 트랜스파일러가 메타 옵션 OFF |
@Column({ type: 'varchar' })은 되는데 type 없이 쓰면 안 됨 | 타입 자동 추론은 design:type 메타에 의존 |
이 모두 같은 한 줄이 원인이다 — “데코레이터·polyfill·emitDecoratorMetadata 셋이 모두 켜져 있는가”.
How — 어떻게 동작하나
1) 데코레이터는 함수다
// 이건 마법처럼 보이지만
@Column({ length: 100 })
name!: string;
// 본질은 이렇다 — 함수 호출
Column({ length: 100 })(User.prototype, "name");@Column(...)는 데코레이터 팩토리다 — 옵션을 받아 데코레이터 함수를 반환한다. 그 데코레이터 함수가 *target(클래스 prototype)*과 *propertyKey("name")*를 받아 실행된다.
// TypeORM 내부의 @Column 함수의 (단순화) 정체
function Column(options?: ColumnOptions): PropertyDecorator {
return function (target: Object, propertyKey: string) {
// 핵심: 전역 저장소에 줄 하나를 추가
getMetadataArgsStorage().columns.push({
target: target.constructor,
propertyName: propertyKey,
options,
});
};
}마법은 없다 — 함수가 실행될 때 글로벌 큐에 객체 하나를 push할 뿐이다.
2) reflect-metadata가 하는 일
Reflect.metadata API는 원래 JS 표준이 아니다 — TC39 metadata reflection 제안서 Stage 0/실험에 머물러 있다. 그래서 polyfill 라이브러리가 필요하다.
// reflect-metadata가 제공하는 핵심 API
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey?);
Reflect.getMetadata(metadataKey, target, propertyKey?);이 두 함수가 전역 Reflect 객체에 추가되어 클래스/메서드/속성에 임의의 메타를 박을 수 있게 된다. 박힌 곳은 그 객체의 prototype에 숨겨진 메타 슬롯이다.
import "reflect-metadata"; // ← 이 한 줄이 *반드시* 필요
class User {
@Reflect.metadata("custom:role", "admin")
doSomething() {}
}
Reflect.getMetadata("custom:role", User.prototype, "doSomething"); // "admin"TypeORM 진입점은 항상 import "reflect-metadata"로 시작해야 한다.
3) emitDecoratorMetadata — TS가 타입을 자동으로 넣어준다
이게 진짜 마법의 정체다. tsconfig.json에 다음 두 줄이 모두 있어야 한다.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}emitDecoratorMetadata: true일 때, TypeScript는 데코레이터가 붙은 모든 속성에 대해 세 개의 메타키를 자동으로 추가한다.
| 메타키 | 의미 | 예시 |
|---|---|---|
design:type | 속성/매개변수의 타입 | String, Number, Array, 사용자 클래스 |
design:paramtypes | 메서드 매개변수들의 타입 배열 | [String, Number] |
design:returntype | 메서드의 반환 타입 | Promise |
// 원본
class User {
@Column()
name!: string;
}
// TS가 emitDecoratorMetadata로 만든 트랜스파일 결과 (단순화)
class User {
name!: string;
}
__decorate([
Column(),
__metadata("design:type", String) // ← TS가 자동으로 넣어줌
], User.prototype, "name");TypeORM의 @Column()이 옵션 없이 호출돼도 SQL 타입을 알 수 있는 이유가 여기 있다 — Reflect.getMetadata("design:type", target, "name")을 호출하면 String이 나오기 때문.
4) MetadataArgsStorage — 전역 메타 큐
데코레이터가 실행될 때마다 메타가 쌓이는 곳이 MetadataArgsStorage다. 전역 싱글톤이고, 클래스가 로드되는 순서대로 줄이 추가된다.
// (단순화) 저장소의 구조
class MetadataArgsStorage {
tables: TableMetadataArgs[] = []; // @Entity
columns: ColumnMetadataArgs[] = []; // @Column
relations: RelationMetadataArgs[] = []; // @ManyToOne 등
indices: IndexMetadataArgs[] = [];
// ...
}DataSource.initialize()가 호출되는 순간, 이 저장소를 읽어 EntityMetadata 그래프를 만든다 — 그게 SQL 생성의 진짜 출처다.
5) 트랜스파일러별 미묘한 차이
같은 TS 코드를 어떤 트랜스파일러로 빌드하느냐에 따라 메타 동작이 달라진다.
| 트랜스파일러 | experimentalDecorators | emitDecoratorMetadata | TypeORM 호환성 |
|---|---|---|---|
TypeScript (tsc) | 옵션 | 옵션 | 완전 호환 |
| SWC | jsc.parser.decorators: true | jsc.transform.decoratorMetadata: true | 호환 (옵션 명시 시) |
| esbuild | 부분 지원 (기본은 새 spec 따름) | 지원 안 함 | 호환 불가 — tsc로 별도 빌드 필요 |
| Babel | @babel/plugin-proposal-decorators (legacy) | babel-plugin-transform-typescript-metadata 필요 | 호환 (플러그인 조합 시) |
| Vite + esbuild | 위와 동일 | 동일 | 불가 — Vite는 @swc/core 등 별도 필요 |
esbuild·Vite로 직접 빌드하면 TypeORM이 동작하지 않는다는 사실이 처음 만나는 사람을 가장 자주 좌절시키는 지점이다.
What — 구체 사양·예시
데코레이터 종류별 메타키
TypeORM이 박는 메타키는 내부 구현 상세지만, 디버깅 시 알아두면 유용하다.
| TypeORM 데코레이터 | MetadataArgsStorage 위치 | 박는 메타 |
|---|---|---|
@Entity() | tables | target, name, options |
@Column() | columns | target, propertyName, options, mode |
@PrimaryGeneratedColumn() | columns | mode: “generated” |
@ManyToOne() | relations | type, target, relationType |
@OneToMany() | relations | inverse property |
@JoinColumn() | joinColumns | foreign key 컬럼 매핑 |
@Index() | indices | 컬럼 + 옵션 |
@BeforeInsert() | entityListeners | 라이프사이클 후크 |
타입 자동 추론의 한계
emitDecoratorMetadata가 자동으로 박는 design:type은 런타임 클래스만 식별한다. 다음 경우엔 추론 실패한다.
// 1) 제네릭/유니온/인터페이스 — design:type이 Object
@Column()
data!: { foo: string }; // → 메타에는 Object만, 타입은 추론 못함
// 2) 명시적 옵션 필요
@Column({ type: "jsonb" })
data!: { foo: string };
// 3) 배열의 원소 타입 — design:type이 Array
@OneToMany(() => Post, p => p.user) // ← () => Post 명시 필요
posts!: Post[];
// 4) 순환 참조 — 함수로 lazy하게 감싸야 함
@ManyToOne(() => User, u => u.posts) // () => User로 늦은 평가
user!: User;순환 참조 때문에 () => User 형태가 강제된다 — 클래스 로드 순서를 무관하게 만들기 위한 지연 평가다.
한 데코레이터의 풀 라이프사이클
// 단계 1 — 모듈 로드
import { Entity, Column } from "typeorm";
import "reflect-metadata";
// 단계 2 — 클래스 정의 평가
@Entity("users")
class User {
@Column({ length: 50 })
name!: string;
}
// ↑ 이 시점에 데코레이터 함수들이 실행됨
// MetadataArgsStorage.tables.push({ target: User, name: "users" })
// MetadataArgsStorage.columns.push({ target: User, propertyName: "name", options: { length: 50 } })
// 단계 3 — DataSource 초기화
const ds = new DataSource({ entities: [User], /* ... */ });
await ds.initialize();
// ↑ MetadataArgsStorage를 읽어 EntityMetadata 그래프 빌드
// 단계 4 — 실제 사용
const repo = ds.getRepository(User);
await repo.find();
// ↑ EntityMetadata로 SELECT SQL 생성같은 클래스가 두 신원을 가진다 — 컴파일 타임엔 타입, 런타임엔 메타 컨테이너.
What-if — 잘못 설정하면
1) import "reflect-metadata"를 깜빡하면
→ Reflect.getMetadata is not a function. 모든 엔티티가 조용히 빈 객체가 된다.
대응: 진입점(main.ts, app.ts 등)의 최상단에 import "reflect-metadata". NestJS는 자체적으로 import하지만 다른 환경은 직접 해야 함.
2) emitDecoratorMetadata: false로 빌드하면
→ design:type이 박히지 않아 컬럼 타입이 모두 unknown. TypeORM이 수동으로 옵션 type을 명시하지 않은 한 모든 컬럼이 깨진다.
대응: tsconfig.json에 두 옵션 모두 true. CI에서 tsc --showConfig로 확인.
3) esbuild/Vite로 직접 백엔드 빌드하면
→ esbuild는 emitDecoratorMetadata를 원천적으로 지원하지 않는다. 모든 컬럼이 깨진다.
대응: tsc로 별도 빌드하거나, SWC(@swc/core)로 옮기고 decoratorMetadata: true 설정. 또는 unplugin-swc/vite-plugin-swc.
4) @Column 대신 @Column(괄호 없이) 쓰면
→ 데코레이터 팩토리가 아니라 데코레이터 함수 자체를 호출하려 해서 TypeScript 컴파일 에러 또는 런타임 에러.
대응: TypeORM 데코레이터는 모두 팩토리 — 옵션이 없어도 () 필수.
5) 순환 참조에 () =>를 안 쓰면
→ Cannot read properties of undefined (reading 'name') 같은 에러. 클래스 로드 시점에 상대 클래스가 아직 정의 전이라.
대응: 모든 관계 데코레이터의 첫 인자는 항상 () => Entity 형태로.
6) 트랜스파일러를 섞으면
→ 같은 monorepo의 한 패키지는 tsc, 다른 패키지는 SWC면 메타가 부분만 박히는 상태가 생길 수 있다.
대응: 트랜스파일러를 팀 단위로 통일. 옵션을 .swcrc / tsconfig.json 둘 다 검토.
Insight — 흥미로운 이야기
”TypeScript가 ‘실험적’이라 부르는 이유”
experimentalDecorators라는 옵션 이름은 2014년부터 그대로다. TypeScript 팀은 TC39 데코레이터 spec이 안정화되기 전에 Angular 2를 지원하기 위해 Stage 1 spec을 구현했다 — 그것이 지금까지 experimental 표시를 단 채 살아 있는 이유. 9년이 지난 지금, 새 데코레이터 spec(Stage 3, 2022)이 TypeScript 5.0에 비실험으로 들어왔지만 — emitDecoratorMetadata는 새 spec에 정의되지 않는다. 즉 TypeORM이 동작하려면 여전히 ‘experimental’ 옵션을 켜야 한다. 마법 같은 매끈함 뒤에 지난 9년의 spec 정체가 통째로 들어 있다.
”Reflect.metadata의 영원한 Stage 0”
Reflect.metadata는 TC39에 공식 제안서로 올라간 적이 없다. 처음 등장한 곳은 Microsoft가 2015년 TypeScript와 함께 발표한 비공식 제안서 문서였고, 이후 그 문서를 polyfill한 reflect-metadata 라이브러리가 사실상 표준이 됐다. 결과 — 모든 데코레이터 ORM(TypeORM·MikroORM·NestJS DI)은 공식 표준이 아닌 문서 한 장에 의존하고 있다. TC39 데코레이터가 표준화되면 이 의존 자체가 깨질 수도 있다.
”왜 NestJS 마이그레이션이 어려운가”
NestJS의 의존성 주입, TypeORM의 ORM 메타, class-validator의 검증 — 셋 다 같은 reflect-metadata를 공유한다. 그래서 NestJS 프로젝트는 데코레이터 메타에 깊이 묶여 있다. 트랜스파일러를 SWC로 옮기거나, esbuild로 빌드 시간 줄이려고 시도할 때 세 라이브러리가 동시에 깨질 수 있다. 이 연쇄 결합이 NestJS 진영에서 번들러 이주가 어려운 핵심 이유다.
요약 + 다이어그램
TypeORM의 ‘데코레이터 마법’은 셋의 묶음이다 — 데코레이터 함수, reflect-metadata polyfill, TS emitDecoratorMetadata. 셋 다 동시에 켜져 있어야 동작하고, 하나라도 빠지면 조용히 모든 것이 깨진다. esbuild·Vite로 백엔드를 빌드하려는 시도가 자주 실패하는 이유가 여기 있다.
다음 문서:
05-mental-model.mdx— 이 모든 메커니즘을 한 줄의 멘탈모델로 어떻게 잡을까?