Published on

ES2024 데코레이터로 TS 타입 추론 깨짐 해결법

Authors

서드파티 DI, 로깅, 캐싱, 권한 체크 같은 횡단 관심사를 데코레이터로 정리하려고 ES2024+ 표준 데코레이터를 도입하면, 예상보다 빨리 TypeScript 타입 추론이 무너지는 순간을 만납니다. 특히 메서드/필드 데코레이터에서 반환 타입이 any 로 퍼지거나, 오버로드가 사라지거나, this 컨텍스트가 깨져서 체이닝이 무의미해지는 문제가 자주 발생합니다.

이 글은 “왜 깨지는지”를 TS 타입 시스템 관점에서 설명하고, 타입을 보존하는 데코레이터 작성 패턴실무에서 덜 고통스러운 적용 전략을 제공합니다.

데코레이터는 런타임 기능이지만, 우리는 컴파일 타임의 타입 추론을 지키고 싶습니다. 핵심은 “데코레이터가 원본 시그니처를 바꾸지 않는 것처럼 타입을 설계”하는 것입니다.

ES2024+ 표준 데코레이터에서 타입이 깨지는 이유

1) 데코레이터가 “다른 함수”를 반환하면 시그니처가 사라짐

표준 데코레이터(새 데코레이터)는 메서드 데코레이터에서 원본 메서드를 다른 함수로 교체할 수 있습니다. 문제는 TS가 그 교체 결과를 완벽히 추론하기 어렵다는 점입니다.

예를 들어 아래처럼 단순 래핑을 하면, 런타임은 정상인데 타입이 (...args: any[]) => any 로 떨어지는 경우가 흔합니다.

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

원인은 간단합니다.

  • value 가 어떤 함수 타입인지(제네릭 파라미터, 오버로드, this 타입 포함)를 TS가 잃기 쉽습니다.
  • 반환한 함수가 any[]any 를 사용하면, 그 순간부터 타입 정보가 복구되지 않습니다.

2) this 파라미터를 잃으면 메서드 호출 타입이 틀어짐

TS에서 메서드는 암묵적으로 this 컨텍스트를 갖습니다. 래핑 함수에서 this 를 제대로 모델링하지 않으면 아래 같은 문제가 생깁니다.

  • 클래스 메서드 내부에서 this.someField 접근은 되는데
  • 데코레이터가 감싼 메서드를 외부에서 호출할 때 this 타입이 any 로 퍼지거나
  • noImplicitThis 환경에서 에러가 발생합니다.

3) 오버로드/리턴 타입 조건부 추론이 데코레이터에서 무너짐

오버로드 시그니처는 TS가 “구현 시그니처”와 “호출 시그니처”를 분리해 관리합니다. 데코레이터가 구현을 교체하면, 보통은 구현 시그니처만 남고 오버로드가 사라집니다.

또한 T extends (...args: infer A) => infer R ? ... 같은 조건부 타입 기반 추론을 쓰는 API는, 데코레이터에서 T 를 보존하지 못하면 연쇄적으로 깨집니다.

기본 전제: TS 설정과 데코레이터 종류를 먼저 정리

현재 생태계에는 데코레이터가 섞여 있습니다.

  • 레거시 데코레이터: experimentalDecorators 기반(구 TS/바벨 스타일)
  • 표준 데코레이터(ES2024+): Stage 3 기반, context 를 받는 형태

프로젝트가 어떤 데코레이터를 쓰는지부터 고정해야 합니다. 혼용하면 타입/트랜스파일 결과가 엉키기 쉽습니다.

tsconfig.json 예시(표준 데코레이터 전제):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "useDefineForClassFields": true
  }
}

주의: 표준 데코레이터는 TS 버전, 번들러, 실행 환경(노드/브라우저) 조합에 따라 폴리필/트랜스폼 전략이 달라집니다. 빌드 파이프라인에서 데코레이터 트랜스폼이 두 번 걸리면 런타임도 타입도 둘 다 망가질 수 있습니다.

해결 1) “함수 시그니처 보존” 제네릭으로 래핑하기

가장 강력한 원칙은 이것입니다.

  • 데코레이터가 메서드를 교체하더라도
  • 반환 타입을 원본 함수 타입 T 그대로 유지하도록 강제한다

아래는 메서드 데코레이터에서 타입을 보존하는 패턴입니다.

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

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

  const wrapped = function (this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> {
    console.log("call", name, args);
    return value.apply(this, args);
  };

  // 핵심: 래핑 결과를 다시 T로 단언해 시그니처를 보존
  return wrapped as unknown as T;
}

포인트는 세 가지입니다.

  • T extends AnyFn 로 “원본 메서드 타입”을 제네릭으로 들고 간다
  • ThisParameterType<T>, Parameters<T>, ReturnType<T> 로 래핑 함수의 타입을 원본과 동일하게 만든다
  • 마지막에 as unknown as T 로 TS가 잃어버린 동일성(identity)을 복구한다

이 패턴은 깔끔하진 않지만, 실무에서 타입 안정성을 유지하는 데 가장 효과적입니다.

오버로드 메서드는 어떻게 하나

오버로드는 데코레이터에서 특히 취약합니다. 가능한 전략은 두 가지입니다.

  1. 오버로드를 줄이고, 단일 시그니처 + 유니온으로 재구성
  2. 오버로드를 유지하되, 데코레이터는 “교체” 대신 “부수효과만” 수행(아래 해결 3 참고)

해결 2) this 를 잃지 않도록 apply 와 타입 유틸을 함께 사용

다음은 this 타입이 중요한 케이스(플루언트 API, 체이닝 등)에서 자주 터지는 예입니다.

class QueryBuilder {
  private parts: string[] = [];

  @Log
  where(expr: string) {
    this.parts.push(`where ${expr}`);
    return this;
  }
}

wherethis 를 반환하는데, 데코레이터가 잘못 작성되면 반환 타입이 QueryBuilder 가 아니라 any 또는 unknown 으로 바뀝니다.

앞의 Log<T> 패턴처럼 ThisParameterType<T> 를 사용하면 this 가 정확히 유지됩니다. 또한 래핑 함수에서 value(...args) 로 호출하면 this 바인딩이 깨질 수 있으니 value.apply(this, args) 를 권장합니다.

해결 3) “교체형” 대신 “초기화/부수효과형” 데코레이터로 설계

타입 추론 문제의 대부분은 “메서드를 다른 함수로 바꿔치기”할 때 발생합니다. 표준 데코레이터는 context.addInitializer 를 제공하므로, 런타임 동작을 바꾸지 않고도 많은 일을 할 수 있습니다.

예를 들어 메서드를 감싸지 않고도, 클래스 초기화 시점에 메타데이터를 등록할 수 있습니다.

const routes = new Map<string, string[]>();

function Route(path: string) {
  return function (_value: Function, context: ClassMethodDecoratorContext) {
    context.addInitializer(function () {
      const cls = this.constructor.name;
      const list = routes.get(cls) ?? [];
      list.push(`${String(context.name)}:${path}`);
      routes.set(cls, list);
    });
  };
}

class UserController {
  @Route("/users")
  list() {
    return ["a", "b"];
  }
}

이 방식은 메서드 시그니처를 건드리지 않으므로 타입 추론이 매우 안정적입니다.

  • 로깅/트레이싱처럼 “호출 시점” 개입이 꼭 필요하면 교체형이 필요하지만
  • 라우팅/검증 스키마 등록/권한 메타데이터 같은 “선언적 정보”는 초기화형이 더 낫습니다

해결 4) 데코레이터 팩토리를 만들 때 반환 타입을 명시

데코레이터를 옵션 기반 팩토리로 만들면, TS가 반환 타입을 넓게 잡는 경우가 있습니다. 가능한 한 반환 타입을 명시해 추론 경로를 단순하게 만드세요.

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

type MethodDec = <T extends AnyFn>(
  value: T,
  context: ClassMethodDecoratorContext
) => T;

function LogWith(prefix: string): MethodDec {
  return function <T extends AnyFn>(value: T, context: ClassMethodDecoratorContext): T {
    const name = `${prefix}${String(context.name)}`;
    const wrapped = function (this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> {
      console.log(name, args);
      return value.apply(this, args);
    };
    return wrapped as unknown as T;
  };
}

여기서 MethodDec 를 따로 빼면, 팀 단위로 데코레이터를 늘려도 타입 품질이 일정하게 유지됩니다.

해결 5) 반환 타입이 Promise 인 경우 Awaited 를 섞지 말고 그대로 보존

비동기 데코레이터를 만들 때 흔히 저지르는 실수는 다음입니다.

  • 래핑 함수에서 async 를 붙여버려서
  • 원래 동기 함수였던 것이 무조건 Promise 로 바뀜

또는

  • 원래 Promise 를 반환하던 함수인데
  • 데코레이터가 Awaited 를 잘못 적용해 리턴 타입이 변형됨

원칙은 “원본 ReturnType<T> 를 절대 바꾸지 않는다” 입니다.

function Timing<T extends AnyFn>(value: T, context: ClassMethodDecoratorContext): T {
  const wrapped = function (this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> {
    const start = performance.now();
    try {
      return value.apply(this, args);
    } finally {
      const end = performance.now();
      console.log(String(context.name), "took", end - start);
    }
  };
  return wrapped as unknown as T;
}

비동기 함수도 try/finally 는 정상 동작하며, async 를 강제하지 않으므로 타입이 유지됩니다.

실무 체크리스트: 타입 추론 깨짐을 줄이는 적용 순서

1) 데코레이터는 “최소 교체” 원칙으로

  • 가능하면 context.addInitializer 로 해결
  • 꼭 필요할 때만 메서드 교체(래핑)

2) 래핑이 필요하면 T 보존 템플릿을 복붙 가능한 유틸로

프로젝트에 아래 같은 유틸을 두면, 팀원이 새로운 데코레이터를 만들 때 실수 확률이 크게 줄어듭니다.

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

export function wrapMethod<T extends AnyMethod>(
  value: T,
  wrapper: (value: T) => (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>
): T {
  return wrapper(value) as unknown as T;
}

// 사용 예
export function Log(value: any, context: ClassMethodDecoratorContext) {
  return wrapMethod(value, (orig) => function (this, ...args) {
    console.log(String(context.name), args);
    return orig.apply(this, args);
  });
}

3) 오버로드가 많다면 데코레이터 적용 범위를 좁히기

오버로드가 복잡한 API 계층(예: SDK public surface)에서는 데코레이터를 직접 붙이기보다, 내부 구현 레이어에서 사용하거나, 데코레이터 대신 고차함수(HOF)로 분리하는 편이 낫습니다.

4) emitDecoratorMetadata 같은 레거시 메타데이터 기대는 분리

레거시 데코레이터 생태계(예: 일부 DI 프레임워크)는 런타임 리플렉션 메타데이터에 기대는 경우가 있습니다. 표준 데코레이터로 갈아타는 과정에서 “타입 추론” 문제와 “런타임 메타데이터” 문제를 한 번에 해결하려고 하면 디버깅 난이도가 급상승합니다.

타입 추론 이슈를 먼저 잡고, 메타데이터는 별도 단계로 분리하는 것을 권합니다.

트러블슈팅: 증상별 빠른 처방

증상 A: 데코레이터 붙이면 반환 타입이 any 가 됨

  • 래핑 함수에 ...args: any[] / : any 를 쓰고 있지 않은지 확인
  • T extends AnyFn + Parameters / ReturnType 패턴으로 바꾸기

증상 B: 메서드 체이닝이 깨지고 thisany 로 변함

  • 래핑 함수의 this 타입을 ThisParameterType<T> 로 지정
  • 호출을 value.apply(this, args) 로 고정

증상 C: 오버로드 시그니처가 사라짐

  • 데코레이터에서 메서드를 교체하면 오버로드 보존이 어렵습니다
  • 가능하면 교체형 대신 초기화형(addInitializer)로 변경
  • 또는 오버로드를 유니온 기반 단일 시그니처로 정리

마무리: 데코레이터는 런타임, 타입은 설계로 지킨다

ES2024+ 표준 데코레이터는 표현력이 좋아졌지만, TS 타입 추론이 “자동으로” 따라와 주지는 않습니다. 특히 메서드 교체형 데코레이터는 타입을 쉽게 잃습니다.

정리하면 실무 해법은 아래로 수렴합니다.

  • 메서드를 바꾸지 않는 방향(context.addInitializer)을 우선 고려
  • 바꿔야 한다면 T 시그니처 보존 래핑 템플릿을 강제
  • this 와 오버로드는 가장 먼저 무너지는 지점이므로 별도 주의

분산 시스템에서 장애 원인을 체계적으로 좁혀가듯, 타입 추론 문제도 “어디서 타입이 넓어졌는지”를 단계적으로 좁혀야 합니다. 이런 접근은 메시지 멱등 처리처럼 복잡한 경계 조건을 다룰 때도 유사하게 적용됩니다: Kafka 중복·역순 메시지, DDD로 멱등 처리하기

또한 빌드/런타임 레이어에서 문제가 섞이면 원인 파악이 어려워지므로, 빌드 에러를 빠르게 격리하는 습관이 중요합니다. 배포 환경에서의 전형적인 장애 진단 흐름은 다음 글도 참고할 만합니다: Kubernetes CrashLoopBackOff 원인 8가지 진단

다음 단계로는, 프로젝트에서 실제로 사용하는 데코레이터(로깅/트랜잭션/캐시/검증) 각각을 위 템플릿으로 리팩터링하고, “교체형이 꼭 필요한가”를 항목별로 재검토해 보세요. 타입 추론 품질이 눈에 띄게 안정될 것입니다.