TypeScript 5.0에서 도입된 TC39 Stage 3 데코레이터의 원리, API 구조, 실전 패턴을 다루고, 기존 실험적 데코레이터와의 차이를 분석합니다.
2장에서 const 타입 파라미터와 satisfies로 타입 추론을 제어하는 방법을 배웠습니다. 이 장에서는 TypeScript 5.0의 또 다른 핵심 변경인 TC39 표준 데코레이터를 다룹니다. 데코레이터는 클래스와 그 멤버를 선언적으로 수정하는 강력한 메타프로그래밍 도구입니다.
TypeScript에는 현재 두 가지 데코레이터 시스템이 공존합니다.
| 구분 | 실험적 데코레이터 | TC39 표준 데코레이터 |
|---|---|---|
| 도입 시기 | TypeScript 1.5 (2015) | TypeScript 5.0 (2023) |
| 활성화 | experimentalDecorators: true | 기본 활성 (5.0+) |
| 표준 | TypeScript 독자 제안 | TC39 Stage 3 |
| 런타임 호환 | TypeScript 전용 | JavaScript 엔진 네이티브 지원 예정 |
| 매개변수 | (target, key, descriptor) | (target, context) |
| 적용 대상 | 클래스, 메서드, 접근자, 속성, 매개변수 | 클래스, 메서드, 접근자, 필드, getter/setter, auto-accessor |
tsconfig.json에 experimentalDecorators: true가 설정되어 있으면 기존 실험적 데코레이터가 활성화됩니다. TC39 표준 데코레이터를 사용하려면 이 옵션을 제거하거나 false로 설정해야 합니다. 두 시스템은 동시에 사용할 수 없습니다.
TC39 데코레이터는 두 개의 인수를 받습니다.
type Decorator = (
target: any, // 데코레이팅되는 값 (메서드 함수, 클래스 등)
context: DecoratorContext // 데코레이터에 대한 메타 정보
) => any | void; // 대체할 값을 반환하거나 voidcontext 객체는 데코레이터가 적용된 위치에 따라 다른 프로퍼티를 제공합니다.
interface DecoratorContext {
kind: "class" | "method" | "getter" | "setter" | "field" | "accessor";
name: string | symbol;
static: boolean; // 정적 멤버 여부
private: boolean; // 프라이빗 멤버 여부
access: { // 접근자 함수
get?(): unknown;
set?(value: unknown): void;
has?(value: unknown): boolean;
};
addInitializer(fn: () => void): void; // 초기화 로직 추가
metadata: Record<string | symbol, unknown>; // 5.2+ 메타데이터
}클래스 데코레이터는 클래스 자체를 수정하거나 대체할 수 있습니다.
function sealed(target: Function, context: ClassDecoratorContext) {
Object.seal(target);
Object.seal(target.prototype);
}
function withTimestamp<T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext
) {
return class extends target {
createdAt = new Date();
};
}
@sealed
@withTimestamp
class User {
constructor(public name: string) {}
}
const user = new User("Kreath");
console.log((user as any).createdAt); // Date 인스턴스addInitializer를 사용하면 클래스 정의 완료 후 실행할 로직을 등록할 수 있습니다.
function register(target: Function, context: ClassDecoratorContext) {
context.addInitializer(function () {
// 클래스 정의가 완료된 후 실행
globalRegistry.set(context.name, target);
});
}메서드 데코레이터는 가장 자주 사용되는 유형으로, 메서드의 동작을 감싸거나 수정합니다.
function log(
target: Function,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacement(this: any, ...args: any[]) {
console.log(`[${methodName}] called with:`, args);
const result = target.call(this, ...args);
console.log(`[${methodName}] returned:`, result);
return result;
}
return replacement;
}
class MathService {
@log
multiply(a: number, b: number) {
return a * b;
}
}
const math = new MathService();
math.multiply(3, 4);
// [multiply] called with: [3, 4]
// [multiply] returned: 12function debounce(ms: number) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
let timer: ReturnType<typeof setTimeout>;
function replacement(this: any, ...args: any[]) {
clearTimeout(timer);
timer = setTimeout(() => target.apply(this, args), ms);
}
return replacement;
};
}
class SearchController {
@debounce(300)
onInput(query: string) {
console.log(`Searching for: ${query}`);
}
}데코레이터 팩토리(인수를 받는 데코레이터)는 데코레이터를 반환하는 함수입니다. @debounce(300)에서 debounce(300)이 실제 데코레이터를 반환합니다.
필드 데코레이터는 필드의 초기값을 변환할 수 있습니다.
function uppercase(
target: undefined, // 필드 데코레이터에서 target은 항상 undefined
context: ClassFieldDecoratorContext
) {
return function (initialValue: string) {
return initialValue.toUpperCase();
};
}
function range(min: number, max: number) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (initialValue: number) {
return Math.max(min, Math.min(max, initialValue));
};
};
}
class Settings {
@uppercase
locale = "en-us";
@range(0, 100)
volume = 150; // 100으로 클램핑됨
}
const settings = new Settings();
console.log(settings.locale); // "EN-US"
console.log(settings.volume); // 100TC39 표준 데코레이터에서 새로 도입된 accessor 키워드는 getter/setter를 자동 생성합니다. 이를 통해 필드의 읽기/쓰기를 가로챌 수 있습니다.
function observable(
target: ClassAccessorDecoratorTarget<any, any>,
context: ClassAccessorDecoratorContext
) {
return {
get(this: any) {
return target.get.call(this);
},
set(this: any, value: any) {
const oldValue = target.get.call(this);
target.set.call(this, value);
if (oldValue !== value) {
console.log(`${String(context.name)} changed: ${oldValue} -> ${value}`);
}
},
init(initialValue: any) {
return initialValue;
},
};
}
class Store {
@observable
accessor count = 0;
@observable
accessor name = "untitled";
}
const store = new Store();
store.count = 5; // "count changed: 0 -> 5"
store.name = "my-store"; // "name changed: untitled -> my-store"TypeScript 5.2에서 추가된 데코레이터 메타데이터를 사용하면, 데코레이터가 클래스에 메타데이터를 부착하고 이를 런타임에 읽을 수 있습니다.
const VALIDATORS = Symbol("validators");
function validate(schema: { type: string; min?: number; max?: number }) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
// 메타데이터에 검증 규칙 저장
const existing = (context.metadata[VALIDATORS] as any[]) || [];
existing.push({ field: context.name, ...schema });
context.metadata[VALIDATORS] = existing;
};
}
class UserDTO {
@validate({ type: "string", min: 1, max: 50 })
name = "";
@validate({ type: "number", min: 0, max: 150 })
age = 0;
@validate({ type: "string", min: 5 })
email = "";
}
// 런타임에 메타데이터 읽기
const metadata = UserDTO[Symbol.metadata];
const validators = metadata?.[VALIDATORS];
console.log(validators);
// [
// { field: "name", type: "string", min: 1, max: 50 },
// { field: "age", type: "number", min: 0, max: 150 },
// { field: "email", type: "string", min: 5 }
// ]메타데이터는 Symbol.metadata를 통해 클래스에 부착됩니다. 이를 활용하면 DI(의존성 주입), ORM 매핑, 검증 등의 프레임워크를 구축할 수 있습니다.
데코레이터와 메타데이터를 결합하면 간단한 DI 컨테이너를 구축할 수 있습니다.
const INJECTABLE = Symbol("injectable");
const container = new Map<string, any>();
function injectable(
target: new (...args: any[]) => any,
context: ClassDecoratorContext
) {
const name = String(context.name);
context.addInitializer(function () {
container.set(name, new target());
});
}
function inject(serviceName: string) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
return function () {
return container.get(serviceName);
};
};
}
@injectable
class Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
@injectable
class UserService {
@inject("Logger")
private logger!: Logger;
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
}
}여러 데코레이터를 하나의 대상에 적용할 때, 실행 순서는 아래에서 위로(오른쪽에서 왼쪽으로) 진행됩니다. 이는 수학의 함수 합성 f(g(x))와 같습니다.
function first(target: Function, context: ClassMethodDecoratorContext) {
console.log("first 평가");
return function (this: any, ...args: any[]) {
console.log("first 실행");
return target.call(this, ...args);
};
}
function second(target: Function, context: ClassMethodDecoratorContext) {
console.log("second 평가");
return function (this: any, ...args: any[]) {
console.log("second 실행");
return target.call(this, ...args);
};
}
class Example {
@first
@second
greet() {
return "hello";
}
}
// 평가(데코레이터 함수 호출) 순서: second → first
// 실행(래핑된 함수 호출) 순서: first → second → 원본주요 차이점을 정리하면 다음과 같습니다.
// === 실험적 데코레이터 ===
function logLegacy(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey}`);
return original.apply(this, args);
};
}
// === TC39 표준 데코레이터 ===
function logStandard(
target: Function,
context: ClassMethodDecoratorContext
) {
const name = String(context.name);
return function (this: any, ...args: any[]) {
console.log(`Calling ${name}`);
return target.call(this, ...args);
};
}핵심 차이는 다음과 같습니다.
propertyKey 대신 context.name, 정적/프라이빗 여부 등 풍부한 정보 제공@Body(), @Param() 등은 별도의 전략이 필요합니다NestJS, Angular 등 실험적 데코레이터에 깊이 의존하는 프레임워크는 아직 TC39 표준으로의 전환이 진행 중입니다. 프레임워크의 공식 마이그레이션 가이드를 확인하고 전환하세요.
TC39 표준 데코레이터는 TypeScript의 메타프로그래밍을 JavaScript 표준 위에 올려놓는 중요한 변화입니다. 클래스, 메서드, 필드, auto-accessor에 적용할 수 있으며, context 객체를 통해 풍부한 메타 정보에 접근할 수 있습니다. 데코레이터 메타데이터(5.2+)와 결합하면 DI 컨테이너, ORM, 검증 시스템 등 강력한 프레임워크 기반을 구축할 수 있습니다.
다음 장에서는 TypeScript 5.2에서 도입된 using 선언과 명시적 리소스 관리를 살펴봅니다. 파일 핸들, 데이터베이스 연결 등의 리소스를 안전하게 해제하는 새로운 패턴을 배웁니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
TypeScript 5.2에서 도입된 using 선언과 Symbol.dispose를 활용한 명시적 리소스 관리 패턴을 실전 예제와 함께 심층 분석합니다.
TypeScript의 타입 추론을 정밀하게 제어하는 두 가지 핵심 도구인 const 타입 파라미터와 satisfies 연산자의 원리, 차이점, 실전 활용 패턴을 다룹니다.
TypeScript 조건부 타입의 원리, 분배적 조건부 타입, infer 키워드와의 조합, 그리고 실전 활용 패턴을 깊이 있게 다룹니다.