Published on

TS 5.6 Decorators 도입 - 실전 마이그레이션

Authors

서버/프론트 공용 라이브러리나 NestJS·ORM·DI 컨테이너를 쓰는 프로젝트라면 데코레이터는 이미 코드베이스 깊숙이 들어가 있습니다. 문제는 TypeScript의 데코레이터가 오랫동안 experimentalDecorators라는 이름처럼 “실험적” 상태였고, TC39 표준 데코레이터(이하 표준 데코레이터)로의 전환 과정에서 동작 모델과 타입 시그니처가 달라진다는 점입니다.

TS 5.6에서는 표준 데코레이터 지원이 본격적으로 안정화되면서, 레거시 데코레이터(기존 experimentalDecorators)를 쓰던 코드가 그대로는 맞지 않거나, 라이브러리/트랜스파일러 조합에 따라 미묘하게 깨질 수 있습니다. 이 글은 “이론 소개”가 아니라, 실제 마이그레이션에서 어디가 터지고 어떻게 고치는지를 중심으로 정리합니다.

CI에서 버전 업 후 빌드가 갑자기 깨졌다면, 캐시 때문에 이전 산출물이 섞여 원인 파악이 더 어려워질 수 있습니다. TS 업그레이드 PR에서는 캐시 키/경로를 함께 점검하는 것을 권합니다: GitHub Actions 캐시 미스? 키·경로 함정 9가지

TS 5.6 데코레이터: 무엇이 달라졌나

핵심 차이는 “데코레이터가 무엇을 받는가”입니다.

  • 레거시(실험) 데코레이터는 보통 (target, propertyKey, descriptor) 스타일입니다.
  • 표준 데코레이터는 valuecontext를 받습니다. context에는 kind, name, static, private, addInitializer 같은 정보가 들어갑니다.

즉, 레거시에서 흔히 하던 아래 패턴이 표준에서는 그대로 성립하지 않습니다.

  • descriptor.value를 감싸서 메서드 래핑
  • target.prototype에 메타데이터를 심기
  • Reflect.defineMetadata에 의존(특히 런타임 리플렉션)

표준 데코레이터에서는 초기화 시점과 접근 방식이 달라져 addInitializer를 통해 인스턴스 생성 시점에 작업을 걸거나, context.access로 getter/setter에 접근하는 식으로 바뀝니다.

마이그레이션 전략: 한 번에 갈아엎지 말고 “경계”를 만든다

실전에서 가장 안전한 접근은 다음 순서입니다.

  1. 데코레이터 사용처를 분류한다
    • 로깅/트레이싱(메서드 래핑)
    • 검증/권한(메서드/파라미터)
    • DI/메타데이터 수집(클래스/프로퍼티)
    • ORM/라우팅(메타데이터 수집)
  2. “표준 데코레이터로 재작성 가능한 것”부터 전환한다
  3. 메타데이터 기반(Reflect Metadata, Nest/TypeORM 스타일)은 호환 레이어를 두고 점진 전환한다

특히 2번이 중요합니다. 로깅/성능 계측 같은 “기능성 데코레이터”는 표준으로 비교적 쉽게 옮길 수 있지만, DI/ORM처럼 메타데이터를 광범위하게 수집하는 영역은 라이브러리 의존성이 얽혀 있어 한 번에 바꾸기 어렵습니다.

tsconfig에서 가장 먼저 확인할 것

프로젝트가 어떤 데코레이터 모델을 쓰는지 tsconfig에서 드러납니다. 레거시 기반이라면 보통 아래가 켜져 있습니다.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

마이그레이션 관점에서 체크 포인트는 다음입니다.

  • experimentalDecorators: 레거시 데코레이터 문법/시그니처를 활성화
  • emitDecoratorMetadata: design:type 같은 타입 메타데이터를 런타임에 쏴주는 옵션(Reflect Metadata 계열과 결합)

표준 데코레이터로 갈수록 emitDecoratorMetadata 의존은 줄이는 편이 좋습니다. 이 옵션은 런타임 타입 정보 제공이라는 편의가 있지만, 번들/트리쉐이킹/런타임 의존성 측면에서 비용이 큽니다.

예제 1: 레거시 메서드 데코레이터를 표준으로 옮기기

레거시 방식

아래는 흔한 “실행 시간 로깅” 레거시 데코레이터입니다.

export function LogTime(): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    const original = descriptor.value as (...args: any[]) => any;

    descriptor.value = function (...args: any[]) {
      const start = Date.now();
      try {
        return original.apply(this, args);
      } finally {
        const end = Date.now();
        console.log(String(propertyKey), end - start, "ms");
      }
    };

    return descriptor;
  };
}

표준 데코레이터 방식

표준에서는 (value, context)를 받습니다. 메서드의 경우 value는 함수이며, 래핑한 함수를 반환하면 됩니다.

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

export function LogTime() {
  return function (value: AnyFn, context: ClassMethodDecoratorContext) {
    const name = String(context.name);

    return function (this: any, ...args: any[]) {
      const start = Date.now();
      try {
        return value.apply(this, args);
      } finally {
        const end = Date.now();
        console.log(name, end - start, "ms");
      }
    };
  };
}

이관 시 자주 터지는 포인트

  • 레거시는 descriptor를 수정하지만, 표준은 “새 함수를 반환”하는 모델입니다.
  • propertyKey 대신 context.name을 씁니다.
  • private 메서드/필드의 경우 context.privatetrue일 수 있어, 로깅 이름 노출 정책을 정해야 합니다.

예제 2: 인스턴스 초기화가 필요한 데코레이터는 addInitializer

레거시에서는 target.prototype에 값을 심거나, 생성자 패치를 하는 식으로 해결하곤 했습니다. 표준에서는 context.addInitializer가 정석입니다.

예를 들어, 특정 필드가 초기화될 때마다 값을 검증하고 싶다고 해봅시다.

export function NonEmpty() {
  return function (value: unknown, context: ClassFieldDecoratorContext) {
    context.addInitializer(function () {
      const fieldName = String(context.name);
      const v = (this as any)[fieldName];
      if (typeof v === "string" && v.length === 0) {
        throw new Error("Empty string is not allowed: " + fieldName);
      }
    });

    return value;
  };
}

여기서 핵심은 addInitializer에 전달한 함수의 this가 인스턴스를 가리킨다는 점입니다. 레거시처럼 프로토타입을 더럽히지 않고, 인스턴스 라이프사이클에 안전하게 붙일 수 있습니다.

메타데이터(Reflect) 의존 코드: 가장 위험한 구간

NestJS/TypeORM/클래스-밸리데이터 계열은 레거시 데코레이터 + Reflect Metadata 조합에 강하게 의존해 왔습니다.

대표 패턴은 아래와 같습니다.

import "reflect-metadata";

const META_KEY = "my:routes";

export function Route(path: string): MethodDecorator {
  return (target, propertyKey) => {
    const routes = Reflect.getMetadata(META_KEY, target) ?? [];
    routes.push({ name: propertyKey, path });
    Reflect.defineMetadata(META_KEY, routes, target);
  };
}

표준 데코레이터로 이동할 때의 문제는 다음입니다.

  • 레거시의 target이 “프로토타입”인 경우가 많고, 메타데이터 저장 위치가 관례적으로 prototype이었습니다.
  • 표준에서는 contextaddInitializer 중심이어서, “어디에 메타데이터를 저장할지”를 명시적으로 설계해야 합니다.

실전 해법: 메타데이터 저장소를 직접 만든다

Reflect Metadata에 계속 기대면 전환이 느려집니다. 점진 전환을 위해, 우선 “저장소”를 직접 두는 방식이 좋습니다.

type RouteDef = { methodName: string; path: string };

const routeStore = new WeakMap<Function, RouteDef[]>();

export function Route(path: string) {
  return function (value: Function, context: ClassMethodDecoratorContext) {
    context.addInitializer(function () {
      const ctor = this.constructor as Function;
      const prev = routeStore.get(ctor) ?? [];
      routeStore.set(ctor, [...prev, { methodName: String(context.name), path }]);
    });

    return value;
  };
}

export function getRoutes(ctor: Function): RouteDef[] {
  return routeStore.get(ctor) ?? [];
}

이 방식의 장점:

  • 저장 위치가 prototype/target에 종속되지 않습니다.
  • GC 친화적인 WeakMap으로 누수 위험을 낮춥니다.
  • 표준 데코레이터 모델(addInitializer)에 자연스럽게 맞습니다.

단점:

  • 기존 Reflect 기반 생태계(Nest의 스캐너 등)와는 바로 호환되지 않습니다. 그래서 “전부 표준으로 갈아타는” 프로젝트에서 특히 유용합니다.

공존(레거시 + 표준) 기간을 위한 운영 체크리스트

대부분의 팀은 한 번에 전환할 수 없어서 공존 기간이 생깁니다. 이때 실제로 많이 겪는 문제는 “코드는 맞는데 빌드/테스트가 흔들리는” 운영 이슈입니다.

1) 트랜스파일러 조합을 고정하라

  • tsc로 emit하는지
  • Babel/SWC가 데코레이터를 변환하는지
  • 테스트(Jest/ts-jest)가 어떤 파이프라인을 쓰는지

이 조합이 환경별로 다르면, 로컬에서는 되는데 CI에서만 깨지는 일이 흔합니다. TS 업그레이드 PR에서는 “컴파일 경로”를 문서화하고, CI에서 단일 경로로 고정하는 것이 좋습니다.

2) 데코레이터가 붙은 모듈은 사이드이펙트를 명시하라

표준 데코레이터는 초기화 훅을 등록할 수 있어, 번들러가 순서를 바꾸거나 트리쉐이킹할 때 의도치 않은 변화가 생길 수 있습니다. 데코레이터가 라우팅 등록/DI 등록 같은 사이드이펙트를 만든다면, 번들러 설정에서 sideEffects 처리를 점검하세요.

3) 회귀 테스트는 “기능”이 아니라 “등록 결과”를 본다

라우팅/DI/검증처럼 데코레이터가 메타데이터를 쌓는 구조라면, E2E만으로는 원인 파악이 느립니다. getRoutes(MyController)처럼 “등록 결과”를 직접 스냅샷 테스트하는 편이 전환기에 훨씬 강합니다.

테스트가 flaky해지면 CI 재시도만 늘리게 되는데, 이때는 파이프라인 동시 실행/취소 정책도 함께 정리하는 게 좋습니다: GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress

마이그레이션 단계별 가이드(권장)

1단계: 데코레이터를 “카탈로그화”

  • 어떤 데코레이터가 어디서 쓰이는지
  • 메타데이터를 저장하는지, 단순 래핑인지
  • 런타임 의존(Reflect, DI 컨테이너, 프레임워크 스캐너)이 있는지

이 목록이 없으면 전환이 끝나지 않습니다.

2단계: 순수 래핑 데코레이터부터 표준으로 전환

  • 로깅
  • 캐싱
  • 권한 체크(단, 메타데이터 기반이면 보류)

이 영역은 value 반환 패턴으로 빠르게 옮길 수 있고, 리스크 대비 효과가 큽니다.

3단계: 메타데이터 기반은 저장소를 분리하고 점진 치환

  • Reflect에 직접 쓰던 메타데이터를 WeakMap 저장소로 이동
  • 프레임워크 스캐너/리졸버가 있다면 저장소를 읽도록 변경

4단계: emitDecoratorMetadata 의존 제거(가능한 범위)

런타임 타입이 꼭 필요하다면 유지하되, 가능한 곳은 명시적 스키마/DTO로 전환하는 편이 장기적으로 안전합니다. 타입 기반 런타임 마법은 업그레이드 때마다 비용이 커집니다.

트러블슈팅: 자주 보는 에러/증상

데코레이터 타입 에러가 폭발한다

  • 레거시 시그니처를 표준 컨텍스트에 대입하고 있을 가능성이 큽니다.
  • 해결: 레거시 데코레이터 팩토리의 반환 타입(MethodDecorator 등)을 제거하고, 표준 컨텍스트 타입(ClassMethodDecoratorContext 등)을 사용하세요.

런타임에서 등록이 누락된다

  • 레거시에서는 “정의 시점”에 메타데이터를 쌓았는데, 표준에서는 addInitializer로 “인스턴스 생성 시점”에 쌓도록 바꿨을 수 있습니다.
  • 해결: 등록이 클래스 단위로 필요하면, 인스턴스가 아니라 클래스 평가 시점에 쌓이도록 설계를 바꾸거나(예: 클래스 데코레이터에서 저장), 최소 1회 인스턴스화가 보장되는 구조인지 확인하세요.

빌드/테스트 환경마다 결과가 다르다

  • Babel/SWC/ts-jest가 서로 다른 데코레이터 변환을 하고 있을 가능성이 큽니다.
  • 해결: 전환기에는 한 경로로 통일하고, 캐시를 비우고, 산출물 디렉터리를 명확히 분리하세요.

결론: “표준으로의 전환”은 코드 변경보다 설계 변경이다

TS 5.6 표준 데코레이터 도입은 단순히 시그니처를 바꾸는 작업이 아닙니다. 레거시 데코레이터가 암묵적으로 기대하던 것들(프로토타입 기반 메타데이터, descriptor 패치, Reflect 의존)을 명시적인 저장소/초기화 모델로 재설계하는 작업에 가깝습니다.

  • 래핑 계열은 빠르게 표준으로 전환
  • 메타데이터 계열은 저장소를 분리해 점진 전환
  • 빌드 파이프라인을 단일화해 환경 차이를 제거

이 3가지만 지켜도 “TS 업그레이드가 곧 장애”가 되는 상황을 상당히 줄일 수 있습니다.

추가로 TS 업그레이드 과정에서 타입 추론/좁히기가 함께 흔들리는 경우도 많습니다. 데코레이터 전환과 별개로 타입 좁히기 회귀가 보인다면 다음 글도 함께 참고하세요: TS 5.7 - satisfies로 타입 좁히기 실패 해결