TypeScript 5 데코레이터 완전 가이드: 메타데이터부터 의존성 주입까지

프론트엔드

TypeScript데코레이터메타데이터의존성 주입AOP

이 글은 누구를 위한 것인가

  • 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와 함께 사용할 수 없으므로 마이그레이션 시 파일 단위로 전환해야 한다.