Token과 Provider — DI의 주소 체계와 등록 단위

작성일:2026.05.07|조회수:14

Token과 Provider — DI의 주소 체계와 등록 단위

Token — DI의 주소 체계

DI 컨테이너에게 "무언가를 주입해달라"고 요청할 때, 그 "무언가"를 식별하는 것이 토큰이다. fluo의 토큰 타입은 단순하다.

TS
// packages/core/src/types.ts
export type Token<T = unknown> = string | symbol | Constructor<T>;

세 가지뿐이다. 문자열, 심볼, 그리고 클래스 생성자.

TS
// 문자열 토큰 — 환경변수나 설정값에 적합
const DB_URL = 'DATABASE_URL';

// 심볼 토큰 — 충돌 없는 전역 식별자
const LOGGER = Symbol('LOGGER');

// 클래스 토큰 — 클래스 자체가 토큰
class UsersService {}

세 가지 중 클래스 토큰이 가장 흥미롭다. 클래스는 런타임에도 살아있는 값이기 때문에 별도 등록 없이 그 자체로 주소가 된다. typeof UsersService나 별도 심볼 없이, 그냥 UsersService라는 클래스 참조 자체가 토큰이다.

Constructor 타입의 설계 이유

토큰으로 사용되는 클래스는 Constructor<T> 타입으로 정의된다.

TS
// packages/core/src/types.ts
export type Constructor<T = unknown> = new (...args: any[]) => T;

파라미터 목록이 any[]인 것이 의도적이다. 코드 주석에 이유가 명시돼 있다. unknown[]으로 정의하면 [string]unknown[]에 할당되지 않는 TypeScript의 공변성 규칙 때문에 생성자 파라미터가 있는 클래스를 토큰으로 쓸 수 없게 된다. NestJS, Angular, tsyringe, inversify 모두 같은 이유로 any[]를 쓴다.

forwardRef — 선언 순서 문제 해결사

TypeScript 파일에서 두 클래스가 서로를 참조할 때, 선언 순서에 따라 한쪽이 아직 정의되지 않은 시점에 다른 쪽의 @Inject()가 평가될 수 있다. forwardRef()는 이 문제를 해결한다.

TS
import { forwardRef } from '@fluojs/di';
import { Inject } from '@fluojs/core';

@Inject(forwardRef(() => ServiceB))
class ServiceA {
  constructor(private readonly b: ServiceB) {}
}

class ServiceB {
  status() { return 'ready'; }
}

구현은 간단하다.

TS
// packages/di/src/types.ts
export type ForwardRefFn<T = unknown> = { __forwardRef__: true; forwardRef: () => Token<T> };

export function forwardRef<T = unknown>(fn: () => Token<T>): ForwardRefFn<T> {
  return { __forwardRef__: true, forwardRef: fn };
}

forwardRef(fn)은 토큰을 즉시 평가하지 않고 함수로 감싸두기만 한다. 컨테이너가 실제로 의존성을 resolve할 시점에 fn()을 호출해서 토큰을 꺼낸다. 그리고 그 결과는 WeakMap에 캐시해서 중복 호출을 피한다.

여기서 한 가지 주의할 점은, forwardRef()선언 순서 문제만 해결한다는 것이다. 진짜 순환 의존성 — A의 생성자가 B를 필요로 하고, B의 생성자가 A를 필요로 하는 경우 — 은 여전히 CircularDependencyError를 던진다. forwardRef()로 토큰 lookup을 지연해도 A를 만들려면 B가 있어야 하고, B를 만들려면 A가 있어야 한다는 사실 자체는 바뀌지 않는다.

optional — 없으면 undefined

어떤 의존성은 반드시 있어야 하는 것이 아닐 수 있다. optional()로 감싸면 해당 토큰이 컨테이너에 등록되지 않았을 때 에러 대신 undefined가 주입된다.

TS
import { optional } from '@fluojs/di';
import { Inject } from '@fluojs/core';

@Inject(optional(MetricsService))
class UsersService {
  constructor(private readonly metrics?: MetricsService) {}
}
TS
// packages/di/src/types.ts
export type OptionalToken<T = unknown> = { __optional__: true; token: Token<T> };

export function optional<T = unknown>(token: Token<T>): OptionalToken<T> {
  return { __optional__: true, token };
}

내부적으로 컨테이너는 optional 토큰에 대해 보수적으로 행동한다. scope 분석 단계에서 optional 의존성이 등록되지 않았을 경우, 그 의존성이 request-scope를 요구한다고 가정하지 않는다. 있을 수도 없을 수도 있는 의존성 때문에 전체 scope가 끌어올려지는 것을 막기 위해서다.

Provider — 등록 단위의 다섯 가지 형태

토큰이 "주소"라면, provider는 그 주소에 "무엇을 배달할지"를 정의하는 단위다. fluo는 다섯 가지 형태를 지원한다.

1. ClassType — 클래스 그 자체

가장 간단한 형태. 클래스 생성자를 직접 넘기면, 그 클래스가 토큰이자 구현체가 된다.

TS
container.register(UsersService);
// 토큰: UsersService, 구현: UsersService 인스턴스

inject 목록과 scope는 클래스에 붙은 @Inject()/@Scope() 메타데이터에서 읽어온다.

2. ClassProvider — 토큰과 구현체를 분리

인터페이스 토큰에 다른 클래스를 연결할 때 쓴다.

TS
container.register({
  provide: ILogger,
  useClass: PinoLogger,
  scope: 'singleton',
});

inject를 직접 명시하거나, 생략하면 useClass의 메타데이터에서 읽어온다. scope도 직접 명시하거나, 생략하면 useClass의 메타데이터 → 기본값(singleton) 순으로 결정된다.

3. FactoryProvider — 팩토리 함수

비동기 초기화가 필요하거나 동적으로 값을 만들어야 할 때 쓴다.

TS
container.register({
  provide: 'DB_CONNECTION',
  useFactory: async (config: AppConfig) => {
    return await createConnection(config.dbUrl);
  },
  inject: [AppConfig],
  scope: 'singleton',
});

resolverClass 필드가 있으면 그 클래스의 scope 메타데이터를 factory의 기본 scope로 활용한다. dynamic module에서 팩토리의 scope를 클래스 기반으로 제어할 때 유용하다.

4. ValueProvider — 이미 만들어진 값

인스턴스 생성 없이 기존 객체를 그대로 주입한다.

TS
container.register({
  provide: 'CONFIG',
  useValue: { port: 3000, host: 'localhost' },
});

scope는 항상 singleton이다. 값이 이미 존재하기 때문에 생명주기를 컨테이너가 관리할 필요가 없다.

5. ExistingProvider — 별칭(alias)

이미 등록된 토큰을 다른 이름으로 참조한다.

TS
container.register(PinoLogger);
container.register({
  provide: ILogger, // ILogger로 resolve해도 PinoLogger 인스턴스가 반환된다
  useExisting: PinoLogger,
});

alias 체인은 여러 단계를 거칠 수 있다. 컨테이너는 resolveEffectiveProvider()를 통해 최종 구현체를 찾을 때까지 체인을 따라간다. 단, 체인에 순환이 있으면 CircularDependencyError를 던진다.

multi provider — 같은 토큰에 여러 구현체

multi: true를 붙이면 같은 토큰에 여러 provider를 쌓을 수 있다. resolve 결과는 배열이 된다.

TS
container.register(
  { provide: 'MIDDLEWARE', useClass: LoggingMiddleware, multi: true },
  { provide: 'MIDDLEWARE', useClass: AuthMiddleware, multi: true },
  { provide: 'MIDDLEWARE', useClass: CorsMiddleware, multi: true },
);

const middlewares = await container.resolve('MIDDLEWARE');
// → [LoggingMiddleware 인스턴스, AuthMiddleware 인스턴스, CorsMiddleware 인스턴스]

내부적으로 컨테이너는 단일 provider와 multi provider를 별도 Map으로 관리한다.

TS
private readonly registrations = new Map<Token, NormalizedProvider>();
private readonly multiRegistrations = new Map<Token, NormalizedProvider[]>();

override 시 multi provider는 기존 목록 전체를 교체한다. 새로 넘기는 provider 배열이 기존 목록을 완전히 대체한다. 기존 목록에 하나만 추가하거나 제거하는 방식은 없다. 이 설계 이유는 다음 편에서 다룰 override 정책과 연결된다.

NormalizedProvider — 컨테이너의 내부 언어

개발자가 선언한 다섯 가지 형태는 컨테이너 내부에서 모두 NormalizedProvider로 변환된다.

TS
// packages/di/src/types.ts
export interface NormalizedProvider<T = unknown> {
  inject: Array<Token | ForwardRefFn | OptionalToken>;
  provide: Token<T>;
  scope: Scope;
  type: 'class' | 'factory' | 'value' | 'existing';
  useClass?: ClassType<T>;
  useFactory?: (...deps: unknown[]) => MaybePromise<T>;
  useValue?: T;
  useExisting?: Token;
  multi?: boolean;
}

모든 provider 형태를 하나의 구조체로 통일하는 것이다. 컨테이너 내부의 resolution, caching, disposal 로직은 이 형태만 다루면 된다.

변환은 normalizeProvider()가 담당한다. 각 provider 형태별로 다른 기본값 결정 로직이 있다.

TS
// ClassType → inject와 scope를 메타데이터에서 읽어온다
if (isClassConstructor(provider)) {
  const metadata = getClassDiMetadata(provider);

  return {
    inject: (metadata?.inject ?? []).map(normalizeInjectToken),
    provide: provider,
    scope: metadata?.scope ?? Scope.DEFAULT,
    type: 'class',
    useClass: provider,
  };
}

// ClassProvider → inject 우선순위: provider.inject > useClass 메타데이터 > []
if (isClassProvider(provider)) {
  assertObjectProvider(provider);

  if (typeof provider.useClass !== 'function') {
    throw new InvalidProviderError('Class provider useClass must be a constructor.', { token: provider.provide });
  }

  const metadata = getClassDiMetadata(provider.useClass);

  return {
    inject: (provider.inject ?? metadata?.inject ?? []).map(normalizeInjectToken),
    multi: provider.multi,
    provide: provider.provide,
    scope: provider.scope ?? metadata?.scope ?? Scope.DEFAULT,
    type: 'class',
    useClass: provider.useClass,
  };
}

특히 ClassProvider의 inject 우선순위가 눈에 띈다. provider.inject를 직접 명시했으면 그것이 최우선이고, 없으면 useClass@Inject() 메타데이터를 읽고, 그것도 없으면 빈 배열이 된다. 즉, 같은 클래스를 다른 토큰으로 등록할 때 inject 목록을 달리 지정하는 것도 가능하다.

normalizeInjectToken()이 null/undefined 토큰을 등록 시점에 차단한다. 순환 참조나 선언 오류로 토큰이 undefined가 된 채로 등록되는 것을 막기 위해서다. 이 에러가 발생했다면 forwardRef()가 필요한 상황일 가능성이 높다.

정리

지금까지 살펴본 것들이 DI 흐름의 입력 레이어다. 개발자가 작성한 클래스와 provider 선언이 어떻게 컨테이너가 이해할 수 있는 형태로 변환되는지를 보았다.

다음 편에서는 이 provider들이 어떻게 모듈 단위로 묶이고, imports/exports/global이 어떤 가시성 규칙을 만드는지에 대해 이야기해보려 한다.

더 읽어보기

댓글

댓글을 불러오는 중...