이 글은 누구를 위한 것인가
- TypeScript 5의 표준 데코레이터(Stage 3)로 마이그레이션하려는 팀
- 데코레이터로 DI 컨테이너나 유효성 검사 프레임워크를 구현하려는 개발자
- AOP(관점 지향 프로그래밍) 패턴을 TypeScript에 적용하려는 팀
들어가며
TypeScript 5는 TC39 Stage 3 표준 데코레이터를 지원한다. experimentalDecorators: true 없이 사용 가능하며, 이전 레거시 데코레이터와 동작이 다르다. 새 데코레이터는 더 예측 가능하고 타입 안전하다.
이 글은 bluefoxdev.kr의 TypeScript 5 데코레이터 가이드 를 참고하여 작성했습니다.
1. TypeScript 5 데코레이터 개요
[레거시 vs 표준 데코레이터]
레거시 (experimentalDecorators: true):
실행 순서: 아래에서 위
target: 클래스 생성자 또는 프로토타입
descriptor: PropertyDescriptor
반환값: 대체 descriptor (복잡)
표준 (TypeScript 5, Stage 3):
실행 순서: 위에서 아래 (클래스 데코레이터는 마지막)
context 객체 제공 (kind, name, addInitializer 등)
Decorator Metadata API 지원
반환값: 대체 값 또는 undefined
[데코레이터 종류와 context.kind]
@ClassDecorator → kind: 'class'
@MethodDecorator → kind: 'method'
@FieldDecorator → kind: 'field'
@AccessorDecorator → kind: 'accessor' (auto-accessor)
@GetterDecorator → kind: 'getter'
@SetterDecorator → kind: 'setter'
[Decorator Metadata]
Symbol.metadata로 클래스에 메타데이터 첨부
상속 시 부모 메타데이터 자동 복사
런타임에 클래스 구조 반영(reflection) 가능
2. TypeScript 5 데코레이터 구현
// tsconfig.json
// { "compilerOptions": { "target": "ES2022" } }
// experimentalDecorators 불필요!
// 1. 메서드 데코레이터 - 로깅
function log(target: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function(this: unknown, ...args: unknown[]) {
console.log(`[${methodName}] 호출`, { args });
const result = (target as Function).apply(this, args);
console.log(`[${methodName}] 완료`, { result });
return result;
};
}
// 2. 메서드 데코레이터 - 캐싱 (TTL)
function cache(ttlMs: number) {
return function(target: Function, context: ClassMethodDecoratorContext) {
const cacheMap = new Map<string, { value: unknown; expiry: number }>();
return function(this: unknown, ...args: unknown[]) {
const key = JSON.stringify(args);
const cached = cacheMap.get(key);
if (cached && Date.now() < cached.expiry) {
return cached.value;
}
const value = (target as Function).apply(this, args);
cacheMap.set(key, { value, expiry: Date.now() + ttlMs });
return value;
};
};
}
// 3. 접근자 데코레이터 - 유효성 검사
function range(min: number, max: number) {
return function(target: unknown, context: ClassAccessorDecoratorContext) {
return {
set(this: unknown, value: number) {
if (value < min || value > max) {
throw new RangeError(`${String(context.name)}: ${value}는 ${min}~${max} 범위를 벗어남`);
}
(context as any).setFunction?.call(this, value);
},
};
};
}
class UserService {
@log
async findUser(id: string) {
return { id, name: 'Alice' };
}
@cache(5000) // 5초 캐시
getRecommendations(userId: string) {
// 비싼 계산
return ['item1', 'item2'];
}
}
// Decorator Metadata API - 의존성 주입 컨테이너
const INJECT_METADATA = Symbol('inject');
// @Injectable: 클래스를 DI 컨테이너에 등록
function Injectable(target: new (...args: unknown[]) => unknown, context: ClassDecoratorContext) {
context.metadata[INJECT_METADATA] = {
token: context.name,
dependencies: (target as any).__dependencies ?? [],
};
}
// @Inject: 생성자 파라미터 의존성 표시
function Inject(token: string) {
return function(target: new (...args: unknown[]) => unknown, context: ClassDecoratorContext) {
if (!target.__dependencies) {
(target as any).__dependencies = [];
}
(target as any).__dependencies.push(token);
};
}
class DIContainer {
private registry = new Map<string, new (...args: unknown[]) => unknown>();
private instances = new Map<string, unknown>();
register(token: string, cls: new (...args: unknown[]) => unknown) {
this.registry.set(token, cls);
}
resolve<T>(token: string): T {
if (this.instances.has(token)) {
return this.instances.get(token) as T;
}
const Cls = this.registry.get(token);
if (!Cls) throw new Error(`'${token}' 등록되지 않음`);
// 메타데이터에서 의존성 읽기
const metadata = (Cls as any)[Symbol.metadata]?.[INJECT_METADATA];
const deps = metadata?.dependencies ?? [];
const resolvedDeps = deps.map((dep: string) => this.resolve(dep));
const instance = new Cls(...resolvedDeps);
this.instances.set(token, instance);
return instance as T;
}
}
// 사용 예시
@Injectable
class DatabaseService {
query(sql: string) { return []; }
}
@Injectable
class UserRepository {
constructor(private db: DatabaseService) {}
findById(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = '${id}'`);
}
}
const container = new DIContainer();
container.register('DatabaseService', DatabaseService);
container.register('UserRepository', UserRepository);
const repo = container.resolve<UserRepository>('UserRepository');
// 유효성 검사 데코레이터 시스템
const VALIDATORS = Symbol('validators');
type Validator = (value: unknown) => string | null;
function validate(validator: Validator) {
return function(_: unknown, context: ClassFieldDecoratorContext) {
context.addInitializer(function(this: any) {
const originalSet = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(this),
context.name as string
)?.set;
// 메타데이터에 validator 등록
if (!this[VALIDATORS]) this[VALIDATORS] = new Map();
const validators: Validator[] = this[VALIDATORS].get(context.name) ?? [];
validators.push(validator);
this[VALIDATORS].set(context.name, validators);
});
};
}
function validateObject(obj: Record<string, unknown>): Record<string, string[]> {
const validators: Map<string, Validator[]> = (obj as any)[VALIDATORS] ?? new Map();
const errors: Record<string, string[]> = {};
for (const [field, fieldValidators] of validators) {
const value = obj[field];
const fieldErrors = fieldValidators
.map(v => v(value))
.filter((e): e is string => e !== null);
if (fieldErrors.length > 0) {
errors[field] = fieldErrors;
}
}
return errors;
}
// 재사용 가능한 validator 팩토리
const required = () => validate((v) =>
v === null || v === undefined || v === '' ? '필수 항목입니다' : null
);
const minLength = (min: number) => validate((v) =>
typeof v === 'string' && v.length < min ? `최소 ${min}자 이상` : null
);
const isEmail = () => validate((v) =>
typeof v === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
? '유효하지 않은 이메일' : null
);
class SignupForm {
@required()
@minLength(2)
name: string = '';
@required()
@isEmail()
email: string = '';
@required()
@minLength(8)
password: string = '';
}
const form = new SignupForm();
form.name = 'A';
form.email = 'not-an-email';
// errors: { name: ['최소 2자 이상'], email: ['유효하지 않은 이메일'], password: ['필수 항목입니다'] }
console.log(validateObject(form as any));
마무리
TypeScript 5 표준 데코레이터는 context 객체와 Symbol.metadata로 이전보다 훨씬 예측 가능하다. context.addInitializer()는 클래스 인스턴스 생성 시 실행될 초기화 로직을 등록해 DI나 유효성 검사 시스템의 기반이 된다. 레거시 experimentalDecorators와 함께 사용할 수 없으므로 마이그레이션 시 파일 단위로 전환해야 한다.