Published on

TypeScript 5 데코레이터 완전 가이드 - 실무 패턴·함정

Authors

서버/클라이언트 공통으로 TypeScript를 쓰다 보면, 횡단 관심사(로깅, 권한, 캐싱, 트레이싱, 입력 검증)를 “비즈니스 로직에서 분리”하고 싶어지는 순간이 옵니다. 예전에는 프레임워크(예: NestJS, Angular)나 실험적 데코레이터에 기대곤 했지만, TypeScript 5부터는 ECMAScript 표준(스테이지 3) 데코레이터를 기반으로 한 새로운 모델이 정착했습니다.

이 글은 “데코레이터를 써도 되는 상황”과 “쓰면 사고 나는 지점”을 실무 중심으로 정리합니다. 특히 TypeScript 5 데코레이터의 시그니처 변화, context 기반의 기능 구성, 그리고 프로덕션에서 자주 겪는 함정(바인딩, this, private 필드, 성능, 번들/트리쉐이킹, 테스트)을 코드로 확인합니다.

참고로 데코레이터는 요청/응답 경로에서 로깅·트레이싱을 넣을 때도 유용합니다. 네트워크 오류를 다루는 흐름은 Node.js fetch ECONNRESET·ETIMEDOUT 해결법 같은 글과 같이 보면 “어디에 관측 코드를 심을지” 감이 더 빨리 옵니다.

TypeScript 5 데코레이터, 무엇이 달라졌나

핵심은 “레거시(실험적) 데코레이터”와 “표준 데코레이터”가 다르다는 점입니다.

  • 레거시 방식은 target, propertyKey, descriptor 형태가 익숙합니다.
  • 표준 방식(TypeScript 5)은 데코레이터가 값(value)과 컨텍스트(context) 를 받습니다.
  • context.addInitializer 같은 훅으로 인스턴스 초기화 타이밍에 코드를 주입할 수 있습니다.

실무적으로 중요한 차이는 다음입니다.

  1. 시그니처가 달라서 기존 코드가 그대로 안 돌아갈 수 있음
  2. reflect-metadata 기반 메타데이터 패턴이 예전과 다르게 느껴질 수 있음
  3. 런타임에서 “뭘 바꾸는지”가 더 명확해졌지만, 잘못 쓰면 성능/디버깅이 더 어려워질 수 있음

컴파일 설정: 표준 데코레이터를 켜는 최소 조건

TypeScript 5에서 표준 데코레이터를 쓰려면 tsconfig.json을 점검해야 합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "experimentalDecorators": false
  }
}
  • 표준 데코레이터는 experimentalDecorators에 의존하지 않습니다.
  • 프로젝트에 레거시 데코레이터가 섞여 있으면, 마이그레이션 전략을 먼저 잡는 편이 안전합니다.

표준 데코레이터 기본: 메서드 로깅부터

가장 많이 쓰는 패턴은 “메서드 호출 전후로 로깅/측정”입니다.

function LogCall(label?: string) {
  return function (
    value: (...args: any[]) => any,
    context: ClassMethodDecoratorContext
  ) {
    const name = String(context.name);

    return function (this: any, ...args: any[]) {
      const tag = label ?? name;
      const start = Date.now();

      try {
        const result = value.apply(this, args);

        // Promise도 처리
        if (result && typeof (result as any).then === "function") {
          return (result as Promise<any>).finally(() => {
            const ms = Date.now() - start;
            console.log(`[${tag}] done in ${ms}ms`);
          });
        }

        const ms = Date.now() - start;
        console.log(`[${tag}] done in ${ms}ms`);
        return result;
      } catch (e) {
        const ms = Date.now() - start;
        console.log(`[${tag}] failed in ${ms}ms`);
        throw e;
      }
    };
  };
}

class UserService {
  @LogCall("getUser")
  async getUser(id: string) {
    // ...
    return { id };
  }
}

여기서 중요한 포인트는 value.apply(this, args) 입니다.

  • 데코레이터가 원본 메서드를 감싸면서 this 바인딩을 깨뜨리기 쉽습니다.
  • apply로 원래 컨텍스트를 유지해야 합니다.

함정 1: this 바인딩 깨짐과 화살표 함수

표준 데코레이터는 “메서드”를 감싸는 형태로 구현하는 경우가 많습니다. 이때 실수로 value(...args)로 호출하면 thisundefined가 될 수 있습니다.

// 나쁜 예: this가 깨질 수 있음
return function (...args: any[]) {
  return value(...args);
};

반드시 아래처럼 호출하세요.

return function (this: any, ...args: any[]) {
  return value.apply(this, args);
};

함정 2: private 필드/메서드와 데코레이터

표준 데코레이터는 문법적으로 private 요소에 붙일 수 있지만, 런타임 접근이 제한적이라 “하고 싶은 것”이 안 되는 경우가 많습니다.

  • #privateField 같은 진짜 private는 런타임에서 우회 접근이 불가능합니다.
  • 데코레이터에서 내부 상태를 건드리려는 설계는 피하는 편이 좋습니다.

대신 “공개 메서드 경계”에 데코레이터를 두고, 내부는 순수 함수/캡슐화로 유지하는 구성이 안전합니다.

함정 3: 데코레이터는 타입 시스템이 아니라 런타임이다

데코레이터는 “타입을 바꾸는 기능”이 아닙니다. 반환 타입/오버로드/제네릭 제약을 마법처럼 강화해주지 않습니다.

  • 타입 안전성은 함수 시그니처명시적 타입으로 확보
  • 데코레이터는 런타임 동작(감싸기, 초기화, 메타데이터 부착)만 담당

즉, 데코레이터로 검증을 넣더라도, 타입은 별도로 지켜야 합니다.

실무 패턴 1: 권한 체크(Authorization) 데코레이터

HTTP 핸들러나 서비스 메서드에서 권한 체크를 반복하는 대신, 데코레이터로 경계를 만들 수 있습니다.

type AuthContext = { roles: string[] };

function RequireRole(role: string) {
  return function (
    value: (...args: any[]) => any,
    context: ClassMethodDecoratorContext
  ) {
    return function (this: any, ...args: any[]) {
      const auth: AuthContext | undefined = this.auth;
      if (!auth || !auth.roles.includes(role)) {
        throw new Error(`Forbidden: missing role ${role}`);
      }
      return value.apply(this, args);
    };
  };
}

class AdminService {
  constructor(public auth: AuthContext) {}

  @RequireRole("admin")
  deleteUser(userId: string) {
    return { ok: true, userId };
  }
}

이 패턴의 함정:

  • this.auth 같은 의존을 암묵적으로 가정하면 테스트가 어려워집니다.
  • 해결책은 “명시적 컨텍스트 주입” 또는 “팩토리로 래핑”입니다.

권한/인증 계층에서 403이 반복될 때는, 애플리케이션 로직뿐 아니라 인프라/미들웨어 설정도 함께 보게 됩니다. 예를 들어 Ingress/ALB 단에서 막히는 케이스는 EKS ALB Ingress 403, WAF 아닌 원인 7가지처럼 원인 분리가 중요합니다.

실무 패턴 2: 캐싱(메모이제이션) 데코레이터

동일 입력에 대해 결과가 반복되는 메서드라면 캐싱이 효과적입니다. 단, “키 설계”와 “메모리 누수”가 함정입니다.

function Cached(options?: { ttlMs?: number; key?: (...args: any[]) => string }) {
  const ttlMs = options?.ttlMs ?? 5_000;
  const keyFn = options?.key ?? ((...args: any[]) => JSON.stringify(args));

  return function (
    value: (...args: any[]) => any,
    context: ClassMethodDecoratorContext
  ) {
    // 인스턴스별 캐시
    const cache = new Map<string, { exp: number; v: any }>();

    return function (this: any, ...args: any[]) {
      const k = keyFn(...args);
      const now = Date.now();
      const hit = cache.get(k);
      if (hit && hit.exp > now) return hit.v;

      const v = value.apply(this, args);
      cache.set(k, { exp: now + ttlMs, v });
      return v;
    };
  };
}

class ProductService {
  @Cached({ ttlMs: 2_000 })
  getPrice(productId: string) {
    return Math.random();
  }
}

주의할 점:

  • JSON.stringify 키는 순서/비직렬화 값에서 문제가 생길 수 있습니다.
  • 캐시가 인스턴스 생명주기와 함께 사라지지 않으면, 장수 프로세스에서 메모리 누수가 됩니다.
  • Promise를 캐싱할지, resolve된 값을 캐싱할지 정책을 명확히 해야 합니다.

실무 패턴 3: 재시도/백오프 데코레이터(네트워크 안정성)

외부 API 호출은 ECONNRESET, ETIMEDOUT 같은 오류가 흔합니다. 이때 재시도 로직을 데코레이터로 분리하면 코드가 깔끔해집니다.

function Retry(options?: { retries?: number; delayMs?: number }) {
  const retries = options?.retries ?? 2;
  const delayMs = options?.delayMs ?? 200;

  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

  return function (
    value: (...args: any[]) => Promise<any>,
    context: ClassMethodDecoratorContext
  ) {
    return async function (this: any, ...args: any[]) {
      let lastErr: unknown;
      for (let i = 0; i <= retries; i++) {
        try {
          return await value.apply(this, args);
        } catch (e) {
          lastErr = e;
          if (i < retries) await sleep(delayMs * (i + 1));
        }
      }
      throw lastErr;
    };
  };
}

class ApiClient {
  @Retry({ retries: 3, delayMs: 150 })
  async fetchUser(id: string) {
    const res = await fetch(`https://example.com/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
}

여기서 함정:

  • 모든 에러에 재시도를 걸면 장애를 증폭시킵니다(특히 4xx).
  • 재시도 대상 에러를 분류하고, 타임아웃/서킷브레이커와 조합해야 합니다.

네트워크 계층에서 문제가 생길 때는 애플리케이션 재시도만으로는 부족할 수 있습니다. 실제로는 커넥션/프록시/런타임 문제까지 이어지기도 하니, 필요하면 Node.js fetch ECONNRESET·ETIMEDOUT 해결법처럼 원인별로 분해해서 대응하세요.

실무 패턴 4: 트레이싱/상관관계 ID 전파

마이크로서비스에서 “어디서 깨졌는지” 찾는 비용이 너무 큽니다. 데코레이터로 상관관계 ID를 붙여 로그를 구조화하면, 최소한의 침투로 관측 가능성이 좋아집니다.

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

    return function (this: any, ...args: any[]) {
      const traceId = this.traceId ?? `t_${Math.random().toString(16).slice(2)}`;
      const prev = this.traceId;
      this.traceId = traceId;

      try {
        console.log(`[trace=${traceId}] enter ${name}`);
        return value.apply(this, args);
      } finally {
        console.log(`[trace=${traceId}] exit ${name}`);
        this.traceId = prev;
      }
    };
  };
}

이 방식은 단순하지만, 비동기 경계를 넘나들 때는 AsyncLocalStorage 같은 메커니즘이 더 적합합니다. gRPC 환경이라면 인터셉터 기반 전파가 더 자연스럽고, 그때의 함정은 gRPC Interceptor로 분산 트레이싱 전파 오류 잡기에서 다룬 주제와 맞닿아 있습니다.

addInitializer로 인스턴스 초기화 훅 만들기

표준 데코레이터의 강점 중 하나는 context.addInitializer입니다. 예를 들어 “클래스 인스턴스 생성 시 특정 레지스트리에 자동 등록” 같은 패턴이 가능합니다.

const registry = new Set<object>();

function AutoRegister() {
  return function (value: Function, context: ClassDecoratorContext) {
    context.addInitializer(function () {
      // 여기서 this는 인스턴스
      registry.add(this);
    });
  };
}

@AutoRegister()
class Worker {
  run() {}
}

new Worker();
console.log(registry.size);

주의할 점:

  • 전역 레지스트리는 테스트 간 오염을 만들기 쉽습니다.
  • 서버리스/에지 환경에서 인스턴스 생명주기가 짧거나 예측 불가능할 수 있습니다.

데코레이터 도입 체크리스트(실무 함정 요약)

1) 성능: 핫패스에 과도한 래핑 금지

  • 데코레이터는 결국 함수 래핑입니다.
  • 초당 수만 번 호출되는 경로에서는 오버헤드가 누적됩니다.
  • 측정/로깅 데코레이터는 샘플링 옵션을 두거나, 개발/스테이징에서만 활성화하는 토글을 권장합니다.

2) 디버깅: 스택트레이스가 길어지고 원인 추적이 어려움

  • 래퍼 함수가 스택에 끼어듭니다.
  • 에러 메시지에 context.name과 입력 요약을 넣되, PII는 마스킹해야 합니다.

3) 테스트: 데코레이터가 붙은 메서드는 “행동이 바뀐 것”

  • 단위 테스트에서 원본 메서드만 검증하면 놓치는 게 생깁니다.
  • 데코레이터 자체를 별도 테스트하거나, 통합 테스트에서 경계 동작을 검증하세요.

4) 트리 쉐이킹/번들: 사이드 이펙트로 취급될 수 있음

  • 데코레이터는 런타임 실행을 동반합니다.
  • 번들러가 안전하게 제거하지 못하는 코드가 늘 수 있습니다.

5) 프레임워크 혼용: 레거시 데코레이터와 섞이면 지옥

  • NestJS/Angular는 레거시 데코레이터 생태계가 큽니다.
  • 프로젝트에서 표준/레거시를 동시에 쓰지 않도록 경계를 정하세요.

마이그레이션 전략: 레거시에서 표준으로

레거시 데코레이터가 이미 많은 코드베이스라면, 한 번에 바꾸기 어렵습니다.

  • 신규 모듈부터 표준 데코레이터로 시작
  • 공통 데코레이터(로깅, 캐싱, 재시도)를 “표준 버전”으로 별도 패키지화
  • 레거시 의존(예: reflect-metadata)이 필요한 구간과 아닌 구간을 분리

특히 DI/메타데이터 중심 설계는 프레임워크 결합도가 높아서, 데코레이터만 바꿔도 동작이 미묘하게 달라질 수 있습니다.

결론: 데코레이터는 ‘규칙’과 ‘경계’에만 쓰면 강력하다

TypeScript 5 표준 데코레이터는 예전보다 명확하고 안전한 모델을 제공합니다. 하지만 “편하니까 여기저기 붙이자”는 순간, 디버깅/성능/테스트 복잡도가 급격히 늘어납니다.

추천하는 사용 원칙은 단순합니다.

  • 도메인 로직 내부가 아니라 경계(입출력, 인프라 호출, 권한 체크) 에만 적용
  • this 바인딩과 Promise 처리 규칙을 데코레이터 유틸에서 통일
  • 캐싱/재시도/로깅은 옵션(토글, 샘플링, 필터링)을 기본 제공

이 원칙만 지키면, 데코레이터는 코드베이스의 “반복”을 줄이면서도 운영 안정성을 높이는 좋은 도구가 됩니다.