Published on

TS 5.6 데코레이터 적용 시 런타임 오류 해결

Authors

서버/프론트 공통으로 데코레이터를 도입할 때 가장 당황스러운 순간은 컴파일은 통과하는데 런타임에서만 터지는 케이스입니다. 특히 TypeScript 5.6 구간에서는 (1) 데코레이터 스펙 변화(legacy vs standard), (2) 번들러 트랜스파일 경로(SWC/Babel/tsc), (3) 메타데이터(emitDecoratorMetadata/reflect-metadata) 기대치가 섞이면서 오류가 자주 발생합니다.

이 글은 “TS 5.6에서 데코레이터를 적용하자마자 런타임 오류가 난다”를 원인→증상→해결 형태로 정리한 실전 가이드입니다.

참고로 타입 수준의 검증을 먼저 단단히 해두면(설정 변경으로 타입이 흔들릴 때) 문제 범위를 줄일 수 있는데, 이때 satisfies가 큰 도움이 됩니다. 관련 내용은 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 함께 보시면 좋습니다.

1) TS 5.6 데코레이터: legacy vs standard를 먼저 구분

현재 생태계에는 데코레이터가 사실상 두 갈래로 공존합니다.

  • Legacy decorators(구 실험적 데코레이터): experimentalDecorators, emitDecoratorMetadatareflect-metadata 조합에 의존하는 프레임워크가 많습니다(대표적으로 NestJS/TypeORM 일부 사용 패턴).
  • Standard decorators(표준 데코레이터): TC39 Stage 3 기반의 “새 데코레이터”로, 시그니처와 실행 타이밍/컨텍스트가 legacy와 다릅니다.

문제는 프로젝트가 어느 쪽을 쓰는지 애매한 상태에서 번들러/트랜스파일러가 임의로 한쪽 방식으로 변환해버리면, 런타임에서 아래 같은 오류가 발생합니다.

  • TypeError: Cannot read properties of undefined (reading 'value')
  • TypeError: decorator is not a function
  • TypeError: Reflect.getMetadata is not a function
  • Error: Cannot find module 'reflect-metadata'

따라서 첫 단계는 내 코드/의존성이 legacy를 전제로 하는지 확인하는 것입니다.

legacy를 전제로 하는 대표 신호

  • emitDecoratorMetadata: true가 필요하다
  • Reflect.getMetadata / Reflect.defineMetadata를 사용한다
  • NestJS, TypeORM(특정 버전/설정), class-validator, class-transformer 조합을 쓴다

2) 가장 흔한 런타임 오류 5종과 해결

아래는 TS 5.6에서 데코레이터 도입 시 자주 만나는 런타임 오류를 증상별로 정리한 것입니다.

(A) Reflect.getMetadata is not a function

원인

  • reflect-metadata가 런타임에 로드되지 않았거나,
  • 번들러가 reflect-metadata를 트리쉐이킹/외부화 처리해 누락했거나,
  • Node 실행 환경이 ESM/CJS 경계에서 import가 실패했는데도 조용히 넘어간 경우

해결

  1. 엔트리 포인트에서 가장 먼저 로드
// main.ts (or index.ts)
import "reflect-metadata";

import { bootstrap } from "./app";
bootstrap();
  1. tsconfig.json 확인(legacy 조합)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  1. 번들러 사용 시 외부화/폴리필 누락 점검
  • esbuild/tsup: reflect-metadata를 external로 두면 런타임에 패키지가 실제로 설치/배포되는지 확인
  • 서버리스 번들: 레이어/번들 산출물에 포함되는지 확인

> 팁: 운영에서 “특정 환경에서만” 재현되면, 의존성/번들 누락 가능성이 큽니다. 이런 류의 환경 의존 문제는 네트워크/런타임 레벨에서 원인 추적이 중요한데, 원인 추적 관점은 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법처럼 레이어를 나눠 관찰하는 방식이 큰 도움이 됩니다.

(B) TypeError: decorator is not a function / 데코레이터가 undefined

원인

  • 데코레이터 팩토리(export/import)가 꼬였거나
  • CJS/ESM interop에서 default/named export가 뒤바뀌었거나
  • Babel/SWC가 데코레이터를 다른 모드로 변환(legacy vs standard mismatch)

해결 1: export/import 형태를 고정

// decorators.ts
export function Log(): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    const original = descriptor.value as Function;
    descriptor.value = function (...args: unknown[]) {
      console.log("call", String(propertyKey), args);
      return original.apply(this, args);
    };
  };
}

// usage.ts
import { Log } from "./decorators";

class Service {
  @Log()
  run(x: number) {
    return x * 2;
  }
}

해결 2: 트랜스파일러를 하나로 통일

  • tsc로 컴파일할지, swc/babel로 컴파일할지 결정하고 데코레이터 변환 책임을 중복시키지 마세요.
  • 예: tsc는 타입 제거 + 데코레이터 변환까지 하는데, Babel이 다시 변환하면 런타임 코드가 망가질 수 있습니다.

(C) Cannot read properties of undefined (reading 'value') (메서드 데코레이터에서)

원인

  • 표준 데코레이터에서는 legacy처럼 (target, key, descriptor) 형태가 아닐 수 있습니다.
  • 즉, 데코레이터 구현이 legacy 시그니처를 가정했는데 표준 방식으로 호출되어 descriptor가 undefined가 됩니다.

해결

  • 프로젝트가 legacy 생태계(Nest 등)라면 표준 데코레이터로 섞지 말고 legacy로 고정하세요.
  • 표준 데코레이터를 쓸 거면 구현을 표준 시그니처로 다시 작성해야 합니다.

아래는 표준 데코레이터 스타일의 매우 간단한 예시(환경에 따라 빌드 설정 필요):

// standard decorators conceptual example
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 Service {
  @LogStd
  run(x: number) {
    return x * 2;
  }
}

핵심은 데코레이터 구현과 트랜스파일 모드가 반드시 일치해야 한다는 점입니다.

(D) Cannot find module 'reflect-metadata' (배포 후에만)

원인

  • devDependency로만 설치되어 프로덕션 설치에서 빠짐
  • 번들 산출물에 포함되지 않는데 런타임이 외부 모듈을 찾지 못함

해결

  • 서버 런타임에서 필요하면 dependencies로 이동
npm i reflect-metadata
# (이미 devDependency라면) package.json에서 dependencies로 이동
  • 번들러 external 설정을 썼다면 배포 아티팩트에 node_modules/reflect-metadata가 포함되는지 확인

(E) ORM/DI에서 타입 메타데이터가 undefined

증상

  • NestJS에서 DI 토큰 추론 실패
  • class-validator에서 특정 타입 검증이 동작하지 않음
  • TypeORM에서 컬럼 타입 추론 실패

원인

  • emitDecoratorMetadata가 꺼져 있거나
  • reflect-metadata 로드 타이밍이 늦거나
  • 빌드 파이프라인이 tsconfig를 무시(예: SWC가 별도 설정 파일을 사용)

해결

  • emitDecoratorMetadata: true + 엔트리에서 reflect-metadata 최상단 import
  • SWC 사용 시 .swcrc에 데코레이터/메타데이터 옵션을 명시

예시(SWC):

{
  "jsc": {
    "parser": { "syntax": "typescript", "decorators": true },
    "transform": {
      "legacyDecorator": true,
      "decoratorMetadata": true
    },
    "target": "es2022"
  },
  "module": { "type": "nodenext" }
}

3) TS 5.6에서 안전한 설정 조합(레시피)

여기서는 “대부분의 서버 프로젝트(예: Nest 계열)에서 런타임 오류를 최소화”하는 조합을 제안합니다. 핵심은 legacy 데코레이터 + reflect-metadata를 명확히 고정하는 것입니다.

tsconfig.json (Node 18+ 기준 예시)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

엔트리 포인트

import "reflect-metadata";

// 나머지 import는 그 다음
import { App } from "./app";

new App().start();

빌드 파이프라인 원칙

  • tsc를 쓰면: Babel/SWC에서 데코레이터를 다시 변환하지 않기
  • SWC를 쓰면: legacyDecorator/decoratorMetadata를 켜고, tsc는 타입체크 전용(tsc --noEmit)으로 분리

예시(package.json):

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "swc src -d dist",
    "start": "node dist/main.js"
  }
}

4) 재현 가능한 최소 예제로 런타임 검증하기

데코레이터 런타임 오류는 프로젝트 전체에서 찾기 어렵기 때문에, **최소 재현(MRE)**로 설정을 검증하는 게 빠릅니다.

최소 재현 코드

// src/main.ts
import "reflect-metadata";

function ClassTag(tag: string): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata("tag", tag, target);
  };
}

@ClassTag("demo")
class Demo {}

console.log("tag:", Reflect.getMetadata("tag", Demo));

기대 결과

  • 정상: tag: demo
  • 실패 시:
    • Reflect.getMetadata is not a function → reflect-metadata 로드/번들 누락
    • Reflect.defineMetadata is not a function → 동일
    • 출력이 undefined → emitDecoratorMetadata가 아니라 defineMetadata/getMetadata 자체가 안 붙었거나, 다른 Reflect를 참조

5) 디버깅 체크리스트(10분 안에 보는 순서)

  1. 의존성 확인: reflect-metadata가 dependencies에 있는가?
  2. 엔트리 로드 순서: import "reflect-metadata";가 최상단인가?
  3. tsconfig 적용 여부: 실제 빌드가 이 tsconfig를 읽는가?
  4. 트랜스파일러 단일화: 데코레이터 변환을 tsc와 Babel/SWC가 동시에 하지 않는가?
  5. legacy/standard 혼용 여부: 데코레이터 구현 시그니처가 빌드 모드와 일치하는가?
  6. ESM/CJS 경계: NodeNext 환경에서 import/export가 꼬이지 않는가?
  7. 배포 아티팩트 점검: dist에 reflect-metadata가 포함/해결되는가?
  8. 번들러 최적화 옵션: tree-shaking/minify로 side-effect import가 제거되지 않았는가?

런타임에서만 터지는 문제는 결국 “환경 차이”로 수렴하는 경우가 많습니다. 이런 문제를 시스템적으로 줄이려면, 빌드/배포/실행 경로를 명확히 나눠 관찰하고(로컬/CI/프로덕션), 실패 지점을 좁히는 접근이 필요합니다. 장애 원인을 계층적으로 분리하는 방법론은 OpenAI Responses API 502 Bad Gateway 원인과 해결처럼 증상-레이어-원인으로 쪼개는 글들도 참고할 만합니다.

6) 결론: TS 5.6 데코레이터 런타임 오류의 본질

TS 5.6에서 데코레이터 런타임 오류는 대개 “TypeScript 버그”라기보다 아래 셋 중 하나입니다.

  • 데코레이터 모드 불일치(legacy vs standard)
  • 메타데이터 런타임 누락(reflect-metadata import/배포/번들링)
  • 빌드 파이프라인 이중 변환(tsc + babel/swc 혼용)

프로젝트가 Nest/TypeORM 등 legacy 생태계라면, 가장 안전한 해법은 legacy 데코레이터를 명시적으로 고정하고(experimentalDecorators, emitDecoratorMetadata, reflect-metadata), 트랜스파일러를 단일화하는 것입니다. 반대로 표준 데코레이터를 채택할 계획이라면, 데코레이터 구현과 도구체인을 표준 스펙에 맞춰 재정렬해야 합니다.

위의 최소 재현 코드가 정상 출력될 때까지 설정을 조정하면, 대다수의 “TS 5.6 데코레이터 런타임 오류”는 빠르게 정리됩니다.