Published on

ES2024 데코레이터 - TS 타입추론·메타데이터 함정

Authors

서버/프론트 공통으로 데코레이터를 쓰는 코드베이스가 늘면서, experimentalDecorators 기반의 “옛 TS 데코레이터”에서 ES2024 표준 데코레이터로 넘어가는 팀이 많아졌습니다. 문제는 이 전환이 단순 문법 변경이 아니라는 점입니다. 타입스크립트의 타입추론이 예상과 다르게 꺾이거나, 메타데이터를 기대하고 작성한 로직이 런타임에서 조용히 실패하는 경우가 자주 발생합니다.

이 글은 ES2024 데코레이터(표준 데코레이터)를 TypeScript에서 사용할 때 특히 많이 밟는 함정 두 가지, 즉 타입추론메타데이터를 중심으로 정리합니다. Next.js/RSC처럼 빌드·런타임 경계가 까다로운 환경에서는 작은 오해가 큰 장애로 이어지기도 하니, 프레임워크/번들러 환경까지 고려한 안전한 패턴을 함께 소개합니다. (관련해서 Next.js 환경의 함정은 Next.js 14 RSC로 생기는 Hydration Error 7가지도 참고할 만합니다.)

ES2024 데코레이터 한 줄 요약: “표준”은 TS 구식과 다르다

많은 사람이 데코레이터를 이렇게 기억합니다.

  • 클래스/메서드/프로퍼티에 @something을 붙인다
  • 런타임에 리플렉션 메타데이터를 꺼내서 DI/검증/라우팅을 구성한다

하지만 ES2024 표준 데코레이터는 다음이 핵심입니다.

  • 데코레이터는 값을 바꾸는 함수에 가깝고, 대상에 대한 “컨텍스트 객체”를 받습니다.
  • 표준은 reflect-metadata 같은 자동 메타데이터 방출을 규정하지 않습니다.
  • TypeScript의 옛 방식(experimentalDecorators)과 시그니처/호출 타이밍/가능한 동작이 다릅니다.

즉, “문법은 비슷한데 의미가 다르다”가 함정의 시작점입니다.

설정 체크: TS에서 표준 데코레이터를 켜는 최소 조건

TypeScript 버전과 tsconfig.json 옵션에 따라 동작이 달라집니다. 팀 내에서 가장 먼저 해야 할 일은 레거시(실험) 데코레이터인지, 표준 데코레이터인지를 확실히 구분하는 것입니다.

tsconfig.json 예시(표준 데코레이터를 쓰려는 의도):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true
  }
}

주의할 점:

  • experimentalDecorators를 켜면 레거시 경로로 해석될 수 있습니다. 표준으로 갈아타는 중이라면 “둘을 섞어 쓰는 기간”이 가장 위험합니다.
  • 프레임워크(Next.js 등)가 내부적으로 어떤 트랜스파일 경로를 타는지에 따라 데코레이터 처리 결과가 달라질 수 있습니다. 캐시/빌드 산출물이 꼬이면 현상이 재현 불가능해져 디버깅이 어려워지니, 캐시 관련 이슈가 의심되면 Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅 같은 체크리스트 방식이 도움이 됩니다.

함정 1: TS 타입추론이 “데코레이터 이후”를 모른다

문제: 데코레이터로 메서드를 래핑하면 타입이 무너진다

표준 데코레이터는 메서드의 구현을 바꿀 수 있습니다. 예를 들어 로깅/트레이싱을 위해 메서드를 래핑하는 경우가 대표적입니다.

function LogCalls(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: unknown, ...args: unknown[]) {
    console.log("call", String(context.name), args)
    return value.apply(this, args)
  }
}

class UserService {
  @LogCalls
  getUser(id: string) {
    return { id }
  }
}

런타임은 잘 동작하지만, 타입 관점에서 다음 문제가 생깁니다.

  • value: Function으로 받는 순간 원래 메서드 시그니처((id: string) => { id: string })가 사라집니다.
  • 래퍼 함수가 (...args: unknown[]) => unknown 형태가 되면서 호출부 타입 안정성이 약해집니다.

해결: “제네릭으로 원본 시그니처를 보존”하는 데코레이터

핵심은 Function을 쓰지 말고, 원본 타입을 제네릭으로 받아 그대로 반환하는 것입니다. 단, 본문에 부등호 문자가 나오면 MDX 빌드가 깨질 수 있으므로 제네릭 표기는 인라인 코드로 감쌉니다.

type AnyMethod = (this: any, ...args: any[]) => any

function LogCallsTyped(value: AnyMethod, context: ClassMethodDecoratorContext) {
  return function (this: ThisParameterType<typeof value>, ...args: Parameters<typeof value>) {
    console.log("call", String(context.name), args)
    return value.apply(this, args) as ReturnType<typeof value>
  }
}

class UserService {
  @LogCallsTyped
  getUser(id: string) {
    return { id }
  }
}

const svc = new UserService()
const u = svc.getUser("42") // u는 { id: string }

포인트:

  • Parameters, ReturnType, ThisParameterType를 활용하면 “래핑 후에도” 타입이 유지됩니다.
  • 이 패턴을 쓰지 않으면, 데코레이터가 많아질수록 타입이 any 또는 unknown으로 붕괴하는 경우가 많습니다.

추가 함정: 오버로드/제너릭 메서드 데코레이터

오버로드가 있는 메서드를 데코레이터로 감싸면, typeof value가 “구현 시그니처”만 보게 되어 오버로드 정보가 손실될 수 있습니다. 이런 경우는 데코레이터로 래핑하기보다 다음 중 하나를 권장합니다.

  • 오버로드가 필요한 API는 데코레이터 대신 명시적 래퍼 함수로 감싼다
  • 또는 오버로드를 줄이고, 호출부에서 유니온/조건부 타입으로 표현한다

함정 2: 프로퍼티 데코레이터로 “타입”을 얻으려 한다

문제: 필드 타입은 런타임에 없다

많은 개발자가 DTO 검증, ORM 매핑 등을 위해 “프로퍼티의 타입을 데코레이터에서 읽고 싶다”고 생각합니다. 하지만 TS 타입은 컴파일 타임에만 존재하고, 표준 데코레이터는 자동으로 타입 메타데이터를 제공하지 않습니다.

function Field(value: undefined, context: ClassFieldDecoratorContext) {
  // 여기서 context로 "string" 같은 타입을 얻고 싶지만 불가능
  console.log(context.name, context.kind)
}

class CreateUser {
  @Field
  email!: string
}

여기서 얻을 수 있는 건 이름, kind, 접근자 여부 같은 “구조 정보”에 가깝습니다. string 같은 TS 타입은 런타임에 존재하지 않습니다.

해결 1: 타입이 필요하면 “명시적으로” 전달한다

가장 현실적인 패턴은 타입(혹은 스키마)을 데코레이터 인자로 넘기는 것입니다.

type FieldType = "string" | "number" | "boolean"

function Field(type: FieldType) {
  return function (value: undefined, context: ClassFieldDecoratorContext) {
    ;(context.metadata as any).fields ??= []
    ;(context.metadata as any).fields.push({ name: context.name, type })
  }
}

class CreateUser {
  @Field("string")
  email!: string

  @Field("boolean")
  marketingOptIn!: boolean
}

여기서 context.metadata는 표준 데코레이터의 메타데이터 저장소를 활용하는 예시입니다. 다만 이 또한 “표준 리플렉션 타입”이 아니라, 데코레이터 실행 과정에서 우리가 채워 넣는 데이터입니다.

해결 2: 스키마 라이브러리와 결합한다

Zod 같은 스키마를 단일 소스로 두고, 데코레이터는 “라벨링/등록”만 하게 만들면 타입과 런타임 스키마를 일치시키기 쉽습니다.

import { z } from "zod"

const CreateUserSchema = z.object({
  email: z.string().email(),
  marketingOptIn: z.boolean()
})

type CreateUser = z.infer<typeof CreateUserSchema>

function Schema(schema: unknown) {
  return function (value: Function, context: ClassDecoratorContext) {
    ;(context.metadata as any).schema = schema
  }
}

@Schema(CreateUserSchema)
class CreateUserDto {}

이 방식은 “타입을 런타임에서 뽑아내려는 시도”를 버리고, 런타임 스키마를 기준으로 타입을 파생시키는 방향입니다.

함정 3: reflect-metadata 기대하면 조용히 망가진다

레거시 TS 데코레이터 생태계(예: 일부 DI/ORM)는 emitDecoratorMetadatareflect-metadata를 전제로 “design:type” 같은 메타데이터를 읽는 패턴이 많았습니다.

하지만 표준 데코레이터는 이 메타데이터 방출을 보장하지 않습니다. 즉, 다음 같은 코드는 표준 데코레이터 전환 시 갑자기 undefined가 되기 쉽습니다.

// 레거시 패턴 예시(표준 데코레이터에서는 기대 금물)
import "reflect-metadata"

function InspectType(target: any, propertyKey: string) {
  const t = Reflect.getMetadata("design:type", target, propertyKey)
  console.log(propertyKey, t)
}

대응 전략:

  • 표준 데코레이터로 갈아타는 구간에서는 “리플렉션 기반 자동 타입 추론”을 기능 요구사항에서 제외하는 게 안전합니다.
  • 정말 필요하다면, 빌드 단계에서 타입 정보를 추출하는 코드 생성(예: TS AST 기반)로 전환하는 편이 예측 가능합니다.

함정 4: 실행 시점과 초기화 순서(특히 필드)

표준 데코레이터는 addInitializer를 제공하고, 필드/메서드 초기화와 엮이면서 실행 순서가 미묘해질 수 있습니다. 예를 들어 인스턴스 생성 시점에 등록 로직을 넣고 싶을 때가 있습니다.

function Register(value: undefined, context: ClassFieldDecoratorContext) {
  context.addInitializer(function () {
    // 여기의 this는 인스턴스
    ;(this as any).__registered ??= []
    ;(this as any).__registered.push(context.name)
  })
}

class A {
  @Register
  x = 1

  @Register
  y = 2
}

const a = new A()
console.log((a as any).__registered) // ["x", "y"] 같은 형태

주의:

  • 초기화 순서는 클래스 필드 선언 순서, 상속 구조, 번들러 변환 방식에 영향을 받습니다.
  • 데코레이터에서 전역 레지스트리를 건드리는 경우, 서버리스/엣지 런타임에서 “요청 간 상태 공유”로 이어질 수 있어 위험합니다.

실전 가이드: 안전한 데코레이터 설계 체크리스트

1) 타입은 “보존”하거나 “명시”하라

  • 메서드 래핑은 Parameters/ReturnType 기반으로 원본 시그니처를 유지
  • 프로퍼티 타입은 런타임에 없으니, 스키마/타입 토큰을 인자로 명시

2) 메타데이터는 표준이 아니라 “내가 저장한 것”만 믿어라

  • context.metadata를 사용하더라도, 그 안의 데이터는 직접 채운 것만 신뢰
  • 레거시의 design:type 기반 자동 마법은 표준 전환 시 제거하는 게 안전

3) 프레임워크 경계(SSR/RSC/번들러)를 의식하라

  • 데코레이터가 모듈 로드 시점에 실행되는지, 인스턴스 생성 시점에 실행되는지 분리해서 설계
  • Next.js 같은 환경에서 서버/클라이언트 번들이 갈라질 때, 데코레이터가 참조하는 값이 양쪽에 존재하는지 점검

빌드/캐시 문제가 겹치면 “코드는 맞는데 배포만 실패” 같은 형태로 나타나기도 합니다. CI에서 캐시가 꼬여 재현이 어려울 때는 GitHub Actions 캐시가 안 먹을 때 키·경로·권한처럼 캐시 키와 경로를 먼저 의심하는 편이 빠릅니다.

마이그레이션 팁: 레거시 데코레이터에서 표준으로

전환 시 흔한 전략은 “한 번에 전부”가 아니라, 다음 순서로 위험을 낮추는 것입니다.

  1. 레거시 데코레이터가 의존하던 리플렉션 메타데이터 사용처를 찾는다
  2. 타입 자동 추론에 기대는 로직을 제거하고, 스키마/명시 인자로 대체한다
  3. 메서드 데코레이터는 래핑 시그니처 보존 패턴으로 통일한다
  4. 마지막에 컴파일러/트랜스파일 경로를 표준 데코레이터로 전환한다

결론

ES2024 표준 데코레이터는 “타입스크립트의 장식 문법”이 아니라, 자바스크립트 런타임 기능으로서의 데코레이터에 가깝습니다. 그래서 TypeScript에서 사용할 때 가장 큰 함정은 다음 두 가지로 수렴합니다.

  • 타입추론: 래핑 순간 시그니처가 사라지기 쉬우니 제네릭/유틸 타입으로 보존해야 한다
  • 메타데이터: 표준은 자동 타입 메타데이터를 주지 않으므로, 명시 인자/스키마/코드 생성으로 해결해야 한다

데코레이터는 강력하지만, “마법처럼 동작할 것”이라는 기대가 커질수록 유지보수 비용도 함께 커집니다. 표준 데코레이터로 넘어가는 시점에 타입과 런타임 경계를 분리해 설계하면, 이후 DI/검증/로깅 같은 횡단 관심사를 훨씬 안정적으로 확장할 수 있습니다.