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

작성일:2026.05.07|수정일:2026.05.07|조회수:17

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

DI 컨테이너에게 “이걸 넣어줘”라고 말하려면, 먼저 “이것”을 가리키는 주소가 필요하다. 사람끼리는 “그 서비스 있잖아” 정도로도 대화가 되지만, 컨테이너는 그런 눈치가 없다. 다행히 눈치 없는 도구일수록 주소 체계가 분명해야 오래 버틴다.

fluo에서 그 주소가 바로 token이다. 타입은 단순하다.

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라는 클래스 참조가 곧 컨테이너가 찾을 수 있는 token이 된다.

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';

// ServiceB 참조를 즉시 평가하지 않고 함수로 미룬다.
// 덕분에 파일 안의 선언 순서나 순환 참조 때문에 decorator 평가가 깨지지 않는다.
@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';

// MetricsService가 등록돼 있으면 주입하고, 없으면 undefined로 둔다.
// 로깅/메트릭처럼 없어도 핵심 기능이 동작해야 하는 의존성에 적합하다.
@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({
  // ILogger 토큰으로 요청해도 실제 구현체는 PinoLogger를 만든다.
  provide: ILogger,
  useClass: PinoLogger,
  // 로거는 앱 전체에서 하나만 공유해도 되는 상태 없는 서비스다.
  scope: 'singleton',
});

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

3. FactoryProvider — 팩토리 함수

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

TS
container.register({
  provide: 'DB_CONNECTION',
  // factory provider는 비동기 생성이나 외부 리소스 연결에 적합하다.
  useFactory: async (config: AppConfig) => {
    return await createConnection(config.dbUrl);
  },
  // factory 인자는 inject 배열 순서대로 resolve된다.
  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 선언은 그대로 실행되지 않는다. 먼저 token이라는 주소로 정리되고, provider라는 등록 단위로 바뀐 뒤, 컨테이너가 이해할 수 있는 NormalizedProvider로 수렴한다.

이 정규화가 있어야 뒤쪽 resolver가 덜 똑똑해질 수 있다. 좋은 의미다. resolver가 매번 “이게 class provider인가, factory인가, alias인가”를 추측하지 않아도 되기 때문이다. 다음 글에서는 이 provider들이 module 단위로 묶일 때, importsexports가 어떤 가시성 규칙을 만드는지 살펴본다.

댓글

댓글을 불러오는 중...