Ecosystem — DI 위에서 동작하는 패키지들

작성일:2026.05.11|조회수:10

Ecosystem — DI 위에서 동작하는 패키지들

프레임워크의 DI는 core 패키지 안에서만 의미가 있으면 부족하다. 실제 가치는 다른 패키지들이 같은 규칙 위에 올라갈 때 드러난다. CQRS는 compiled module graph를 읽어 handler를 발견하고, Config는 dynamic module과 lifecycle hook으로 런타임 설정을 제공하며, HTTP는 request scope를 이용해 요청 단위 상태를 격리한다.

즉 ecosystem은 DI의 소비자이자 검증자다. Core DI가 제공하는 token, provider, module, lifecycle, scope 규칙이 충분히 일반적이어야 여러 패키지가 각자의 문제를 풀 수 있다. 이 마지막 글은 그 연결 지점을 보는 정리편에 가깝다.

한 가지 관점을 먼저 잡고 가자. fluo 생태계 패키지는 대체로 “자기만의 작은 프레임워크”를 만들지 않는다. 대신 core/runtime/DI가 제공하는 module entrypoint, token, provider, lifecycle, request scope 위에 올라간다. 그래서 패키지가 달라도 읽는 질문은 비슷하다.

1. CQRS — 핸들러 Discovery 패턴

@fluojs/cqrs는 Command/Query/Event bus를 제공하는 패키지다. 흥미로운 점은 handler를 사용자가 직접 배열로 모두 넘기지 않아도 된다는 것이다. CQRS 패키지는 bootstrap 이후 compiled module graph를 순회하며 handler decorator가 붙은 provider를 발견한다.

Discovery의 기반은 CqrsBusBase다.

TS
export abstract class CqrsBusBase {
  protected readonly handlerInstances = new Map<Token, Promise<unknown>>();

  constructor(
    protected readonly runtimeContainer: Container,
    protected readonly compiledModules: readonly CompiledModule[],
    protected readonly logger: ApplicationLogger,
  ) {}

  protected discoveryCandidates(): DiscoveryCandidate[] {
    const candidates: DiscoveryCandidate[] = [];

    // bootstrap이 끝난 뒤의 compiled module graph를 순회한다.
    // CQRS는 별도 registry를 만들지 않고, DI에 등록된 provider/controller를 discovery source로 사용한다.
    for (const compiledModule of this.compiledModules) {
      for (const provider of compiledModule.definition.providers ?? []) {
        if (typeof provider === 'function') {
          candidates.push({
            moduleName: compiledModule.type.name,
            scope: scopeFromProvider(provider),
            targetType: provider,
            token: provider,
          });
          continue;
        }

        if (isClassProvider(provider)) {
          candidates.push({
            moduleName: compiledModule.type.name,
            scope: scopeFromProvider(provider),
            targetType: provider.useClass,
            token: provider.provide,
          });
        }
      }

      // controller도 DI가 알고 있는 class token이므로 handler decorator 탐색 후보에 포함된다.
      for (const controller of compiledModule.definition.controllers ?? []) {
        candidates.push({
          moduleName: compiledModule.type.name,
          scope: scopeFromProvider(controller),
          targetType: controller,
          token: controller,
        });
      }
    }

    return candidates;
  }
}

이 코드는 CQRS가 DI를 어떻게 소비하는지 잘 보여 준다. compiledModules는 “어떤 provider/controller 후보가 존재하는가”를 알려 준다. runtimeContainer는 “그 token의 실제 instance를 어떻게 만들 것인가”를 담당한다. Discovery와 instantiation이 분리되어 있다.

Command bus는 이 후보들 중 @CommandHandler(...) metadata가 있는 singleton provider를 찾는다.

TS
for (const candidate of this.discoveryCandidates()) {
  const metadata = getCommandHandlerMetadata(candidate.targetType);

  if (!metadata) {
    continue;
  }

  if (candidate.scope !== 'singleton') {
    this.logger.warn(
      `${candidate.targetType.name} in module ${candidate.moduleName} declares @CommandHandler() but is registered with ${candidate.scope} scope. Command handlers are registered only for singleton providers.`,
      'CommandBusLifecycleService',
    );
    continue;
  }

  // command type -> handler descriptor 등록
}

Singleton-only discovery는 임의의 제한이 아니다. Bus는 handler routing table을 만든다. Routing table에 올라간 handler는 안정적인 identity와 lifecycle을 가져야 한다. Request-scoped handler를 여기에 넣으면 command dispatch 시점마다 어떤 request container를 써야 하는지, handler cache는 어떻게 해야 하는지 같은 문제가 bus 레이어로 새어 나온다. CQRS는 이 문제를 피하기 위해 non-singleton handler를 discovery 대상에서 제외하고 경고한다.

Handler instance도 promise-first cache 패턴을 사용한다.

TS
protected async preloadHandlerInstance(token: Token): Promise<void> {
  if (this.handlerInstances.has(token)) {
    return;
  }

  // handler 인스턴스는 runtime container에서 resolve하고 Promise를 먼저 캐시한다.
  // 동시에 같은 handler를 preload해도 중복 생성하지 않기 위해서다.
  const resolving = this.runtimeContainer.resolve(token);
  this.handlerInstances.set(token, resolving);

  try {
    await resolving;
  } catch (error) {
    // 실패한 Promise는 캐시에 남기지 않아 다음 bootstrap/dispatch에서 재시도할 수 있다.
    this.handlerInstances.delete(token);
    throw error;
  }
}

이전에 봤던 singleton promise cache와 비슷한 모양이다. 중복 resolve를 막기 위해 Promise를 먼저 저장하고, 실패하면 cache에서 제거한다. 다만 여기의 cache는 container의 lifecycle cache가 아니라 bus-level handler lookup cache다. 실제 handler instance의 scope와 dependency resolution은 여전히 runtimeContainer.resolve(token)이 담당한다.

Command와 Query는 하나의 message type에 하나의 handler를 요구한다. 중복 handler가 있으면 명확한 error가 난다. Event는 여러 handler로 fan-out될 수 있고, Saga는 event를 받아 command를 실행하는 process manager 역할을 한다. 하지만 이 모든 모델의 공통 출발점은 같다. Decorator metadata를 읽고, compiled module graph에서 후보를 찾고, DI container로 실제 instance를 얻는다.

CQRS는 fluo DI의 좋은 소비자다. DI container가 provider를 만들고, runtime module graph가 visibility를 고정하며, CQRS는 그 위에서 도메인 메시지 routing이라는 자기 문제에 집중한다.

2. Config — Dynamic Module과 Lifecycle 활용

@fluojs/config는 dynamic module 패턴을 가장 직접적으로 보여 주는 패키지다. ConfigModule.forRoot(options)는 호출 시점에 module class를 만들고, options에 따라 provider 목록을 구성한다.

TS
export class ConfigModule {
  static forRoot(options?: ConfigModuleOptions): new () => ConfigModule {
    // options를 snapshot으로 고정해 module metadata가 나중에 외부 객체 변경의 영향을 받지 않게 한다.
    const loadOptions = snapshotConfigModuleOptions(options);
    // forRoot 호출마다 고유한 module class를 만들어 서로 다른 설정을 담을 수 있게 한다.
    class ConfigModuleImpl extends ConfigModule {}

    const providers: NonNullable<ModuleMetadata['providers']> = [
      {
        provide: ConfigService,
        // ConfigService는 factory에서 한 번 로드한 설정 snapshot을 감싼다.
        useFactory: () => createConfigServiceFromSnapshot(loadConfig(loadOptions)),
      },
    ];

    if (loadOptions.watch) {
      providers.push(
        {
          provide: CONFIG_MODULE_WATCH_OPTIONS,
          useValue: loadOptions,
        },
        ConfigModuleWatchManager,
      );
    }

    defineModuleMetadata(ConfigModuleImpl, {
      global: loadOptions.global ?? true,
      exports: [ConfigService],
      providers,
    });

    return ConfigModuleImpl;
  }
}

여기에는 fluo 생태계 패키지에서 자주 보이는 패턴이 모여 있다.

  1. forRoot()는 익명 module class를 만든다. class ConfigModuleImpl extends ConfigModule {}는 호출마다 독립적인 module type을 제공한다. 같은 앱에서 서로 다른 options로 module을 만들더라도 metadata가 충돌하지 않게 하기 위한 방식이다.
  2. options를 즉시 snapshot한다. snapshotConfigModuleOptions(options)는 caller가 넘긴 객체를 복사해 module definition 안에 고정한다. forRoot() 이후 caller가 options object를 변경해도 이미 만들어진 module graph에는 영향을 주지 않는다. Bootstrap graph는 외부 mutable object에 흔들리지 않아야 한다.
  3. 조건부 provider 등록을 사용한다. watch가 켜진 경우에만 ConfigModuleWatchManager와 watch options token을 등록한다. 모든 앱에 watcher provider를 싣지 않고, 필요한 앱에서만 provider graph를 확장한다.
  4. export surface가 작다. 기본적으로 ConfigService만 export한다. 내부 option token인 CONFIG_MODULE_WATCH_OPTIONS는 symbol token으로 숨겨져 있고, 외부 소비자는 service contract만 사용한다.

Watch mode는 lifecycle hook과 연결된다.

TS
@Inject(ConfigService, CONFIG_MODULE_WATCH_OPTIONS)
class ConfigModuleWatchManager {
  private reloader: ConfigReloader | undefined;
  private reloadForwarder: ConfigReloadSubscription | undefined;

  onApplicationBootstrap(): void {
    if (!this.options.watch) {
      return;
    }

    if (this.reloader) {
      return;
    }

    this.reloader = createConfigReloader(this.options);
    this.reloadForwarder = this.reloader.subscribe((snapshot) => {
      const previousConfig = this.config.snapshot();

      try {
        replaceConfigServiceSnapshotUnchecked(this.config, snapshot);
      } catch (error: unknown) {
        replaceConfigServiceSnapshotUnchecked(this.config, previousConfig);
        throw error;
      }
    });
  }

  onModuleDestroy(): void {
    this.reloadForwarder?.unsubscribe();
    this.reloadForwarder = undefined;
    this.reloader?.close();
    this.reloader = undefined;
  }
}

onApplicationBootstrap()에서 watcher를 시작하고, onModuleDestroy()에서 subscription과 watcher를 정리한다. P5/P8에서 본 lifecycle과 disposal 규칙이 그대로 쓰인다.

Config reload의 흥미로운 점은 ConfigService instance identity를 유지한다는 것이다. Consumer들은 이미 주입받은 ConfigService를 계속 들고 있다. Reload가 성공하면 같은 service object 안의 snapshot만 교체된다. 실패하면 이전 snapshot으로 rollback한다. Provider를 다시 등록하거나 container를 재구성하지 않는다.

ConfigReloadModule은 이 패턴을 더 명시적인 injectable reload layer로 확장한다.

TS
export const CONFIG_RELOADER = Symbol('fluo.config.reloader');

export class ConfigReloadModule {
  static forRoot(options?: ConfigLoadOptions): new () => ConfigReloadModule {
    const loadOptions = snapshotConfigLoadOptions(options);
    class ConfigReloadModuleImpl extends ConfigReloadModule {}

    defineModuleMetadata(ConfigReloadModuleImpl, {
      exports: [CONFIG_RELOADER],
      providers: [
        { provide: CONFIG_RELOAD_OPTIONS, useValue: loadOptions },
        ConfigReloadManager,
        { provide: CONFIG_RELOADER, useExisting: ConfigReloadManager },
      ],
    });

    return ConfigReloadModuleImpl;
  }
}

여기서는 useExisting alias가 나온다. 외부에는 CONFIG_RELOADER token을 export하지만, 실제 구현은 ConfigReloadManager다. DI의 alias provider가 package API boundary를 만드는 데 사용되는 예다.

3. HTTP — Request Scope의 실제 사용

@fluojs/http는 request scope가 실제로 쓰이는 대표적인 패키지다. HTTP 요청이 들어오면 dispatcher는 해당 요청이 request-scoped DI를 필요로 하는지 판단하고, 필요하면 request child container를 만든다.

TS
function createRootDispatchScope(rootContainer: Container): DispatchScope {
  return {
    // guard/interceptor가 없으면 root container를 그대로 써 singleton 경로를 유지한다.
    container: rootContainer,
    requestScoped: false,
  };
}

function createRequestDispatchScope(rootContainer: Container): DispatchScope {
  return {
    // 요청별 상태가 필요하면 child container를 만들어 request-scoped provider를 격리한다.
    container: rootContainer.createRequestScope(),
    requestScoped: true,
  };
}

Request scope가 필요한지 판단하는 plan도 있다.

TS
function compileHandlerExecutionPlan(
  handler: HandlerDescriptor,
  options: CreateDispatcherOptions,
): CompiledHandlerExecutionPlan {
  const routeGuards = handler.route.guards ?? [];
  const requestScope = compileMiddlewareScopePlan(handler.metadata.moduleMiddleware);
  const mergedInterceptors = mergeInterceptors(options.interceptors ?? [], handler.route.interceptors ?? []);

  return {
    mergedInterceptors,
    requestScope,
    // true면 요청마다 child container를 만들고 dispatch 후 dispose한다.
    requiresRequestScope:
      routeGuards.length > 0
      || mergedInterceptors.length > 0
      || requestScope.alwaysRequiresRequestScope
      || requestDtoMayRequireRequestScope(handler, options)
      || handlerMethodMayUseRequestContext(handler)
      || (hasRequestScopeInspector(options.rootContainer)
        ? options.rootContainer.hasRequestScopedDependency(handler.controllerToken)
        : true),
    routeGuards,
  };
}

여기서 HTTP는 DI container의 hasRequestScopedDependency()를 사용한다. P6에서 본 request scope pre-flight verdict가 HTTP dispatcher의 실행 계획에 직접 연결된다.

Request scope가 필요한 이유는 다양하다.

반대로 singleton-only handler라면 request child container를 만들 필요가 없다. HTTP dispatcher는 이런 경우 root container 경로를 사용할 수 있다. 이 최적화는 public contract가 아니라 내부 실행 계획이다. Public RequestContext.container 접근은 항상 request-scoped provider resolution이 안전하도록 promotion wrapper를 거친다.

Fast-path eligibility checker도 같은 판단을 사용한다.

TS
function determineRequestScopeRequirement(
  handler: HandlerDescriptor,
  options: CreateDispatcherOptions,
): boolean {
  // guard/interceptor/middleware는 요청별 principal, header, body에 접근할 수 있으므로 request scope가 안전하다.
  if (handler.route.guards && handler.route.guards.length > 0) {
    // inspector가 없으면 singleton으로 단정하지 않고 보수적으로 request scope를 사용한다.
  return true;
  }
  if (handler.route.interceptors && handler.route.interceptors.length > 0) {
    return true;
  }
  if (handler.metadata.moduleMiddleware && handler.metadata.moduleMiddleware.length > 0) {
    return true;
  }
  if (requestDtoMayRequireRequestScope(handler, options)) {
    return true;
  }
  if (hasRequestScopeInspector(options.rootContainer)) {
    // DI container가 controller 의존성 그래프에 request scope가 섞였는지 판정한다.
    return options.rootContainer.hasRequestScopedDependency(handler.controllerToken);
  }
  return true;
}

즉 HTTP 패키지는 DI를 단순히 “controller를 resolve하는 도구”로만 쓰지 않는다. DI graph 분석 결과를 request execution plan과 fast-path 판단에 사용한다.

이 구조의 장점은 책임 분리다. Core DI는 scope와 cache, request child, disposal을 제공한다. HTTP는 요청이라는 boundary에서 그 기능을 호출한다. Request가 끝나면 request container를 dispose해 request-scoped instance와 cleanup hook을 정리한다. Core는 HTTP를 몰라도 되고, HTTP는 DI 내부 cache 구현을 몰라도 된다.

4. 생태계 전반의 DI 활용 패턴

CQRS, Config, HTTP를 보면 fluo ecosystem의 공통 패턴이 보인다.

Module facade: forRoot() 중심의 entrypoint

Application-facing registration은 보통 XModule.forRoot(...), forRootAsync(...), forFeature(...), register(...) 같은 namespace facade로 노출된다. 앱 코드는 저수준 provider assembly 함수를 직접 호출하지 않는다.

TS
@Module({
  imports: [
    // Config/CQRS 모두 DI module entrypoint를 제공하므로 AppModule은 import만으로 기능을 조립한다.
    ConfigModule.forRoot({ envFile: '.env' }),
    CqrsModule.forRoot(),
  ],
})
class AppModule {}

이 패턴은 app module만 읽어도 runtime capability를 알 수 있게 만든다. Config가 들어왔는지, CQRS bus가 들어왔는지, HTTP platform adapter가 무엇인지가 imports surface에 드러난다.

Symbol token으로 내부 경계 보호

패키지 내부 옵션이나 runtime bridge는 Symbol() token을 자주 사용한다.

Symbol token은 문자열 충돌을 피하고, package 내부 provider boundary를 명확히 한다. 외부에 공개할 token은 export하고, 내부 implementation detail은 module 안에 숨긴다.

Lifecycle hook으로 리소스 소유권 표현

Config watcher, CQRS discovery, event bus, scheduler, socket adapter 같은 패키지는 bootstrap/shutdown 시점이 중요하다. fluo 생태계는 이를 lifecycle hook으로 표현한다.

이 방식은 리소스 소유권을 provider 안에 둔다. Module이 provider를 등록하고, container lifecycle이 provider cleanup 순서를 관리한다.

Runtime bridge token

CQRS처럼 runtime 정보를 필요로 하는 패키지는 RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER 같은 runtime-provided token을 주입받는다.

TS
@Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER)
export class CommandBusLifecycleService extends CqrsBusBase {
  // compiled modules를 읽어 handler를 발견하고,
  // runtime container로 handler instance를 resolve한다.
}

이 패턴은 feature package가 runtime internals에 직접 import로 강하게 묶이지 않게 한다. Runtime은 필요한 값을 provider token으로 노출하고, feature package는 DI를 통해 받는다.

Request scope는 HTTP만의 기능이 아니다

HTTP가 request scope의 대표 소비자이지만, 개념 자체는 더 일반적이다. Message handler, job processor, websocket event 처리처럼 독립된 실행 context가 있다면 같은 모델을 사용할 수 있다.

핵심은 “짧은 lifetime의 상태를 root singleton에 섞지 않는다”는 것이다. Request scope는 per-request state를 isolated child container에 넣고, 처리 후 dispose한다. 이 모델은 transport가 무엇이든 적용할 수 있다.

마치며

fluo가 emitDecoratorMetadatareflect-metadata에 기대지 않는 것은 단순한 기술 취향이 아니다. 의존성 그래프를 코드에 명시적으로 드러내겠다는 선택이다. Token은 명시적으로 주입하고, module boundary는 imports/exports로 드러내며, scope는 lifetime을 분명히 하고, 테스트는 rootModule에서 시작한다.

지금까지 작성된 여러 포스트를 관통하는 핵심은 “명시성”이었다. 명시적인 DI는 처음에는 조금 더 많은 코드를 요구하지만, 대신 graph를 읽고 테스트하고 디버깅하기 쉽게 만든다. 생태계 패키지들도 이 원칙을 공유하기 때문에 core와 feature package 사이의 경계가 예측 가능하다.

앞으로 새로운 fluo 패키지를 읽을 때도 같은 질문으로 접근하면 된다.

이 질문에 답할 수 있다면 fluo DI 위에 올라간 패키지를 훨씬 쉽게 이해할 수 있다. DI는 이 시리즈의 주제였지만, 동시에 fluo 생태계 전체를 읽는 공통 언어이기도 하다.

더 읽어보기

댓글

댓글을 불러오는 중...