Published on

ES2024 데코레이터 실전 - TS 타입추론 함정

Authors

서버/프론트 공통으로 “선언적(Declarative)으로 로깅, 검증, 캐싱을 붙이고 싶다”는 요구가 커지면서 데코레이터가 다시 주목받고 있습니다. 이제는 실험적(legacy) 문법이 아니라 ES2024 표준 데코레이터가 자리 잡았고, TypeScript도 이를 지원합니다.

하지만 실전에서 바로 부딪히는 문제가 있습니다. 런타임에서는 분명히 메서드 시그니처가 바뀌었는데, TypeScript 타입은 그대로라서 호출부가 안전하지 않거나, 반대로 타입만 바꿨는데 런타임은 그대로라서 장애가 나는 경우입니다.

이 글은 ES2024 데코레이터 문법을 기반으로, TypeScript에서 특히 자주 발생하는 타입추론 함정을 패턴별로 정리하고, “타입과 런타임을 함께 맞추는” 실전 코드를 제공합니다. 스트리밍/업로드 같은 비동기 경로에서 중단 처리까지 엮이면 더 복잡해지니, 관련 디버깅 맥락은 Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController도 함께 보면 좋습니다.

ES2024 데코레이터 핵심: legacy와 완전히 다르다

ES2024 데코레이터는 TypeScript의 예전 실험적 데코레이터(experimentalDecorators)와 모델이 다릅니다.

  • 표준 데코레이터는 데코레이터 함수가 valuecontext를 받고, 필요하면 대체 구현을 반환합니다.
  • context.addInitializer로 인스턴스/클래스 초기화 타이밍에 코드를 주입할 수 있습니다.
  • 필드 데코레이터는 필드 초기화 함수(initializer)를 감싸는 형태로 동작합니다.

즉, “프로퍼티 디스크립터를 건드리는” 방식이 아니라, 명시적으로 교체하거나 초기화를 감싸는 방식입니다. 이 차이를 이해하지 못하면 타입도 런타임도 쉽게 어긋납니다.

준비: tsconfig와 실행 환경

TypeScript에서 표준 데코레이터를 쓰려면 컴파일 타깃/옵션을 맞춰야 합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "useDefineForClassFields": true,
    "strict": true
  }
}
  • 번들러(예: Vite, Next.js)로 트랜스파일하는 경우가 많아 moduleResolution이 중요합니다.
  • 표준 데코레이터는 emitDecoratorMetadata 같은 레거시 메타데이터 모델과 결이 다릅니다. “리플렉션 기반 DI”를 기대하면 바로 막힙니다.

함정 1: 메서드 데코레이터로 반환 타입을 바꿔도 TS는 모른다

가장 흔한 실수는 “데코레이터로 메서드를 래핑해서 반환 타입을 Promise로 바꿨는데, 타입은 그대로라서 호출부가 깨지는” 케이스입니다.

잘못된 예: 런타임은 Promise인데 타입은 동기

function asyncify(value: Function, context: ClassMethodDecoratorContext) {
  return async function (this: unknown, ...args: unknown[]) {
    return value.apply(this, args);
  };
}

class UserService {
  @asyncify
  getName(id: number) {
    return `user-${id}`;
  }
}

const s = new UserService();
// 런타임: Promise<string>
// 타입: string
const name = s.getName(1);

이 코드는 타입 시스템이 거짓말을 합니다. 호출부는 string으로 믿고 처리하지만, 실제로는 Promise가 나와서 런타임 오류로 이어질 수 있습니다.

해결 패턴: “타입이 바뀌는 데코레이터”는 메서드가 아니라 함수 래퍼로 분리

현실적으로 TypeScript는 “데코레이터 적용 후의 시그니처”를 자동으로 재작성해주지 않습니다. 그래서 반환 타입을 바꾸는 데코레이터는 두 가지 중 하나로 가야 합니다.

  1. 데코레이터는 시그니처를 유지하는 범위(로깅/측정/가드)로만 사용
  2. 시그니처 변경은 명시적 래퍼 함수로 처리

아래는 데코레이터는 “동작만 추가”하고, 시그니처는 유지하는 정석 패턴입니다.

type AnyFn = (...args: any[]) => any;

function trace<T extends AnyFn>(fn: T, context: ClassMethodDecoratorContext) {
  const name = String(context.name);

  return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
    const start = performance.now();
    try {
      return fn.apply(this, args);
    } finally {
      const ms = performance.now() - start;
      console.log(`[trace] ${name} ${ms.toFixed(1)}ms`);
    }
  } as T;
}

class UserService {
  @trace
  getName(id: number) {
    return `user-${id}`;
  }
}

포인트는 Parameters<T> / ReturnType<T>원래 시그니처를 그대로 유지하는 것입니다.

함정 2: this 타입이 사라져서 메서드 내부에서 타입이 무너진다

데코레이터가 메서드를 반환할 때 this 컨텍스트를 잃어버리면, 클래스 내부에서 this.someField 접근이 any 또는 unknown으로 떨어지거나, 반대로 호출 시점에 바인딩 문제가 생깁니다.

안전한 패턴: this 파라미터를 보존하는 시그니처

TypeScript에는 ThisParameterType / OmitThisParameter 같은 유틸이 있습니다. 메서드 래핑에서 this를 보존하려면 아래처럼 작성하는 것이 안전합니다.

type AnyMethod = (this: any, ...args: any[]) => any;

type Wrap<T extends AnyMethod> = (
  this: ThisParameterType<T>,
  ...args: Parameters<OmitThisParameter<T>>
) => ReturnType<T>;

function guard<T extends AnyMethod>(fn: T, context: ClassMethodDecoratorContext) {
  return function (this: ThisParameterType<T>, ...args: Parameters<OmitThisParameter<T>>) {
    if (args.length === 0) throw new Error("missing args");
    return fn.apply(this, args);
  } as Wrap<T> as T;
}

class Counter {
  private n = 0;

  @guard
  inc(this: Counter, by: number) {
    this.n += by;
    return this.n;
  }
}
  • 메서드에 this: Counter를 명시하면, 래퍼도 그 this 타입을 따라갑니다.
  • apply 호출로 런타임 바인딩도 유지합니다.

함정 3: 필드 데코레이터에서 “타입은 number인데 실제 값은 string”이 된다

필드 데코레이터는 특히 위험합니다. 필드 타입은 선언으로 굳어있는데, 데코레이터가 initializer를 바꿔서 다른 타입 값을 주입할 수 있기 때문입니다.

위험한 예: 런타임 변환이 타입에 반영되지 않음

function asString(initialValue: unknown, context: ClassFieldDecoratorContext) {
  return function initializer() {
    return String(initialValue);
  };
}

class Model {
  @asString
  id: number = 123;
}

const m = new Model();
// 타입: number
// 런타임: "123"
console.log(typeof m.id);

이런 코드는 디버깅이 매우 어렵습니다. 특히 직렬화/검증/ORM 계층에서 “분명 number인데 왜 문자열이지?” 같은 이슈로 번집니다.

해결 원칙: 필드 데코레이터는 “동일 타입 유지”만 허용

필드 데코레이터는 가능하면 아래 범위로 제한하세요.

  • 로깅, 트래킹
  • lazy init 캐싱(단, 타입은 유지)
  • 불변성(동결) 같은 런타임 제약

예를 들어 lazy init을 타입 안전하게 하려면 “반환 타입을 원래 타입과 동일하게” 강제합니다.

function lazy<T>(initializerValue: T, context: ClassFieldDecoratorContext) {
  const key = Symbol(String(context.name));

  context.addInitializer(function () {
    // 인스턴스마다 숨은 캐시 슬롯을 만든다
    Object.defineProperty(this, key, {
      configurable: false,
      enumerable: false,
      writable: true,
      value: initializerValue
    });
  });

  return function () {
    return initializerValue;
  };
}

class Config {
  @lazy
  token: string = "default";
}

여기서도 중요한 건 “타입을 바꾸는 변환”을 하지 않는 것입니다.

함정 4: 파라미터 데코레이터가 없어서 검증 로직이 어색해진다

레거시 데코레이터에 익숙하면 @Param() 같은 파라미터 데코레이터를 기대합니다. 하지만 ES2024 표준 데코레이터는 파라미터 데코레이터가 핵심 범위가 아니고(환경/도구에 따라 지원이 다름), 실전에서 “메서드 단위 검증”으로 재구성해야 합니다.

실전 대안: 메서드 데코레이터로 인자 검증을 통합

type AnyFn = (...args: any[]) => any;

function validate(schema: (args: unknown[]) => void) {
  return function <T extends AnyFn>(fn: T, context: ClassMethodDecoratorContext) {
    return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
      schema(args as unknown[]);
      return fn.apply(this, args);
    } as T;
  };
}

const nonEmptyString = (args: unknown[]) => {
  const v = args[0];
  if (typeof v !== "string" || v.length === 0) throw new Error("bad arg");
};

class Api {
  @validate(nonEmptyString)
  hello(name: string) {
    return `hi ${name}`;
  }
}

검증을 “인자별 선언”로 예쁘게 만들고 싶다면, 데코레이터로 무리하기보다 스키마 라이브러리(예: zod)로 함수 래핑을 구성하는 편이 타입/런타임 정합성이 좋습니다.

함정 5: async 메서드 래핑에서 Abort 신호를 누락하면 취소가 안 된다

데코레이터로 재시도/타임아웃/로깅을 붙이다 보면 AbortSignal 전달이 누락되는 순간이 많습니다. 타입추론이 완벽해 보여도, 런타임에서 cancel 경로를 끊어먹는 문제가 생깁니다.

특히 fetch 업로드/스트림처럼 장시간 작업은 “취소가 되느냐”가 안정성에 직결됩니다. 이 주제는 Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController에서 더 깊게 다룹니다.

패턴: 마지막 인자가 options이고 그 안에 signal이 있다고 가정하지 말기

아래는 데코레이터가 임의로 인자를 재구성하지 않고, 단순히 실행 전후 훅만 넣어 AbortSignal 경로를 보존하는 예입니다.

type AnyFn = (...args: any[]) => any;

function withTimeout(ms: number) {
  return function <T extends AnyFn>(fn: T, context: ClassMethodDecoratorContext) {
    return async function (this: unknown, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
      const ac = new AbortController();
      const t = setTimeout(() => ac.abort(new Error("timeout")), ms);

      try {
        // 중요: args를 변형하지 않는다. 변형이 필요하면 명시적 래퍼로 분리.
        const res = await fn.apply(this, args);
        return res as Awaited<ReturnType<T>>;
      } finally {
        clearTimeout(t);
      }
    } as unknown as T;
  };
}

class Downloader {
  @withTimeout(3000)
  async run(url: string) {
    const r = await fetch(url);
    return r.text();
  }
}

여기서도 사실 “반환 타입을 Promise로 강제”하는 문제가 숨어 있습니다. 원래 async 메서드라서 괜찮지만, 동기 메서드에 적용하면 함정 1이 그대로 재현됩니다. 그래서 이런 데코레이터는 async 메서드 전용으로 제한하거나, 타입 레벨에서 async 함수만 받도록 제약을 걸어야 합니다.

type AsyncFn = (...args: any[]) => Promise<any>;

function onlyAsyncTimeout(ms: number) {
  return function <T extends AsyncFn>(fn: T, context: ClassMethodDecoratorContext) {
    return (async function (this: unknown, ...args: Parameters<T>) {
      const t = setTimeout(() => {}, ms);
      try {
        return await fn.apply(this, args);
      } finally {
        clearTimeout(t);
      }
    }) as T;
  };
}

이렇게 하면 “동기 메서드에 잘못 적용”하는 실수를 타입 단계에서 차단할 수 있습니다.

함정 6: 클래스 데코레이터로 생성자를 바꾸면 타입이 과신된다

클래스 데코레이터는 클래스를 다른 클래스로 교체할 수 있습니다. 문제는 TypeScript가 이를 완전히 반영하기 어렵다는 점입니다.

  • 런타임: 생성자/프로토타입이 바뀜
  • 타입: 기존 클래스 타입을 그대로 믿음

안전한 원칙: 클래스 데코레이터는 부작용(등록/메타) 위주로

DI 컨테이너 등록, 라우팅 테이블 등록 같은 “부작용”은 괜찮습니다. 하지만 생성자 시그니처를 바꾸거나 프록시로 감싸는 패턴은 타입/런타임 불일치가 커집니다.

const registry = new Map<string, Function>();

function service(name?: string) {
  return function (value: Function, context: ClassDecoratorContext) {
    const key = name ?? String(context.name);
    registry.set(key, value);
  };
}

@service()
class BillingService {
  charge(amount: number) {
    return { ok: true, amount };
  }
}

이 방식은 타입추론 함정을 거의 만들지 않으면서도 데코레이터의 장점을 살립니다.

실전 체크리스트: 타입추론 함정 피하는 규칙 7

  1. 시그니처를 바꾸는 데코레이터를 만들지 말기: 특히 동기 ReturnTypePromise로 바꾸는 패턴 금지
  2. 래핑 시 Parameters<T> / ReturnType<T>원형 시그니처 보존
  3. 메서드라면 this 타입을 보존(ThisParameterType, OmitThisParameter)하고, 가능하면 메서드에 this: Class를 명시
  4. 필드 데코레이터는 “같은 타입 유지”만 허용하고, 변환은 별도 로직(팩토리/파서)로 분리
  5. async 관련 데코레이터는 AsyncFn 제약으로 “async 메서드 전용”을 타입으로 강제
  6. 인자 재배열/주입은 데코레이터에서 하지 말고, 필요하면 명시적 래퍼 함수로 분리
  7. 데코레이터 체인이 길어질수록 취소/타임아웃/재시도 같은 제어 흐름이 꼬인다. 장애 대응 관점에선 가드레일 설계가 중요하며, 비용/루프 통제 관점은 AutoGPT 무한루프·비용폭주 차단 가드레일 5가지처럼 “제어 흐름을 끊을 장치”를 미리 넣는 사고방식이 도움이 된다

마무리: 데코레이터는 ‘타입을 바꾸는 마법’이 아니다

ES2024 데코레이터는 문법적으로는 깔끔해졌지만, TypeScript에서의 핵심은 여전히 같습니다. 런타임에서 바뀐 것을 타입이 자동으로 따라오지 않는다는 점입니다.

실전에서는 데코레이터를 다음 용도로 제한하면 가장 안전합니다.

  • 로깅/트레이싱/메트릭
  • 권한/인자 검증(단, 시그니처 유지)
  • 등록(registry) 같은 부작용

반대로 “반환 타입/필드 타입을 바꾸는 변환”은 데코레이터로 해결하려고 하면 타입추론 함정에 빠지기 쉽습니다. 그때는 데코레이터가 아니라 명시적 래퍼 함수 + 제네릭 타입으로 모델링하는 편이 장기 유지보수에 유리합니다.