Published on

TS 5.x 데코레이터 적용 시 Unable to resolve signature 해결

Authors

서드파티 라이브러리(NestJS, TypeORM, class-validator 등)나 사내 공통 프레임워크에서 데코레이터를 쓰다 보면, TS 5.x로 올린 뒤 아래 류의 에러가 갑자기 터지는 경우가 많습니다.

  • Unable to resolve signature of method decorator when called as an expression.
  • Unable to resolve signature of property decorator when called as an expression.
  • No overload matches this call.

표면적으로는 “데코레이터 함수의 타입이 맞지 않는다”는 이야기인데, 실제 원인은 보통 2가지 축에서 발생합니다.

  1. TS 5.x에서 데코레이터가 표준(ECMAScript) 데코레이터 중심으로 재정의되며, 기존 레거시(Experimental) 데코레이터 타입/동작과 충돌
  2. 라이브러리의 데코레이터 선언(.d.ts)이 레거시 시그니처로 되어 있는데, 프로젝트 설정은 표준 데코레이터로 해석(또는 반대)

이 글에서는 에러를 재현한 뒤, 어떤 시그니처가 기대되는지, 그리고 가장 흔한 해결 패턴을 코드와 설정으로 정리합니다.

에러를 만드는 최소 재현 예제

다음처럼 “레거시 방식” 데코레이터를 직접 만들어 메서드에 붙인다고 해보겠습니다.

// legacy-style decorator
export function Log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log("call", propertyKey, args);
    return original.apply(this, args);
  };
}

class A {
  @Log
  hello(name: string) {
    return `hi ${name}`;
  }
}

TS 5.x 환경에서 데코레이터 설정이 표준 쪽으로 잡히면, 위 코드는 컴파일 단계에서 Unable to resolve signature... 류의 메시지로 막히기 쉽습니다. 이유는 간단합니다.

  • 레거시 메서드 데코레이터는 (target, key, descriptor)를 받습니다.
  • 표준 메서드 데코레이터는 (value, context)를 받습니다.

즉, 타입 시스템이 “이 데코레이터는 호출될 때 인자가 2개여야 하는데 3개짜리 함수가 왔다” 또는 그 반대로 판단하게 됩니다.

TS 5.x 데코레이터: 레거시 vs 표준 핵심 차이

1) 레거시(Experimental) 데코레이터 시그니처

레거시 데코레이터는 TS가 오랫동안 제공해온 형태이며, 주로 아래 시그니처를 씁니다.

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

이 방식은 descriptor.value를 바꿔치기 하는 패턴이 흔합니다.

2) 표준(ECMAScript) 데코레이터 시그니처

TS 5.x에서 본격적으로 들어온 표준 데코레이터는 “컨텍스트 객체”를 받습니다.

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

대표적으로 메서드는 대략 이런 형태입니다.

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

class A {
  @LogStd
  hello(name: string) {
    return `hi ${name}`;
  }
}

여기서 중요한 점은 descriptor가 아니라 context를 통해 메타데이터에 접근하고, 래핑은 “새 함수 반환”으로 하는 쪽이 자연스럽다는 것입니다.

Unable to resolve signature가 나는 대표 원인 4가지

원인 1) 프로젝트 설정과 라이브러리 데코레이터 타입이 서로 다름

가장 흔합니다.

  • 프로젝트는 표준 데코레이터로 해석
  • 라이브러리의 .d.ts는 레거시 데코레이터 타입으로 선언

또는 반대 조합입니다.

특히 NestJS/TypeORM/class-transformer/class-validator 계열은 오랫동안 레거시 데코레이터 기반으로 발전해왔고, TS 5.x로 올라오며 전환 구간에서 충돌이 자주 납니다.

원인 2) 데코레이터 팩토리(함수 호출) 형태에서 오버로드가 꼬임

다음처럼 @Dec() 형태로 쓰는 팩토리 데코레이터는 오버로드/제네릭과 결합될 때 시그니처 추론이 더 쉽게 깨집니다.

function Role(role: string) {
  return function (target: any, key: string, desc: PropertyDescriptor) {
    // ...
  };
}

class A {
  @Role("admin")
  hello() {}
}

여기서 TS가 “Role("admin")의 반환 타입이 메서드 데코레이터인지”를 판정해야 하는데, 프로젝트가 표준 데코레이터 모드라면 반환 타입이 맞지 않아 Unable to resolve signature...로 이어집니다.

원인 3) tsconfig에서 experimentalDecorators/emitDecoratorMetadata 조합이 어긋남

레거시 데코레이터 생태계(특히 NestJS)는 보통 아래 설정을 기대합니다.

  • experimentalDecorators: true
  • emitDecoratorMetadata: true (런타임 리플렉션 기반 메타데이터 필요 시)

이 중 하나가 빠지거나, 반대로 표준 데코레이터로 가려는 프로젝트에 레거시 설정이 섞이면 에러가 확대됩니다.

원인 4) useDefineForClassFields/트랜스파일 타겟 차이로 런타임 동작까지 꼬임

이건 컴파일 에러가 아니라 런타임 버그로 이어지기 쉬운데, 데코레이터가 필드 초기화 타이밍에 의존하는 경우 문제가 됩니다. “에러는 시그니처인데, 고치고 나면 런타임이 이상해졌다”는 케이스가 여기서 나옵니다.

해결 전략 1: 레거시 데코레이터 생태계를 쓰면 레거시로 고정

NestJS나 TypeORM 등 레거시 데코레이터 전제를 강하게 가진 프레임워크를 쓰는 경우, 가장 실용적인 해법은 “표준으로 무리하게 당기지 말고 레거시 모드로 고정”하는 것입니다.

아래는 전형적인 tsconfig.json 방향입니다.

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

포인트는 다음입니다.

  • experimentalDecorators를 명시적으로 켭니다.
  • 런타임 메타데이터에 의존하는 경우 emitDecoratorMetadata도 켭니다.
  • 라이브러리의 데코레이터 타입 선언이 레거시라면, 프로젝트도 레거시로 맞추는 편이 비용이 낮습니다.

이렇게 맞추면 Unable to resolve signature의 상당수가 사라집니다.

해결 전략 2: 표준 데코레이터로 갈 거면 데코레이터 구현을 표준 시그니처로 교체

사내 공통 데코레이터를 직접 운영하고 있고, 앞으로 표준 데코레이터로 옮기려면 구현을 표준 시그니처로 바꾸는 게 정석입니다.

표준 메서드 데코레이터 예시

export function LogStd(
  value: (this: any, ...args: any[]) => any,
  context: ClassMethodDecoratorContext
) {
  const name = String(context.name);

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

class A {
  @LogStd
  hello(name: string) {
    return `hi ${name}`;
  }
}

표준 데코레이터 팩토리 예시

export function RoleStd(role: string) {
  return function (
    value: (this: any, ...args: any[]) => any,
    context: ClassMethodDecoratorContext
  ) {
    return function (this: any, ...args: any[]) {
      console.log("role", role, "method", String(context.name));
      return value.apply(this, args);
    };
  };
}

class A {
  @RoleStd("admin")
  hello() {
    return "ok";
  }
}

표준 데코레이터의 장점은 context로부터 이름, 정적 여부, private 여부 등 더 구조화된 정보를 얻는다는 점입니다. 대신 레거시의 descriptor 조작 패턴을 그대로 가져오면 타입/동작이 맞지 않습니다.

해결 전략 3: “레거시/표준 혼재”를 피하는 경계 레이어 만들기

현실적으로는 프로젝트 안에 다음이 섞여 있는 경우가 많습니다.

  • 외부 라이브러리: 레거시 데코레이터
  • 신규 코드: 표준 데코레이터로 작성하고 싶음

이때는 한 번에 전체 전환을 시도하기보다, “경계 레이어”를 두는 편이 안전합니다.

예를 들어, 레거시 기반 프레임워크(NestJS) 영역은 레거시로 유지하고, 순수 TS 유틸/도메인 레이어에서만 표준 데코레이터를 쓰는 식입니다. 다만 TS 컴파일러 옵션은 프로젝트 단위로 적용되므로, 모노레포라면 패키지별 tsconfig를 분리하는 전략이 필요합니다.

  • 앱 패키지: 레거시 데코레이터 설정
  • 라이브러리 패키지: 표준 데코레이터 설정

이렇게 나누면 “한쪽을 고치면 다른 쪽이 깨지는” 상황을 줄일 수 있습니다.

해결 전략 4: 타입 정의가 문제라면 데코레이터 타입을 명시해 추론 실패를 막기

추론이 깨질 때는 반환 타입을 명시해 컴파일러가 올바른 오버로드를 고르도록 유도할 수 있습니다.

예를 들어 레거시 메서드 데코레이터 팩토리라면 다음처럼 반환 타입을 명시합니다.

export function LogLegacy(): MethodDecorator {
  return (target, key, descriptor) => {
    const original = (descriptor as PropertyDescriptor).value as Function;
    (descriptor as PropertyDescriptor).value = function (...args: any[]) {
      console.log("call", String(key), args);
      return original.apply(this, args);
    };
  };
}

class A {
  @LogLegacy()
  hello() {
    return "ok";
  }
}

이 방식은 “설정이 레거시로 맞춰져 있다”는 전제가 있어야 효과가 큽니다. 설정이 표준인데 MethodDecorator로 밀어붙이면 다른 에러로 번질 수 있습니다.

체크리스트: 원인 빠르게 좁히기

아래 순서대로 보면 진단이 빨라집니다.

  1. 에러가 나는 데코레이터가 내 코드인지, 외부 라이브러리인지 확인
  2. 해당 데코레이터의 구현이 (target, key, descriptor)인지 (value, context)인지 확인
  3. tsconfig.json에서 experimentalDecorators/emitDecoratorMetadata 설정 확인
  4. 같은 레포 안에서 패키지별로 TS 설정이 다른지(모노레포) 확인
  5. 데코레이터를 @Dec로 쓰는지 @Dec()로 쓰는지(팩토리 여부) 확인

이 과정에서 “설정은 표준인데 구현은 레거시” 또는 그 반대가 발견되면, 그게 거의 정답입니다.

실전 팁: 업그레이드 구간에서 자주 같이 터지는 이슈

TS 5.x로 올리는 타이밍은 보통 Next.js/Node/번들러/ESM 전환과 같이 옵니다. 데코레이터 이슈와 직접 관련은 없지만, 설정 변경이 겹치면 원인 파악이 어려워집니다.

  • ESM 전환 과정에서 require 관련 런타임 에러가 같이 나면, 먼저 모듈 시스템부터 정리하는 게 좋습니다. 관련해서는 Node.js ESM에서 require is not defined 해결법도 같이 참고하면 업그레이드 디버깅 동선이 줄어듭니다.
  • Next.js 앱이라면 빌드/런타임 경계(서버 컴포넌트, 클라이언트 컴포넌트)에서 “컴파일은 되는데 hydration이 깨진다” 같은 2차 문제가 나올 수 있습니다. 이때는 Next.js Hydration mismatch 원인 9가지와 해결법처럼 별도 축으로 분리해 점검하는 게 효율적입니다.

결론: 시그니처 문제는 설정과 구현의 정합성 문제다

Unable to resolve signature는 데코레이터 자체가 나쁘다기보다, “컴파일러가 기대하는 데코레이터 호출 규약”과 “현재 데코레이터 구현/타입 선언”이 불일치할 때 발생합니다.

  • 외부 생태계가 레거시라면 프로젝트도 레거시로 맞추는 것이 가장 빠른 해결책
  • 표준으로 가려면 데코레이터 구현을 (value, context) 기반으로 재작성
  • 혼재가 불가피하면 패키지 단위로 TS 설정을 분리해 경계를 만든다

이 3가지만 지키면, TS 5.x 업그레이드에서 데코레이터 관련 컴파일 에러는 대부분 예측 가능하고 통제 가능한 수준으로 내려옵니다.