Published on

ES2024+ Decorators DI 구현과 TS 타입추론 함정 7가지

Authors

서버/백엔드 코드에서 DI(Dependency Injection)는 테스트 용이성, 모듈 경계, 런타임 구성(프로덕션/스테이징) 분리를 위해 자주 쓰입니다. 그동안 TS 진영에서는 reflect-metadata 기반의 레거시 데코레이터 실험 기능에 의존하는 경우가 많았는데, ES2024+ 표준 Decorators(이하 “신 데코레이터”)가 자리 잡으면서 런타임 메타데이터 없이도 꽤 괜찮은 DI를 만들 수 있게 됐습니다.

다만, “표준 Decorators로 DI를 만들면 타입도 알아서 잘 추론되겠지”라는 기대는 쉽게 깨집니다. DI는 기본적으로 런타임 레지스트리(토큰 Map)정적 타입(제네릭/조건부 타입) 을 연결하는 작업이고, 이 경계에서 TypeScript의 타입추론이 미끄러지는 지점이 많습니다.

이 글에서는 ES2024+ Decorators로 최소 DI 컨테이너를 구현한 뒤, 실무에서 자주 밟는 TS 타입추론 함정 7가지를 “왜 깨지는지 / 어떻게 고치는지” 관점으로 정리합니다. (비슷한 ‘오해로 터지는’ 패턴 정리 글로는 Rust 라이프타임 static 오해로 터지는 버그 7가지도 참고할 만합니다.)

주의: Next.js 환경에서 신 데코레이터를 쓰려면 TS/번들러 설정이 필요합니다. 또한 본문에서 부등호 문자는 MDX 빌드 이슈를 피하기 위해 모두 인라인 코드로 감쌉니다.

목표: ES2024+ Decorators로 “가벼운 DI” 만들기

핵심 아이디어는 간단합니다.

  • 주입 대상은 Token으로 식별한다
  • ContainerTokenprovider를 맵으로 들고 있다
  • @inject(token) 데코레이터는 필드(또는 accessor)에 게터를 설치해서 container.resolve(token)을 호출한다

1) 타입 안전한 Token 정의

문자열 키 대신 타입이 붙은 토큰을 쓰면 DI의 타입 안전성이 크게 좋아집니다.

// token.ts
export type Token<T> = symbol & { readonly __type?: T };

export function createToken<T>(description: string): Token<T> {
  return Symbol.for(description) as Token<T>;
}

2) Container 구현

프로바이더는 useValue, useFactory, useClass 정도만 있어도 충분합니다.

// container.ts
import type { Token } from "./token";

type Factory<T> = (c: Container) => T;

type Provider<T> =
  | { token: Token<T>; useValue: T }
  | { token: Token<T>; useFactory: Factory<T> }
  | { token: Token<T>; useClass: new (...args: any[]) => T };

export class Container {
  private providers = new Map<symbol, Provider<any>>();
  private singletons = new Map<symbol, any>();

  register<T>(p: Provider<T>): void {
    this.providers.set(p.token, p);
  }

  resolve<T>(token: Token<T>): T {
    if (this.singletons.has(token)) return this.singletons.get(token);

    const p = this.providers.get(token);
    if (!p) throw new Error(`No provider for token: ${String(token)}`);

    let value: any;
    if ("useValue" in p) value = p.useValue;
    else if ("useFactory" in p) value = p.useFactory(this);
    else value = new p.useClass();

    // 간단화를 위해 전부 singleton 캐시
    this.singletons.set(token, value);
    return value;
  }
}

export const container = new Container();

3) ES2024+ Decorators로 @inject 구현

표준 Decorators의 필드 데코레이터는 context.addInitializer를 지원합니다. 여기서는 인스턴스가 만들어질 때 주입용 프로퍼티를 정의합니다.

// inject.ts
import { container } from "./container";
import type { Token } from "./token";

export function inject<T>(token: Token<T>) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    context.addInitializer(function () {
      Object.defineProperty(this, context.name, {
        configurable: true,
        enumerable: true,
        get() {
          return container.resolve(token);
        },
      });
    });
  };
}

사용 예시는 아래처럼 됩니다.

// app.ts
import { createToken } from "./token";
import { container } from "./container";
import { inject } from "./inject";

interface Logger {
  info(msg: string): void;
}

const LOGGER = createToken<Logger>("Logger");

class ConsoleLogger implements Logger {
  info(msg: string) {
    console.log(msg);
  }
}

container.register({ token: LOGGER, useClass: ConsoleLogger });

class UserService {
  @inject(LOGGER)
  declare logger: Logger;

  run() {
    this.logger.info("hello");
  }
}

new UserService().run();

여기까지는 “깔끔해 보이는 DI”입니다. 하지만 조금만 확장하면 타입추론이 급격히 흔들립니다.

TS 타입추론 함정 7가지

아래 함정들은 단순히 “타입이 빨갛게 뜬다” 수준이 아니라, 타입이 조용히 any/unknown으로 무너져 런타임 버그 가능성을 키우는 패턴들입니다.

함정 1) 토큰을 symbol로만 두면 타입이 증발한다

다음처럼 토큰 타입을 그냥 symbol로 쓰면, resolve가 무엇을 반환하는지 TS가 알 길이 없습니다.

// 나쁜 예
type Token = symbol;

class Container {
  resolve(token: Token) {
    return this.singletons.get(token);
  }
}

이 경우 resolve 반환 타입은 대부분 any 또는 unknown으로 흐릅니다. DI의 핵심인 “주입되는 타입의 정적 보장”이 사라집니다.

해결: 본문처럼 Token<T>에 브랜드를 붙이세요.

  • Token<T>는 런타임에선 symbol
  • 컴파일 타임에만 T를 운반

이 패턴 하나로 이후 함정의 절반이 완화됩니다.

함정 2) createToken() 호출 위치가 달라지면 동일 토큰으로 인식되지 않는다

토큰은 보통 모듈 상수로 두지만, 실수로 함수 안에서 만들면 매번 다른 심볼이 생깁니다.

// 나쁜 예: 호출할 때마다 새 토큰
function getLoggerToken() {
  return createToken<Logger>("Logger");
}

container.register({ token: getLoggerToken(), useClass: ConsoleLogger });
container.resolve(getLoggerToken()); // 등록한 것과 다른 토큰

이건 타입추론 문제가 아니라 런타임 레지스트리 문제지만, TS는 이를 잡아주지 못합니다.

해결:

  • 토큰은 반드시 const LOGGER = createToken<Logger>(...)처럼 모듈 레벨 단일 인스턴스로 유지
  • 심볼 생성은 Symbol.for를 쓰더라도, “동일 description이면 동일 심볼”이라는 규칙에 기대지 말고 상수 공유를 원칙으로 하세요

함정 3) @inject 필드가 declare가 아니면 useDefineForClassFields에 따라 초기화가 꼬인다

TS의 클래스 필드 emit 옵션(useDefineForClassFields)과 데코레이터 초기화 순서가 만나면, 아래처럼 값이 덮일 수 있습니다.

class UserService {
  @inject(LOGGER)
  logger: Logger; // 초기화 코드가 emit되면 주입 getter가 덮이거나 반대로 덮일 수 있음
}

특히 필드가 “정의만 있고 초기값이 없는” 경우에도 컴파일 결과가 환경에 따라 달라질 수 있습니다.

해결:

  • 주입 필드는 declare로 선언만 하고 런타임 필드 초기화를 만들지 않게 합니다.
class UserService {
  @inject(LOGGER)
  declare logger: Logger;
}
  • 또는 accessor 기반으로 강제하는 방식도 있습니다(아래 함정 6 참고).

함정 4) container.register()에서 제네릭이 넓게 추론되면 any가 퍼진다

다음처럼 객체 리터럴을 그대로 넘기면, 상황에 따라 T가 기대보다 넓게 추론될 수 있습니다.

container.register({ token: LOGGER, useClass: ConsoleLogger });

대부분은 잘 되지만, 프로바이더를 배열로 모아 처리하거나, 조건 분기에서 합쳐지면 Provider<any>로 붕괴하는 경우가 생깁니다.

const providers = [
  { token: LOGGER, useClass: ConsoleLogger },
  // 다른 토큰들...
];

providers.forEach((p) => container.register(p)); // p가 union으로 넓어지며 any/unknown 위험

해결:

  • satisfies로 프로바이더 타입을 고정해 추론 붕괴를 막습니다.
import type { Provider } from "./container";

const p1 = {
  token: LOGGER,
  useClass: ConsoleLogger,
} satisfies Provider<Logger>;

container.register(p1);
  • 또는 register를 오버로드로 분리해 “token과 구현 타입의 결합”이 깨지지 않게 합니다.

함정 5) useFactory에서 반환 타입이 암묵적 any로 새는 패턴

useFactory는 DI에서 강력하지만, 반환 타입이 명시되지 않으면 any가 섞이기 쉽습니다.

container.register({
  token: LOGGER,
  useFactory: (c) => {
    const impl = c.resolve(SOME_TOKEN); // 여기서 SOME_TOKEN이 any면 전파
    return impl;
  },
});

특히 resolveunknown을 반환하도록 설계했거나(안전하게 하려고), 토큰 정의가 엉키면 useFactory가 “타입 구멍”이 됩니다.

해결:

  • 팩토리 반환 타입을 명시하거나, 토큰 기반으로 강제합니다.
container.register({
  token: LOGGER,
  useFactory: (c): Logger => new ConsoleLogger(),
});
  • resolve는 반드시 Token<T>를 통해 T로 좁혀지게 만들고, 토큰이 unknown으로 남지 않게 하세요.

함정 6) 필드 데코레이터만으로는 “읽기 전용/지연 주입” 타입이 자연스럽게 안 맞는다

실무에서는 다음 요구가 자주 나옵니다.

  • 주입 필드는 읽기 전용이어야 한다
  • 실제로 접근될 때만 resolve하고 싶다(지연)

필드에 getter를 설치하는 방식은 지연 주입에는 맞지만, 타입 선언을 readonly로 만들면 코드 스타일/린트 규칙과 충돌할 수 있습니다.

class UserService {
  @inject(LOGGER)
  declare readonly logger: Logger; // 선언은 readonly지만 런타임 defineProperty는 writable 개념이 다름
}

여기서 TS는 통과해도, 런타임 프로퍼티 디스크립터를 어떻게 정의하느냐에 따라 “readonly처럼 보이지만 실제로는 재정의 가능”이 됩니다.

해결: accessor(게터) 데코레이터로 형태를 고정하세요.

// inject-getter.ts
import { container } from "./container";
import type { Token } from "./token";

export function injectGetter<T>(token: Token<T>) {
  return function (
    _value: () => T,
    context: ClassGetterDecoratorContext
  ) {
    return function (this: any) {
      return container.resolve(token);
    };
  };
}
class UserService {
  @injectGetter(LOGGER)
  get logger(): Logger {
    // 데코레이터가 구현을 대체
    throw new Error("decorated");
  }
}

이 방식은 “이 프로퍼티는 getter다”가 코드 구조로 강제되어, 필드 초기화/옵션 영향도 줄고 의도도 명확합니다.

함정 7) 인터페이스 기반 DI에서 “런타임 토큰”과 “컴파일 타임 타입”을 혼동한다

다음은 DI 입문자뿐 아니라 숙련자도 자주 헷갈립니다.

  • 인터페이스 Logger는 런타임에 존재하지 않는다
  • 따라서 “타입을 토큰으로 쓰는 DI”는 TS 단독으로는 불가능하다
// 불가능한 기대
container.resolve(Logger); // Logger는 값이 아님

레거시 데코레이터 + emitDecoratorMetadata 조합에서는 “디자인 타입”을 리플렉션으로 읽어 생성자 주입을 흉내낼 수 있었지만, 표준 Decorators는 기본적으로 그런 메타데이터를 제공하지 않습니다.

해결:

  • 인터페이스는 반드시 Token<Interface>로 대응시키고, 토큰을 런타임 키로 사용
  • “토큰을 import해서 주입한다”는 규율을 팀 규칙으로 고정
export interface Logger {
  info(msg: string): void;
}

export const LOGGER = createToken<Logger>("Logger");

이 규율이 잡히면, DI 경계가 명확해지고 테스트 더블도 토큰 단위로 깔끔해집니다.

실전 구성: 테스트/환경별 바인딩 교체 패턴

DI를 쓰는 이유 중 하나는 “환경별 구현 교체”입니다.

// bootstrap.ts
import { container } from "./container";
import { LOGGER } from "./tokens";

class JsonLogger {
  info(msg: string) {
    console.log(JSON.stringify({ level: "info", msg }));
  }
}

export function bootstrap(env: "dev" | "prod") {
  if (env === "prod") {
    container.register({ token: LOGGER, useClass: JsonLogger });
  } else {
    container.register({ token: LOGGER, useValue: { info: console.log } });
  }
}

이때도 함정이 있습니다. useValue: { info: console.log }console.log 시그니처가 넓어서 Logger에 맞지 않는 경우가 생깁니다.

대응: 값 주입은 satisfies로 계약을 강제하세요.

const devLogger = {
  info: (msg: string) => console.log(msg),
} satisfies Logger;

container.register({ token: LOGGER, useValue: devLogger });

DI를 “타입 안전”하게 유지하는 체크리스트

  • 토큰은 Token<T> 브랜드로 만든다
  • 토큰은 모듈 상수로 단일화한다
  • 주입 필드는 declare 또는 getter로 강제한다
  • 프로바이더 정의는 satisfies Provider<T>로 고정한다
  • useFactory 반환 타입을 명시해 타입 구멍을 막는다
  • 인터페이스는 런타임에 없다는 전제를 팀 규칙으로 문서화한다

이런 체크리스트는 “어느 순간부터 타입이 다 any가 됐다” 같은 디버깅 시간을 크게 줄여줍니다. 운영 환경에서 원인 추적이 어려운 문제를 줄이는 관점에서는, 인프라 쪽의 진단 체크리스트 글인 systemd 타이머가 안 도는 이유 10가지 진단처럼 ‘재현 가능한 규칙’이 큰 힘을 발휘합니다.

마무리

ES2024+ 표준 Decorators는 DI 구현을 “프레임워크 의존”에서 “언어 기능 기반의 얇은 유틸”로 옮겨오게 해줍니다. 하지만 DI는 본질적으로 런타임 레지스트리와 정적 타입을 연결하는 작업이라, TypeScript 타입추론이 흔들리는 지점이 많습니다.

이 글의 7가지 함정은 대부분 토큰 설계 + 프로바이더 타입 고정 + 필드 초기화 규율로 예방할 수 있습니다. 특히 Token<T>satisfies는 작은 비용으로 큰 안정성을 주는 조합이니, DI를 직접 구현하거나 경량 컨테이너를 운영하는 팀이라면 기본 규칙으로 채택하는 것을 권합니다.

추가로 “주입 그래프가 커졌을 때 순환 의존을 어떻게 탐지할지”, “스코프(Transient/Request/Singleton)를 어떻게 타입으로 표현할지”까지 확장하면 또 다른 타입 함정이 나오는데, 원하면 그 주제로도 이어서 정리해볼 수 있습니다.