- Published on
ES2024 데코레이터와 TS 타입추론 - this 깨짐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공통으로 로깅, 권한 체크, 캐싱, 트레이싱 같은 횡단 관심사를 깔끔하게 넣고 싶을 때 데코레이터는 매력적입니다. 다만 ES2024(표준) 데코레이터를 TypeScript에서 쓰기 시작하면 곧바로 두 가지 벽을 만납니다.
- 메서드를 래핑하는 순간
this가 깨져 런타임 오류가 난다 - 래핑하면서 원래 함수 시그니처(파라미터/리턴 타입)가
any처럼 흐려지거나 추론이 무너진다
이 글은 ES2024 데코레이터(새 데코레이터) 기준으로, this 깨짐의 정확한 원인과 TypeScript 타입 추론을 보존하는 구현 패턴을 정리합니다.
참고: 이 글의 코드는 “표준 데코레이터” 문법(ES2024) 기준입니다.
experimentalDecorators(레거시)와는 시그니처가 다릅니다.
ES2024 데코레이터에서 달라진 점(핵심만)
표준 데코레이터에서 메서드 데코레이터는 대략 다음 형태입니다.
- 첫 번째 인자
value: 원본 메서드(함수) - 두 번째 인자
context: 메서드 이름, 종류,addInitializer등 메타 정보 - 반환값: 교체할 함수(래핑한 함수)를 반환하면 메서드가 그 함수로 바뀜
즉, 데코레이터는 “메서드 정의 시점”에 실행되고, 반환한 함수가 실제 메서드가 됩니다. 여기서 this 와 타입 보존 문제가 함께 터집니다.
this 가 깨지는 대표 패턴
가장 흔한 버그는 “원본 메서드를 변수에 담아 호출”하면서 발생합니다.
function BadLog() {
return function (
value: Function,
context: ClassMethodDecoratorContext
) {
return function (...args: any[]) {
// 잘못된 예: value(...)는 this를 잃는다
console.log("call", String(context.name));
return value(...args);
};
};
}
class Service {
private prefix = "S";
@BadLog()
greet(name: string) {
return `${this.prefix}:${name}`;
}
}
new Service().greet("kim"); // 런타임에서 this가 undefined가 될 수 있음
왜 깨지나
원래 obj.method() 호출은 내부적으로 this 를 obj 로 바인딩합니다. 그런데 데코레이터에서 value(...args) 형태로 호출하면 “메서드 호출”이 아니라 “일반 함수 호출”이 되어 this 가 사라집니다(엄격 모드에서 undefined).
해결은 간단합니다.
value.apply(this, args)또는value.call(this, ...)로 호출- 혹은
value.bind(this)로 바인딩한 함수를 사용
하지만 여기서 TypeScript 타입 추론까지 제대로 잡으려면 조금 더 정교한 패턴이 필요합니다.
타입 추론을 보존하는 메서드 데코레이터 패턴
목표는 아래 3가지를 동시에 만족하는 것입니다.
this를 원래대로 유지한다- 파라미터 타입과 리턴 타입을 그대로 유지한다
- 가능하면
this타입(리시버 타입)도 유지한다
핵심은 원본 함수 타입을 제네릭으로 받아서 그대로 반환 시그니처에 반영하는 것입니다.
가장 실용적인 기본형: T extends (this: any, ...args: any[]) => any
type AnyMethod = (this: any, ...args: any[]) => any;
function Log() {
return function <T extends AnyMethod>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
function wrapped(this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> {
console.log(`[${name}] args=`, args);
// this 유지
return value.apply(this, args);
}
// 반환 타입을 T로 맞춰 원래 시그니처를 보존
return wrapped as T;
};
}
class UserService {
private prefix = "U";
@Log()
find(id: number) {
return `${this.prefix}:${id}`;
}
}
const s = new UserService();
const r = s.find(123); // r: string, id: number로 추론 유지
여기서 중요한 포인트:
wrapped의this타입을ThisParameterType<T>로 지정- 인자 타입은
Parameters<T>, 반환 타입은ReturnType<T> - 호출은
value.apply(this, args)
이 패턴만으로도 “타입 추론 + this 유지”의 대부분 문제가 해결됩니다.
this 타입까지 더 엄격하게: 리시버 타입을 제네릭으로 분리
위 패턴은 ThisParameterType<T> 를 활용하지만, 원본 메서드가 this 파라미터를 명시하지 않았다면 this 타입이 unknown 또는 any 로 흐려질 수 있습니다.
이럴 때는 메서드에 this 타입을 명시하는 방식이 가장 확실합니다.
class Counter {
private n = 0;
@Log()
inc(this: Counter, delta: number) {
this.n += delta;
return this.n;
}
}
TypeScript에서 메서드의 첫 파라미터를 this: Type 으로 선언하면, 데코레이터 래핑 후에도 ThisParameterType<T> 가 정확히 Counter 로 잡힙니다.
비동기 메서드에서 타입과 예외 로깅까지 안전하게
실무에서는 로깅/트레이싱이 비동기 함수에 많이 붙습니다. Promise 타입을 유지하면서 예외까지 로깅하려면 다음처럼 작성합니다.
type AnyMethod = (this: any, ...args: any[]) => any;
function Trace() {
return function <T extends AnyMethod>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
async function wrapped(
this: ThisParameterType<T>,
...args: Parameters<T>
): Promise<Awaited<ReturnType<T>>> {
const start = performance.now();
try {
const result = await value.apply(this, args);
return result;
} catch (e) {
console.error(`[${name}] error`, e);
throw e;
} finally {
const ms = Math.round(performance.now() - start);
console.log(`[${name}] ${ms}ms`);
}
}
// 원래가 sync 함수면 wrapped가 Promise를 반환하므로 시그니처가 바뀐다.
// 따라서 Trace는 "async 메서드 전용"으로 쓰는 게 안전하다.
return wrapped as unknown as T;
};
}
class Api {
@Trace()
async fetchUser(id: string) {
return { id };
}
}
주의할 점도 같이 기억해야 합니다.
- 원본이 동기 함수인데 데코레이터가
async로 바뀌면 반환 타입이Promise로 변합니다. - 따라서
async전용 데코레이터는 “비동기 메서드에만 적용”하거나, 동기/비동기를 분기 처리하는 별도 구현이 필요합니다.
this 가 깨지는 또 다른 케이스: 필드(프로퍼티) 화살표 함수
클래스에서 아래처럼 메서드를 “필드 초기화”로 정의하는 패턴이 있습니다.
class X {
value = 1;
// 메서드가 아니라 인스턴스 필드에 함수가 들어감
inc = (d: number) => this.value + d;
}
이 경우는 표준 데코레이터에서 kind 가 field 로 취급될 수 있고, 메서드 데코레이터와는 처리 방식이 다릅니다. 또한 화살표 함수는 원래 this 를 렉시컬로 캡처하므로 “this 깨짐”은 덜하지만, 데코레이터로 교체하는 순간 초기화 타이밍과 캡처가 꼬일 수 있습니다.
실무 팁:
- 데코레이터를 붙일 대상은 가급적 프로토타입 메서드(일반 메서드 선언)로 통일
- 필드 함수에 데코레이터를 붙여야 한다면
context.addInitializer를 이용해 인스턴스 생성 시점에 래핑하는 패턴을 고려
context.addInitializer 로 인스턴스별 래핑(고급)
인스턴스 생성 시점에 초기화 코드를 주입할 수 있습니다. 예를 들어, 특정 메서드를 인스턴스마다 바인딩하고 싶다면 다음처럼 할 수 있습니다.
function AutoBind() {
return function <T extends (this: any, ...args: any[]) => any>(
value: T,
context: ClassMethodDecoratorContext
): T {
if (context.kind !== "method") return value;
context.addInitializer(function (this: any) {
// 인스턴스 생성 시점에 해당 메서드를 bind
this[context.name] = value.bind(this);
});
return value;
};
}
class ViewModel {
private n = 1;
@AutoBind()
getN() {
return this.n;
}
}
const vm = new ViewModel();
const f = vm.getN;
console.log(f()); // bind 덕분에 this 유지
이 패턴은 이벤트 핸들러로 메서드를 “그대로 전달”해야 하는 UI 코드에서 특히 유용합니다.
- 장점:
const f = obj.method; f()형태에서도this유지 - 단점: 인스턴스별로 함수가 새로 만들어져 메모리/성능 비용이 증가
데코레이터로 시그니처가 바뀌는 경우: 오버로드/리턴 타입 변경
데코레이터는 본질적으로 함수를 교체합니다. 따라서 아래 상황에서는 타입 보존이 더 어렵습니다.
- 메서드 오버로드가 있는 경우
- 데코레이터가 반환 타입을 바꾸는 경우(예: 캐시 데코레이터가
Promise로 감싸는 등)
오버로드는 TS가 구현 시그니처와 선언 시그니처를 분리하기 때문에, 데코레이터 시점에 잡히는 value 는 “구현 시그니처”인 경우가 많습니다. 이때는 오버로드를 단순화하거나, 데코레이터를 오버로드 없는 내부 메서드에만 적용하고 외부 API는 얇게 위임하는 식이 안전합니다.
실전 예시: 권한 체크 + this/타입 안전
아래는 “현재 사용자 권한”을 this 에서 읽어 검사하는 전형적인 케이스입니다. this 가 깨지면 보안 버그로 직결될 수 있어 데코레이터 구현이 특히 중요합니다.
type AnyMethod = (this: any, ...args: any[]) => any;
type Role = "admin" | "user";
function RequireRole(role: Role) {
return function <T extends AnyMethod>(
value: T,
context: ClassMethodDecoratorContext
): T {
const name = String(context.name);
function wrapped(this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> {
const self = this as any;
if (self.currentRole !== role) {
throw new Error(`forbidden: ${name}`);
}
return value.apply(this, args);
}
return wrapped as T;
};
}
class AdminService {
currentRole: Role = "user";
@RequireRole("admin")
deleteUser(id: string) {
return `deleted:${id}`;
}
}
value.apply(this, args) 를 빼먹는 순간 this.currentRole 접근이 실패하거나, 더 나쁘게는 검사 로직이 엉뚱하게 동작할 수 있습니다.
성능 관점: 데코레이터 로깅이 INP/Long Task를 악화시키는 경우
로깅/트레이싱 데코레이터는 호출 경로에 매번 추가 비용을 넣습니다. 특히 브라우저 메인 스레드에서 동기 로깅, 무거운 JSON.stringify, 스택 파싱 등을 하면 INP에 악영향을 줄 수 있습니다.
- 대량 호출되는 메서드에 데코레이터를 붙였더니 INP가 튄다
- 특정 사용자 환경에서만 Long Task가 늘어난다
이런 상황이라면 Long Task를 추적/분해하는 접근이 필요합니다.
데코레이터는 “아키텍처적으로 우아한 도구”지만, 관측 코드는 비용이므로 샘플링/레벨링(개발/프로덕션 분리) 전략이 필요합니다.
체크리스트: this/타입추론 안정화 요령
1) 원본 호출은 무조건 apply 또는 call
value(...args)금지value.apply(this, args)권장
2) 제네릭으로 시그니처를 그대로 전달
T extends (this: any, ...args: any[]) => anyParameters<T>,ReturnType<T>,ThisParameterType<T>사용
3) async 데코레이터는 async 메서드에만
- sync 메서드에
async wrapped를 반환하면 타입/동작이 바뀜
4) 이벤트 핸들러로 “메서드 참조”를 넘기면 AutoBind 고려
context.addInitializer+bind- 단, 인스턴스별 함수 생성 비용을 감안
5) 필드 화살표 함수는 데코레이터 대상에서 제외하는 게 안전
- 표준 데코레이터에서
method와field는 성격이 다름
마무리
ES2024 표준 데코레이터는 레거시보다 명확하고 강력하지만, 메서드를 래핑하는 순간 this 와 타입 추론이 동시에 흔들릴 수 있습니다. 결론은 단순합니다.
- 런타임
this는apply로 지켜라 - 컴파일 타임 시그니처는
T,Parameters,ReturnType,ThisParameterType로 지켜라
이 두 축을 잡아두면 로깅/권한/캐싱/트레이싱 같은 데코레이터를 “타입 안전하게” 확장할 수 있고, 디버깅 비용도 크게 줄어듭니다.