Published on

TypeScript 5 데코레이터 완전 정복 - 차이와 마이그레이션

Authors

서버 프레임워크나 DI 컨테이너, ORM을 쓰다 보면 데코레이터는 사실상 핵심 문법처럼 느껴집니다. 하지만 TypeScript의 데코레이터는 오랫동안 experimentalDecorators 기반의 “실험(legacy) 문법”이었고, TypeScript 5부터는 ECMAScript 표준 데코레이터(이하 “표준 데코레이터”)를 지원하면서 상황이 크게 바뀌었습니다.

이 글은 다음을 목표로 합니다.

  • TypeScript 5 표준 데코레이터가 무엇인지
  • 기존 실험 데코레이터와 정확히 무엇이 다른지
  • 코드베이스를 어떻게 단계적으로 마이그레이션할지
  • 실전에서 자주 부딪히는 함정과 우회 패턴

추가로, TypeScript 설정을 건드리다 보면 컴파일 옵션 간 상호작용 때문에 예상치 못한 타입 에러가 튀어나오기도 합니다. 관련해서는 TS 5.5 noUncheckedIndexedAccess 에러 해결 가이드도 함께 보면 도움이 됩니다.

1. 배경: “실험”에서 “표준”으로

TypeScript의 기존 데코레이터는 TC39(ECMAScript 표준화)에서 논의되던 초기 제안과 유사했지만, 최종 표준과는 다른 방향으로 굳어졌습니다. 대표적으로 다음이 달랐습니다.

  • 호출 시그니처가 다름
  • descriptor 기반의 조작이 가능했음
  • 메타데이터(emitDecoratorMetadata) 같은 TypeScript 고유 기능에 의존하는 생태계가 컸음

TypeScript 5는 표준 데코레이터를 지원하여, 장기적으로는 JavaScript 표준과 같은 모델로 수렴합니다. 다만 “표준 데코레이터로 자동 업그레이드”가 되는 것은 아니고, 라이브러리도 아직 과도기에 있습니다.

2. 가장 중요한 차이점 한눈에 보기

2.1 데코레이터 함수 시그니처

기존 실험 데코레이터는 대체로 아래 형태였습니다.

  • 클래스: (target) => void
  • 메서드/접근자: (target, key, descriptor) => void
  • 프로퍼티: (target, key) => void
  • 파라미터: (target, key, index) => void

표준 데코레이터는 “값(value)과 컨텍스트(context)”를 받습니다.

  • 클래스: (value, context) => value | void
  • 메서드/필드/접근자 등: (value, context) => value | void

여기서 context는 데코레이션 대상의 종류, 이름, static 여부 등을 담고 있으며, 초기화 훅을 등록할 수 있습니다.

2.2 descriptor 기반 조작의 부재

실험 데코레이터에서는 descriptor.value를 바꾸거나 enumerable 같은 플래그를 수정하는 패턴이 흔했습니다.

표준 데코레이터에서는 PropertyDescriptor를 직접 받지 않습니다. 대신 “원래 값(value)을 감싸서 새 값을 반환”하는 방식이 기본입니다.

즉, 메서드 래핑은 쉬워지지만, 디스크립터 플래그를 직접 만지는 스타일은 재설계가 필요합니다.

2.3 필드 데코레이터와 초기화

실험 데코레이터에서 “프로퍼티 데코레이터”는 런타임에서 값을 바꾸기 애매했고, 주로 메타데이터만 붙였습니다.

표준 데코레이터는 context.addInitializer를 통해 인스턴스 생성 시점에 초기화 로직을 주입할 수 있습니다. 이 점이 DI, 검증, 로깅 같은 횡단 관심사를 구현할 때 매우 중요합니다.

2.4 메타데이터 생태계

  • 실험 데코레이터: reflect-metadataemitDecoratorMetadata 조합이 널리 쓰임
  • 표준 데코레이터: 메타데이터는 표준에 포함되지 않았고, 별도 제안 또는 라이브러리 패턴으로 다룸

즉, NestJS 같은 “런타임 타입 메타데이터”에 강하게 의존하는 프레임워크는 표준 데코레이터로의 전환 시 전략이 필요합니다.

3. TypeScript 설정: 무엇을 켜야 하나

3.1 실험 데코레이터(legacy) 설정

레거시를 유지하려면 보통 아래가 필요합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

emitDecoratorMetadata는 TypeScript 고유 기능이라, 표준 데코레이터 전환 시 가장 먼저 재검토 대상이 됩니다.

3.2 표준 데코레이터 설정

TypeScript 5에서 표준 데코레이터를 쓰려면, 기본적으로 최신 target과 함께 동작합니다. 또한 런타임 변환이 필요한 환경이라면 번들러나 트랜스파일러가 표준 데코레이터 변환을 지원하는지 확인해야 합니다.

프로젝트가 Babel이나 SWC를 쓴다면 “데코레이터 모드”가 레거시인지 표준인지가 매우 중요합니다. 레거시 모드로 변환하면서 TypeScript는 표준 시그니처라고 믿는 상황이 생기면, 런타임이 바로 깨집니다.

체크리스트는 다음과 같습니다.

  • TypeScript 컴파일이 데코레이터를 어떻게 내보내는지
  • Babel 또는 SWC가 데코레이터를 어떤 모드로 변환하는지
  • 테스트 환경(jest, vitest)이 동일한 변환 파이프라인을 쓰는지

CI에서 변환 옵션이 서로 달라 “로컬 OK, CI 실패”가 나면 원인 파악이 꽤 어렵습니다. 캐시와 산출물 문제까지 겹치면 더 악화되는데, 이런 경우 GitLab CI 캐시 꼬임 - 빌드 완전 초기화 가이드가 도움이 될 수 있습니다.

4. 코드로 이해하는 차이

4.1 메서드 로깅 데코레이터: 레거시 버전

// legacy
export function LogLegacy(
  target: any,
  key: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log("call", key, args);
    return original.apply(this, args);
  };

  return descriptor;
}

class Service {
  @LogLegacy
  run(x: number) {
    return x * 2;
  }
}

핵심은 descriptor.value를 교체하는 방식입니다.

4.2 메서드 로깅 데코레이터: 표준 버전

표준 데코레이터에서는 “메서드 함수 자체”를 받아서 새 함수로 감싼 뒤 반환합니다.

// standard
export function Log() {
  return function (
    value: Function,
    context: ClassMethodDecoratorContext
  ) {
    const name = String(context.name);

    return function (this: any, ...args: any[]) {
      console.log("call", name, args);
      return value.apply(this, args);
    };
  };
}

class Service {
  @Log()
  run(x: number) {
    return x * 2;
  }
}

여기서 중요한 점은 다음입니다.

  • 표준에서는 데코레이터 팩토리(@Log()) 형태가 일반적으로 더 안전합니다.
  • context.namestring | symbol일 수 있으니 String(...) 변환을 습관화하는 편이 좋습니다.

5. 필드 데코레이터 마이그레이션 패턴

실험 데코레이터의 프로퍼티 데코레이터는 보통 “메타데이터만 기록”했습니다.

표준에서는 addInitializer로 인스턴스 초기화 시점에 값을 조정할 수 있습니다.

예를 들어, 특정 필드를 생성 시 자동으로 트림하고 싶다고 합시다.

export function Trim() {
  return function (
    _value: undefined,
    context: ClassFieldDecoratorContext
  ) {
    if (context.static) {
      throw new Error("Trim cannot be used on static field");
    }

    context.addInitializer(function () {
      const key = context.name;
      const current = (this as any)[key];
      if (typeof current === "string") {
        (this as any)[key] = current.trim();
      }
    });
  };
}

class User {
  @Trim()
  name = "  alice  ";
}

주의할 점은 context.namesymbol일 수 있으므로, 인덱싱은 (this as any)[key] 같은 형태가 됩니다.

6. 단계별 마이그레이션 전략

대규모 코드베이스에서 “한 번에 전환”은 리스크가 큽니다. 아래 순서가 현실적입니다.

6.1 1단계: 데코레이터 사용 실태 파악

  • 어떤 종류를 쓰는가: 클래스, 메서드, 필드, 접근자, 파라미터
  • descriptor 플래그 조작을 하는가
  • reflect-metadata와 런타임 타입 메타데이터에 의존하는가

특히 파라미터 데코레이터는 표준 데코레이터로의 전환 시 설계 변경이 필요할 수 있어, 우선순위를 높게 두고 점검해야 합니다.

6.2 2단계: “래핑 가능한 데코레이터”부터 전환

메서드 로깅, 측정, 트레이싱처럼 “함수를 감싸는 형태”는 표준으로 옮기기 쉽습니다.

  • 레거시: descriptor.value = ...
  • 표준: return function (...args) { ... value.apply(...) }

이 단계에서 중요한 것은 테스트입니다. 특히 비동기 메서드, 예외 발생, this 바인딩이 깨지지 않는지 확인해야 합니다.

6.3 3단계: 필드/초기화 로직을 addInitializer로 재구성

기존에 생성자에서 하던 작업을 데코레이터로 옮길 수 있지만, 다음을 명확히 해야 합니다.

  • 실행 시점: 인스턴스 생성 중
  • 접근 가능 범위: this 가능
  • 순서: 여러 데코레이터가 있을 때 초기화 순서가 의도대로인지

6.4 4단계: 메타데이터 의존 제거 또는 대체

emitDecoratorMetadata 기반으로 타입을 런타임에 읽는 패턴은 표준 데코레이터로 옮길 때 가장 큰 장벽입니다.

대체 전략은 보통 다음 중 하나입니다.

  • 런타임 타입 추론을 포기하고 명시적 토큰/스키마를 사용
  • 별도 메타데이터 저장소를 만들고 데코레이터에서 직접 기록
  • 프레임워크가 표준 데코레이터를 지원할 때까지 레거시 유지

DI라면 아래처럼 “명시적 토큰”으로 방향을 바꾸는 식입니다.

const TOKENS = {
  Logger: Symbol("Logger"),
};

export function Inject(token: symbol) {
  return function (
    _value: undefined,
    context: ClassFieldDecoratorContext
  ) {
    context.addInitializer(function () {
      (this as any)[context.name] = container.resolve(token);
    });
  };
}

declare const container: {
  resolve: (t: symbol) => any;
};

class App {
  @Inject(TOKENS.Logger)
  logger!: { info: (msg: string) => void };
}

7. 자주 터지는 함정과 디버깅 포인트

7.1 트랜스파일러 모드 불일치

TypeScript는 표준 시그니처로 작성했는데, Babel 또는 SWC가 레거시 데코레이터로 변환하면 런타임 인자가 전혀 다르게 들어옵니다.

증상은 보통 다음과 같습니다.

  • contextundefined로 들어옴
  • context.addInitializer 호출 시 예외
  • 메서드 이름이 기대와 다르게 나옴

이 경우 “한 군데”만 고쳐서는 해결이 안 되고, 빌드 체인의 데코레이터 모드를 통일해야 합니다.

7.2 this 바인딩 깨짐

표준 데코레이터에서 메서드를 감쌀 때, 반드시 function을 써서 동적 this를 보존하거나 value.apply(this, args) 패턴을 써야 합니다.

아래처럼 화살표 함수로 감싸면 this가 렉시컬로 고정되어 문제가 생길 수 있습니다.

// 안티패턴 예시
return (...args: any[]) => value.apply(this, args);

7.3 심볼 이름 처리

context.namesymbol일 수 있습니다. 로깅, 메타데이터 키 구성, 인덱싱에서 문자열만 가정하면 깨집니다.

  • 출력은 String(context.name)
  • 인덱싱은 (obj as any)[context.name]

7.4 타입 시스템과 데코레이터의 간극

표준 데코레이터는 반환값으로 “새로운 함수/값”을 돌려줄 수 있어 강력하지만, 타입 선언을 잘못하면 호출 시그니처가 흐트러집니다.

가능하면 value에 정확한 제네릭 시그니처를 부여하고, 반환 함수도 동일 시그니처를 유지하세요. 단, 제네릭 표기에서 부등호 문자가 필요한 경우 MDX 환경에서는 반드시 인라인 코드로 감싸야 합니다. 예를 들어 제네릭 T를 쓰는 타입은 `T`처럼 표기하고, 실제 코드는 코드 블록 안에서만 사용하세요.

8. 마이그레이션 체크리스트

  • 데코레이터 변환 주체(TypeScript, Babel, SWC)와 모드(레거시, 표준) 통일
  • descriptor 조작 데코레이터는 “값 반환 래핑”으로 재작성
  • 필드 데코레이터는 addInitializer 기반으로 재설계
  • emitDecoratorMetadata 의존 여부 파악 후 제거 또는 대체
  • 테스트에서 비동기, 예외, this 바인딩 케이스 포함
  • 라이브러리(ORM, DI, validation)가 표준 데코레이터를 지원하는지 확인

9. 결론

TypeScript 5 표준 데코레이터는 “표준 JavaScript로 수렴”한다는 점에서 장기적으로 이득이 큽니다. 하지만 기존 실험 데코레이터와는 호출 모델이 확연히 달라서, 단순 옵션 변경만으로는 마이그레이션이 끝나지 않습니다.

가장 안전한 접근은 다음입니다.

  • 메서드 래핑 계열부터 표준으로 전환
  • 필드 초기화는 addInitializer로 옮기기
  • 런타임 타입 메타데이터 의존은 명시적 토큰 또는 별도 메타데이터 저장소로 단계적 축소

이 과정을 거치면, 프레임워크 의존도를 낮추면서도 데코레이터 기반 설계를 지속할 수 있고, 향후 표준 생태계 변화에도 대응력이 좋아집니다.