- Published on
ES2024 데코레이터로 TS 타입추론 깨질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공통으로 ES2024+ 표준 데코레이터(이하 표준 데코레이터)를 도입하면, 런타임 동작은 의도대로인데 TypeScript 타입 추론이 갑자기 약해지거나(특히 메서드/필드/접근자) 오버로드가 사라지거나, this 타입이 any처럼 흐려지는 상황을 자주 만납니다.
이 문제는 “데코레이터가 타입 시스템을 이해하지 못해서”라기보다, 표준 데코레이터가 반환값으로 클래스/메서드를 교체할 수 있고, 그 교체 지점에서 타입스크립트가 보수적으로 타입을 계산하기 때문입니다. 또한 기존 experimentalDecorators(레거시)와 표준 데코레이터는 시그니처/평가 시점/메타데이터 방식이 달라, 마이그레이션 중에 특히 자주 터집니다.
이 글에서는 타입 추론이 깨지는 대표 패턴을 재현하고, 안전하게 복구하는 코딩 패턴(타입 보존 래퍼, satisfies, 명시적 반환 타입, 오버로드 유지, this 바인딩 유지)을 정리합니다.
Next.js 기반 프로젝트에서 데코레이터와 빌드/런타임 경계(RSC/번들링)가 얽히면 더 복잡해질 수 있습니다. 성능/TTFB 이슈까지 같이 본다면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결도 함께 참고하세요.
표준 데코레이터에서 타입이 깨지는 근본 원인
표준 데코레이터는 대략 다음과 같은 특징 때문에 타입 추론이 흔들립니다.
반환으로 “대체(replace)”가 가능
- 클래스 데코레이터는 새로운 클래스를 반환할 수 있고, 메서드/접근자 데코레이터는 새로운 함수(또는 접근자)를 반환할 수 있습니다.
- 타입스크립트는 “원본과 동일한 시그니처를 유지한다”는 보장이 없다고 판단하면, 안전을 위해 타입을 넓히거나 추론을 포기합니다.
제네릭/오버로드/
this같은 고급 타입 정보가 함수 경계에서 손실- 데코레이터가 반환하는 함수는 보통 래핑(wrapper) 함수인데, 래핑 과정에서 제네릭 파라미터, 오버로드 시그니처,
this파라미터 타입이 잘 보존되지 않습니다.
- 데코레이터가 반환하는 함수는 보통 래핑(wrapper) 함수인데, 래핑 과정에서 제네릭 파라미터, 오버로드 시그니처,
필드 데코레이터는 특히 “초기화 시점”과 “타입 선언”이 분리
- 표준 데코레이터는
context.addInitializer등을 통해 초기화를 끼워 넣을 수 있는데, 이때 런타임 값의 형태가 바뀌어도 타입은 선언에만 의존합니다. 선언을 잘못 쓰면 추론이 바로 무너집니다.
- 표준 데코레이터는
이제 실제로 어떤 코드에서 깨지는지, 그리고 어떻게 고치는지 보겠습니다.
케이스 1: 메서드 데코레이터로 오버로드가 사라짐
오버로드가 있는 메서드를 로깅/트레이싱하려고 데코레이터로 감싸면, 흔히 오버로드가 (...args: any[]) = 형태로 붕괴합니다.
문제 재현
function Log(_value: Function, _context: ClassMethodDecoratorContext) {
return function (...args: any[]) {
console.log("call", args);
// 원래 메서드 호출을 빼먹은 예시(문제 재현용)
};
}
class Api {
// 오버로드
fetch(id: number): Promise<string>;
fetch(id: string): Promise<number>;
@Log
fetch(id: number | string) {
return typeof id === "number" ? Promise.resolve("ok") : Promise.resolve(123);
}
}
const api = new Api();
// 기대: api.fetch(1) 은 Promise<string>
// 현실: 데코레이터로 인해 타입이 넓어지거나 any처럼 보일 수 있음
왜 깨지나
데코레이터가 반환한 함수는 원본 메서드의 오버로드 정보를 갖고 있지 않습니다. TypeScript 입장에서는 “이 데코레이터가 어떤 시그니처의 함수를 반환할지 모른다”가 기본 가정이라, 결과 타입을 보수적으로 처리합니다.
해결 1: “시그니처 보존” 데코레이터 팩토리 만들기
핵심은 원본 함수 타입 T를 받아 동일한 T를 반환하도록 타입을 강제하는 것입니다.
type AnyFn = (this: any, ...args: any[]) => any;
function wrapMethod<T extends AnyFn>(
original: T,
wrapper: (original: T) => T
): T {
return wrapper(original);
}
function Log<T extends AnyFn>(value: T, _context: ClassMethodDecoratorContext): T {
return wrapMethod(value, (original) => {
const wrapped = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
console.log("call", args);
return original.apply(this, args);
} as T;
return wrapped;
});
}
class Api {
fetch(id: number): Promise<string>;
fetch(id: string): Promise<number>;
@Log
fetch(id: number | string) {
return typeof id === "number" ? Promise.resolve("ok") : Promise.resolve(123);
}
}
const api = new Api();
const a = api.fetch(1); // Promise<string>
const b = api.fetch("x"); // Promise<number>
포인트는 다음 3가지입니다.
T extends AnyFn로 원본 메서드 타입을 붙잡는다.- 래퍼 함수는
Parameters<T>,ReturnType<T>,ThisParameterType<T>를 사용해 원본 시그니처를 그대로 따른다. - 마지막에
as T로 “같은 타입”임을 확정한다. (런타임이 실제로 동일 계약을 지킨다는 전제)
케이스 2: this 타입이 깨져 체이닝이 무너짐
플루언트 API에서 데코레이터로 메서드를 감싸면 this 리턴 타입이 any 또는 클래스 자신이 아닌 것으로 추론되는 경우가 있습니다.
문제 재현
function Time(value: Function, _context: ClassMethodDecoratorContext) {
return function (...args: any[]) {
const t0 = performance.now();
const r = value.apply(this, args);
console.log(performance.now() - t0);
return r;
};
}
class Builder {
private v = 0;
@Time
add(n: number) {
this.v += n;
return this;
}
build() {
return this.v;
}
}
new Builder().add(1).add(2).build();
// 데코레이터 적용 후 add() 체이닝 타입이 흔들릴 수 있음
해결: this 파라미터를 명시하고 T로 보존
아래처럼 this 타입을 포함하는 함수 타입으로 강제하면 체이닝이 유지됩니다.
type Method<TThis, TArgs extends any[], TReturn> = (this: TThis, ...args: TArgs) => TReturn;
function Time<TThis, TArgs extends any[], TReturn>(
value: Method<TThis, TArgs, TReturn>,
_context: ClassMethodDecoratorContext
): Method<TThis, TArgs, TReturn> {
return function (this: TThis, ...args: TArgs) {
const t0 = performance.now();
const r = value.apply(this, args);
console.log(performance.now() - t0);
return r;
};
}
this를 명시하면 TS가 “이 함수는 특정 this 컨텍스트에서 호출된다”를 유지할 수 있어 플루언트 패턴에 특히 효과적입니다.
케이스 3: 접근자(get/set) 데코레이터에서 타입이 넓어짐
접근자를 감싸 캐싱/검증을 넣을 때도 타입 손실이 발생합니다.
해결 패턴: ClassAccessorDecoratorContext에서 타입 고정
표준 데코레이터에서 접근자 데코레이터는 보통 { get, set, init } 형태를 다룹니다. 타입을 보존하려면 T를 끝까지 들고 가야 합니다.
type Accessor<TThis, TValue> = {
get(this: TThis): TValue;
set(this: TThis, v: TValue): void;
};
function ClampNumber<TThis>(
value: Accessor<TThis, number>,
_context: ClassAccessorDecoratorContext
): Accessor<TThis, number> {
return {
get() {
return value.get.call(this);
},
set(v) {
value.set.call(this, Math.max(0, Math.min(100, v)));
},
};
}
class Model {
accessor score = 0;
@ClampNumber
accessor rating = 0;
}
접근자 데코레이터는 “반환 객체의 형태”가 중요하기 때문에, 반환 타입을 명확히 적어두는 것이 추론 붕괴를 막는 가장 쉬운 방법입니다.
케이스 4: 필드 데코레이터로 프로퍼티 타입과 런타임 값이 불일치
필드 데코레이터로 DI/지연 초기화/프록시를 만들면 런타임 값이 바뀌는데, 타입은 선언에 남아 있어 불일치가 생깁니다.
예를 들어 @Inject가 런타임에 프록시 객체를 넣는데, 타입은 구체 구현체로 적어버리면 메서드 존재 여부/널 가능성에서 문제가 터집니다.
해결: 선언 타입을 “계약(인터페이스)”로 좁히고 satisfies로 검증
interface Cache {
get(key: string): string | undefined;
}
class MemoryCache implements Cache {
private m = new Map<string, string>();
get(key: string) {
return this.m.get(key);
}
}
// 런타임에 주입되는 값이 Cache 계약을 만족해야 함
const defaultCache = new MemoryCache() satisfies Cache;
function Inject(_value: undefined, context: ClassFieldDecoratorContext) {
context.addInitializer(function () {
// 여기서 (this as any)[context.name] 에 프록시/구현체 주입한다고 가정
});
}
class Service {
@Inject
cache!: Cache; // 구현체가 아니라 계약으로 선언
}
필드 데코레이터는 특히 “타입 선언이 진실”이 되도록 설계해야 합니다. 구현체 타입으로 박아두면 데코레이터가 뭘 주입하든 타입 시스템이 그걸 믿어버립니다.
실전 체크리스트: 타입 추론 깨질 때 우선 점검할 것
1) 데코레이터가 “반환으로 교체”를 하고 있나
- 반환을 안 하면 타입 손실 가능성이 줄어듭니다.
- 가능하면
context.addInitializer로 부가 동작만 추가하고, 원본을 교체하지 않는 설계를 우선 고려하세요.
2) 교체가 필요하다면 “입력 타입 T를 그대로 반환”하도록 강제
T extends (this: any, ...args: any[]) =패턴으로 함수 타입을 잡고Parameters<T>,ReturnType<T>,ThisParameterType<T>로 래퍼를 구성- 마지막에
as T로 계약을 고정
3) 오버로드 메서드는 특히 위험
- 오버로드 유지가 필요하면 위의 “시그니처 보존 래퍼”를 필수로 사용
- 또는 오버로드를 줄이고(가능하면) 단일 시그니처 + 유니온 반환으로 재설계
4) 프레임워크/번들러 경계에서 데코레이터가 실행되는 위치 확인
- Next.js에서 서버/클라이언트 경계, RSC 환경에서 데코레이터가 의도치 않게 양쪽에서 평가되면 타입 문제가 아니라 런타임 문제가 먼저 터질 수 있습니다.
- Hydration 경고와 함께 증상이 나타난다면 Next.js Hydration failed 경고 7가지 원인·해결도 같이 확인하세요.
추천 패턴: “타입 보존 데코레이터” 템플릿
프로젝트에 아래 템플릿을 하나 두면, 로깅/권한/트레이싱/리트라이 같은 횡단 관심사를 비교적 안전하게 붙일 수 있습니다.
type AnyFn = (this: any, ...args: any[]) => any;
export function makeMethodDecorator(
wrap: <T extends AnyFn>(original: T, name: string | symbol) => T
) {
return function <T extends AnyFn>(
value: T,
context: ClassMethodDecoratorContext
): T {
return wrap(value, context.name);
};
}
export const Trace = makeMethodDecorator((original, name) => {
const wrapped = function (this: ThisParameterType<typeof original>, ...args: Parameters<typeof original>) {
const t0 = Date.now();
try {
return original.apply(this, args);
} finally {
const dt = Date.now() - t0;
console.log(`[trace] ${String(name)} ${dt}ms`);
}
} as typeof original;
return wrapped;
});
class UserService {
@Trace
findUser(id: string) {
return { id };
}
}
이 템플릿의 장점은 다음과 같습니다.
- 데코레이터마다
T를 다시 설계하지 않아도 됨 name같은 메타 정보도 안전하게 전달- 래퍼가 원본 시그니처를 그대로 따르므로 추론 안정
마이그레이션 팁: 레거시 데코레이터와 혼용 시 주의
표준 데코레이터로 전환하는 중이라면, 같은 코드베이스에서 레거시(experimentalDecorators)와 표준이 섞일 때 타입/런타임 동작이 더 헷갈립니다.
- 레거시는
target, propertyKey, descriptor기반이고 - 표준은
value, context기반이며context.addInitializer가 핵심입니다.
혼용 구간에서는 “동일한 데코레이터 이름인데 구현이 다름” 같은 사고가 나기 쉬우니, 데코레이터 구현 파일을 표준/레거시로 명확히 분리하고 네이밍도 분리하는 편이 안전합니다.
결론
ES2024+ 표준 데코레이터는 강력하지만, 함수를 교체하는 순간 TypeScript는 타입을 보수적으로 넓히며, 그 결과 오버로드/제네릭/this 타입이 쉽게 손실됩니다. 해결의 핵심은 단순합니다.
- “교체하지 말고 초기화로 해결”을 먼저 고려
- 교체가 필요하다면 입력 타입
T를 끝까지 들고 가서 동일한T로 반환 - 래퍼는
Parameters,ReturnType,ThisParameterType로 구성 - 필드/DI는 구현체가 아닌 “계약 타입”으로 선언하고
satisfies로 검증
이 패턴만 팀 컨벤션으로 고정해도, 데코레이터 도입으로 인한 타입 추론 붕괴의 대부분을 예방할 수 있습니다.