Instance Creation — resolve에서 new까지

작성일:2026.05.10|조회수:4

Instance Creation — resolve에서 new까지

이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막 장면은 new MyClass(...deps) 또는 provider.useFactory(...deps)다.

이 단계는 DI 컨테이너에서 가장 체감되는 부분이다. Module Graph와 Resolution Planning이 “무엇을 만들 수 있는가”를 결정했다면, Instance Creation은 “지금 이 token 요청을 실제 값으로 바꾸는 법”을 실행한다. 그래서 여기에는 순환 감지, provider 선택, scope routing, cache hit, dependency resolution, optional()/forwardRef() 처리, factory/class 실행이 한 흐름 안에 모여 있다.

먼저 짚고 가야 할 점이 한 가지 있다. fluo에서 instance creation은 단순한 new 호출이 아니다. new는 마지막 순간에만 등장한다. 그 전에는 컨테이너가 아직 살아 있는지, token이 현재 resolution chain 안에 이미 들어와 있는지, provider가 local인지 parent에서 보이는지, transient인지 cached scope인지, singleton이 request-scoped 의존성을 붙잡으려는지까지 확인한다.

이 글의 관심사는 “어떤 객체가 만들어지는가”보다 “객체를 만들기 전 어떤 안전장치와 분기들이 순서대로 지나가는가”에 가깝다. 이 순서를 이해하면 fluo DI의 에러 메시지, 캐시 동작, request scope 제약이 훨씬 덜 마법처럼 보인다.

1. resolve()의 진입점

공개 API는 작다. 사용자는 container.resolve(Token)을 호출하고, 컨테이너는 Promise로 인스턴스를 돌려준다. 하지만 이 작은 입구에서 두 가지 중요한 일이 시작된다.

TS
async resolve<T>(token: Token<T>): Promise<T> {
  if (this.isDisposedInHierarchy()) {
    throw new ContainerResolutionError(
      'Container has been disposed and can no longer resolve providers.',
      { token, hint: 'Ensure all resolves complete before calling container.dispose().' },
    );
  }

  return this.resolveWithChain(token, [], new Set<Token>());
}

첫 번째는 dispose 상태 검사다. 컨테이너가 이미 dispose되었거나, 부모 컨테이너가 dispose된 상태라면 더 이상 provider를 resolve할 수 없다. 이는 단순한 방어 코드가 아니라 lifecycle boundary다. dispose 이후의 컨테이너는 더 이상 “살아 있는 object graph”를 대표하지 않기 때문이다.

두 번째는 resolution context를 새로 만드는 일이다. resolveWithChain(token, [], new Set<Token>())에서 빈 배열과 빈 Set이 생성된다.

둘을 분리한 이유는 역할이 다르기 때문이다. chain은 에러 메시지 품질을 위한 자료구조다. ServiceA -> ServiceB -> ServiceA 같은 경로를 보여 주려면 순서가 필요하다. 반면 activeTokens는 알고리즘을 위한 자료구조다. “이 token이 현재 생성 중인가?”라는 질문에는 배열 순회보다 Set membership이 더 직접적이다.

또 하나 중요한 점은 이 상태들이 컨테이너 필드가 아니라 resolve 호출의 인자로 흐른다는 것이다. 한 번의 resolve 작업에서만 유효한 임시 상태가 컨테이너의 장기 상태와 섞이지 않는다. 따라서 동시에 여러 resolve가 발생해도 각 호출은 자기만의 chainactiveTokens를 가진다.

정리하면 resolve()는 실제 생성 로직을 거의 수행하지 않는다. 대신 컨테이너 생존 여부를 확인하고, 이후 재귀 resolution이 사용할 추적 상태를 초기화한 뒤 내부 파이프라인으로 넘긴다.

2. resolveWithChain: 순환 감지 관문

resolveWithChain()은 provider를 실제로 찾거나 인스턴스를 만들기 전에, 현재 요청이 이미 진행 중인 생성 체인으로 다시 들어가는지 먼저 확인한다. 이 함수는 이름 그대로 “chain을 보존한 채 resolve한다”는 의미를 갖지만, 실질적으로는 순환 의존성 감지의 첫 번째 관문이다.

TS
private async resolveWithChain<T>(
  token: Token<T>,
  chain: Token[],
  activeTokens: Set<Token>,
  allowForwardRef = false,
): Promise<T> {
  const cachedForwardRef = this.resolveForwardRefCircularDependency(
    token,
    chain,
    activeTokens,
    allowForwardRef,
  );

  if (cachedForwardRef !== undefined) {
    return (await cachedForwardRef) as T;
  }

  return await this.resolveFromRegisteredProviders(token, chain, activeTokens);
}

여기서 중요한 점은 순환 감지가 provider 해석보다 먼저 실행된다는 것이다. 아직 token이 class provider인지, factory provider인지, alias provider인지 모른다. local registration인지 parent에서 온 provider인지도 아직 모른다. 그보다 먼저 보는 것은 단 하나다.

지금 resolve하려는 token이 현재 construction chain 안에서 이미 active한가?

이미 active하다면 “아직 생성이 끝나지 않은 값을 다시 요구했다”는 뜻이다. 이 경우 fluo는 더 내려가지 않고 CircularDependencyError를 던진다. 이런 구조 덕분에 class, factory, alias, multi provider가 모두 같은 순환 감지 규칙을 공유한다.

forwardRef()도 이 규칙을 우회하지 않는다. forwardRef()는 선언 순서 문제를 해결하기 위해 token 조회 시점을 늦추는 장치다. 예를 들어 ServiceA를 선언하는 시점에 ServiceB가 아직 정의되지 않았다면 forwardRef(() => ServiceB)가 도움이 된다. 하지만 ServiceA 생성자가 ServiceB를 요구하고, ServiceB 생성자가 다시 ServiceA를 요구하는 실제 생성 순환은 해결하지 못한다.

TS
private resolveForwardRefCircularDependency(
  token: Token,
  chain: Token[],
  activeTokens: Set<Token>,
  allowForwardRef: boolean,
): Promise<unknown> | undefined {
  if (!activeTokens.has(token)) {
    return undefined;
  }

  if (allowForwardRef) {
    throw new CircularDependencyError(
      [...chain, token],
      'forwardRef only defers token lookup and does not resolve true circular construction.',
    );
  }

  throw new CircularDependencyError([...chain, token]);
}

allowForwardRef라는 이름만 보면 “forwardRef면 허용한다”처럼 읽힐 수 있지만, 실제 의미는 다르다. 이 플래그는 “이번 재귀 edge가 forwardRef에서 왔다”는 진단용 표시다. 이미 active한 token을 다시 만나면 일반 순환과 마찬가지로 실패하고, forwardRef 경로였을 때만 더 정확한 설명을 붙인다.

이 구분은 문서화할 가치가 있다. forwardRef()는 lazy token lookup이지 lazy instance proxy가 아니다. 부분 초기화된 객체를 넘겨 주지도 않고, 서로의 constructor 완료를 기다리는 구조를 만들어 주지도 않는다. Fluo는 declaration-order 문제와 construction-time cycle 문제를 분리해서 다룬다.

3. resolveFromRegisteredProviders: 분기 라우팅

순환 검사를 통과하면 이제 provider를 어떻게 해석할지 결정한다. resolveFromRegisteredProviders()는 실제로 객체를 만드는 함수라기보다, provider 종류와 scope에 따라 다음 경로를 고르는 라우터에 가깝다.

TS
private async resolveFromRegisteredProviders<T>(
  token: Token<T>,
  chain: Token[],
  activeTokens: Set<Token>,
): Promise<T> {
  const localSingleProvider = this.registrations.get(token);

  if (!localSingleProvider) {
    const multiProviders = this.collectMultiProviders(token);

    if (multiProviders.length > 0) {
      const instances = await this.withTokenInChain(token, chain, activeTokens, async (c, at) =>
        this.resolveMultiProviderInstances(multiProviders, c, at),
      );

      return instances as T;
    }
  }

  const provider = this.requireProvider(token);
  const existingTarget = this.resolveExistingProviderTarget(provider);

  if (existingTarget !== undefined) {
    return await this.resolveAliasTarget(existingTarget as Token<T>, token, chain, activeTokens);
  }

  if (provider.scope === 'transient') {
    return (await this.withTokenInChain(token, chain, activeTokens, async (c, at) =>
      this.instantiate(provider, c, at),
    )) as T;
  }

  const cachedInstance = this.getCachedScopedOrSingletonInstance(provider);

  if (cachedInstance) {
    return (await cachedInstance) as T;
  }

  return (await this.withTokenInChain(token, chain, activeTokens, async (c, at) =>
    this.resolveScopedOrSingletonInstance(provider, c, at),
  )) as T;
}

분기 순서를 보면 resolver의 우선순위가 드러난다.

  1. 로컬 single provider가 없으면 multi provider를 모은다.
  2. multi provider가 있으면 배열 인스턴스를 만들어 반환한다.
  3. single provider가 필요하면 requireProvider()로 찾고, 없으면 ContainerResolutionError를 던진다.
  4. alias provider라면 target token으로 redirect한다.
  5. transient provider라면 cache 없이 즉시 instantiate한다.
  6. singleton/request provider라면 cache hit를 먼저 보고, 없을 때만 생성 경로로 내려간다.

이 순서가 중요한 이유는 각 provider shape의 public contract가 여기서 고정되기 때문이다. Multi provider는 token 하나가 값 하나가 아니라 값 배열로 해석되는 케이스다. Alias provider는 자기 자신을 만들지 않고 target token의 resolved value를 공유한다. Transient provider는 cache를 보지 않아야 “resolve할 때마다 새 인스턴스”라는 의미가 유지된다. Singleton/request provider는 cache를 거쳐야 생애주기 계약이 지켜진다.

withTokenInChain()이 감싸는 위치도 눈여겨볼 만하다. token을 active 상태로 표시하는 범위는 “이 token을 실제로 해석하는 동안”이다. Multi provider 배열을 만들 때도 token을 chain에 넣고, transient를 instantiate할 때도 넣고, scoped/singleton cache miss를 채울 때도 넣는다. 반대로 단순 cache hit는 이미 만들어진 Promise를 반환하므로 새 construction chain을 열 필요가 없다.

이 함수는 짧지만 DI 컨테이너의 실행 의미를 압축하고 있다. provider lookup, alias redirect, scope routing, cache policy가 한 번에 결정되는 지점이다.

4. resolveScopedOrSingletonInstance: Promise 선저장 캐싱

Singleton과 request-scoped provider는 매번 새로 만들면 안 된다. 같은 scope 안에서는 같은 인스턴스를 재사용해야 한다. 그런데 JavaScript 환경에서는 factory provider가 async일 수 있다. 즉 “인스턴스가 이미 있는가?”뿐 아니라 “인스턴스를 만드는 중인가?”도 캐시해야 한다.

fluo는 이 문제를 Promise<unknown>을 캐시에 저장하는 방식으로 해결한다.

TS
private async resolveScopedOrSingletonInstance(
  provider: NormalizedProvider,
  chain: Token[],
  activeTokens: Set<Token>,
): Promise<unknown> {
  if (this.shouldResolveFromRoot(provider)) {
    return await this.root().resolveScopedOrSingletonInstance(provider, chain, activeTokens);
  }

  const cache = this.cacheFor(provider);

  if (!cache.has(provider.provide)) {
    const promise = this.instantiate(provider, chain, activeTokens).catch((error: unknown) => {
      cache.delete(provider.provide);
      throw error;
    });

    cache.set(provider.provide, promise);
  }

  return cache.get(provider.provide);
}

핵심은 await보다 cache.set()이 먼저라는 점이다. 첫 번째 resolve가 instantiate()를 시작하면, 아직 완료되지 않았더라도 그 Promise가 cache에 들어간다. 같은 token에 대한 두 번째 resolve가 곧바로 들어오면 새 인스턴스를 만들지 않고 같은 Promise를 받는다.

이 패턴은 singleton race condition을 막는다. 예를 들어 DB connection provider가 async factory로 생성된다고 하자. 두 요청이 거의 동시에 Database token을 resolve하면, cache가 instance 완료 후에만 채워지는 구조에서는 factory가 두 번 실행될 수 있다. fluo는 “생성 작업” 자체를 먼저 공유하기 때문에 이런 중복 초기화를 피한다.

실패 처리도 중요하다. instantiate()가 reject되면 catch handler가 cache entry를 지운다. 실패한 Promise가 singleton cache에 남아 있으면 이후 모든 resolve가 같은 실패를 재사용하게 된다. fluo는 실패를 영구 캐시하지 않는다. 다음 resolve는 다시 생성 시도를 할 수 있다.

shouldResolveFromRoot(provider)는 request-scope child에서 root singleton을 재사용하기 위한 위임 규칙이다. request container가 parent에서 보이는 singleton provider를 resolve할 때, child cache에 새 singleton을 만들면 안 된다. root로 위임해야 application-wide singleton identity가 유지된다. 반대로 request child에 직접 override된 provider라면 child의 request cache에 저장되어 해당 request boundary 안에서만 살아간다.

정리하면 이 함수는 단순 cache helper가 아니다. 동시 생성 중복 방지, 실패한 생성 작업 정리, root singleton 위임, request-local cache 선택이 모두 여기서 맞물린다.

5. instantiate: 4가지 provider 타입 실행

instantiate()는 provider record가 실제 값으로 바뀌는 지점이다. 앞선 단계들이 “어디로 갈지”를 정했다면, 여기서는 “어떻게 만들지”를 실행한다.

TS
private async instantiate<T>(
  provider: NormalizedProvider<T>,
  chain: Token[],
  activeTokens: Set<Token>,
): Promise<T> {
  this.assertSingletonDependencyScopes(provider);

  switch (provider.type) {
    case 'value':
      return provider.useValue as T;

    case 'existing':
      return await this.resolveWithChain(provider.useExisting as Token<T>, [], new Set());

    case 'factory': {
      if (!provider.useFactory) {
        throw new InvariantError('Factory provider is missing useFactory.');
      }

      const deps = await this.resolveProviderDeps(provider, chain, activeTokens);
      return provider.useFactory(...deps);
    }

    case 'class': {
      if (!provider.useClass) {
        throw new InvariantError('Class provider is missing useClass.');
      }

      const deps = await this.resolveProviderDeps(provider, chain, activeTokens);
      return new provider.useClass(...deps) as T;
    }

    default:
      throw new InvariantError('Unknown provider type.');
  }
}

Provider 타입별 의미는 다음과 같다.

이 함수의 첫 줄이 assertSingletonDependencyScopes(provider)라는 점도 중요하다. fluo는 객체를 만든 뒤에야 scope mismatch를 발견하지 않는다. 생성 직전에 “이 provider가 singleton인데 request-scoped dependency를 붙잡으려는가?”를 검사한다. 문제가 있으면 instance가 만들어지기 전에 ScopeMismatchError가 발생한다.

value는 가장 단순하지만, 그래서 더 조심해서 읽어야 한다. value provider는 컨테이너가 값을 만들지 않는다. 이미 만들어진 값을 반환한다. 따라서 value가 mutable object라면 그 object의 identity와 mutation semantics는 사용자가 제공한 그대로 유지된다. DI container는 value를 복제하거나 보호하지 않는다.

factoryclass는 dependency resolution 경로를 공유한다. 둘 다 resolveProviderDeps()를 통과하고, 반환된 deps 배열을 같은 순서로 사용한다. 차이는 마지막 호출뿐이다. factory는 함수 호출이고, class는 constructor 호출이다. 이 구조 덕분에 optional, forwardRef, circular detection, scope guard는 factory와 class provider에 동일하게 적용된다.

existing은 일반적으로 resolveFromRegisteredProviders()의 alias 분기에서 먼저 처리된다. 그럼에도 instantiate() 안에 case가 남아 있는 것은 normalized provider 타입을 끝까지 방어하기 위한 안전망에 가깝다. 중요한 의미는 동일하다. alias는 독립 인스턴스를 만들지 않고 target token의 resolution으로 이동한다.

6. resolveProviderDeps: 순서 보존 직렬 resolve

Factory provider와 class provider는 dependency 배열을 먼저 해결해야 한다. fluo는 이 작업을 병렬로 펼치지 않고, inject 배열 순서대로 하나씩 resolve한다.

TS
private async resolveProviderDeps(
  provider: NormalizedProvider,
  chain: Token[],
  activeTokens: Set<Token>,
): Promise<unknown[]> {
  const deps = new Array<unknown>(provider.inject.length);

  for (const [index, entry] of provider.inject.entries()) {
    deps[index] = await this.resolveDepToken(entry, chain, activeTokens);
  }

  return deps;
}

직렬 resolve의 가장 큰 장점은 진단 가능성이다. constructor 인자 순서와 dependency resolution 순서가 일치한다. 첫 번째 인자에서 실패하면 첫 번째 인자의 chain이 에러에 남고, 두 번째 인자에서 실패하면 두 번째 인자의 chain이 남는다. 에러가 재현 가능한 순서로 발생한다.

또한 chainactiveTokens는 현재 resolution path를 표현하는 mutable state다. 이를 여러 dependency branch에 병렬로 공유하면 진단 순서가 흐려지고, 어떤 branch가 active 상태를 열고 닫는지 추론하기 어려워진다. fluo는 여기서 성능 최적화보다 단순하고 안정적인 resolution trace를 선택한다.

각 dependency entry는 다시 세 종류로 나뉜다.

TS
private async resolveDepToken(
  depEntry: Token | ForwardRefFn | OptionalToken,
  chain: Token[],
  activeTokens: Set<Token>,
): Promise<unknown> {
  if (isOptionalToken(depEntry)) {
    const innerToken = depEntry.token;

    if (!this.has(innerToken)) {
      return undefined;
    }

    return this.resolveWithChain(innerToken, chain, activeTokens);
  }

  if (isForwardRef(depEntry)) {
    const resolvedToken = this.resolveForwardRefToken(depEntry);
    return this.resolveWithChain(resolvedToken, chain, activeTokens, /* allowForwardRef */ true);
  }

  return this.resolveWithChain(depEntry as Token, chain, activeTokens);
}

optional(token)은 “없어도 되는 dependency”를 표현한다. 하지만 optional은 모든 검증을 우회하는 escape hatch가 아니다. token이 없을 때만 undefined를 넘긴다. token이 존재한다면 일반 dependency와 똑같이 resolve되고, 같은 scope 규칙과 circular dependency 규칙을 따른다.

forwardRef(() => Token)은 wrapper callback을 실행해 실제 token을 얻는다. fluo는 이 결과를 forwardRefTokenCache에 저장해 같은 wrapper를 반복 평가하지 않는다. 그런 다음 allowForwardRef=true로 같은 resolveWithChain()에 들어간다. 앞서 본 것처럼 이 플래그는 “순환을 허용한다”가 아니라 “forwardRef 경로에서 발견된 순환임을 더 정확히 설명한다”에 가깝다.

일반 token은 그대로 resolveWithChain()에 들어간다. 따라서 optional이든 forwardRef든 일반 token이든, 최종적으로는 같은 resolution pipeline을 공유한다. special case는 dependency entry를 token으로 해석하는 작은 구간에만 머문다. 그 이후에는 provider lookup, cache, scope guard, circular detection이 모두 동일하게 적용된다.

7. assertSingletonDependencyScopes: 스코프 오용 방어

DI에서 가장 위험한 lifetime mismatch 중 하나는 singleton이 request-scoped 값을 붙잡는 경우다. Singleton은 애플리케이션 전체에서 오래 살아남는다. Request-scoped provider는 요청 하나의 lifetime 안에서만 의미가 있다. Singleton constructor에 request-scoped dependency가 들어가면, 첫 요청의 상태가 전역 객체에 붙잡히는 문제가 생길 수 있다.

fluo는 이 edge를 instance creation 직전에 막는다.

TS
private assertSingletonDependencyScopes(provider: NormalizedProvider): void {
  if (provider.scope !== Scope.DEFAULT) {
    return;
  }

  const requestScopedDependency = this.findRequestScopedDependency(
    provider.inject,
    new Set<Token>([provider.provide]),
  );

  if (requestScopedDependency) {
    throw new ScopeMismatchError(
      `Singleton provider ${formatTokenName(provider.provide)} depends on request-scoped provider ${formatTokenName(requestScopedDependency)}.`,
      {
        token: provider.provide,
        scope: 'singleton',
        hint: `Singleton providers cannot depend on request-scoped providers. Either change ${formatTokenName(requestScopedDependency)} to singleton/transient scope, or change ${formatTokenName(provider.provide)} to request scope.`,
      },
    );
  }
}

검사는 provider의 직접 dependency만 보는 데서 끝나지 않는다. findRequestScopedDependency()는 dependency entry를 token으로 풀고, findRequestScopedDependencyToken()은 그 token의 effective provider를 따라간다. Alias chain도 확인하고, provider가 등록되어 있지 않은 class token이라면 class metadata의 @Scope()/@Inject() 정보도 본다. 즉, 다음과 같은 간접 edge도 문제가 된다.

TXT
SingletonA -> ServiceB -> AliasC -> RequestStore

SingletonA가 직접 RequestStore를 주입받지 않아도, dependency graph 어딘가에서 request scope에 도달하면 singleton 생성은 거부된다. 이 검사가 instantiate 시점에 있는 이유도 여기에 있다. Provider graph는 override, parent/child container, alias 구성에 따라 달라질 수 있다. 등록 시점에는 괜찮아 보였던 구성이 실제 resolve 경로에서는 request scope를 끌고 들어올 수 있다.

반대로 singleton이 transient provider에 의존하는 것은 허용된다. Transient는 “요청별 상태”가 아니라 “resolve할 때마다 새로 만든다”는 생성 전략이다. Singleton이 생성되는 순간 transient 인스턴스 하나를 받아 오래 들고 있는 것은 의도된 사용일 수 있다. fluo가 금지하는 것은 긴 lifetime 객체가 request lifetime의 값을 붙잡는 경우다.

이 정책은 request scope를 단순한 성능 최적화가 아니라 안전한 lifetime boundary로 취급한다는 뜻이기도 하다. HTTP 요청, background job, message handler처럼 독립된 실행 컨텍스트를 child container로 표현하려면, 그 안의 상태가 root singleton으로 새어 나가지 않아야 한다.

8. 에러 정리

Instance Creation 단계의 에러는 “객체 생성 실패”처럼 보이지만, 실제로는 서로 다른 종류의 계약 위반을 표현한다. 에러를 구분해 두면 문제를 훨씬 빨리 좁힐 수 있다.

CircularDependencyError

현재 construction chain 안에서 이미 active한 token을 다시 resolve하려고 할 때 발생한다. 직접 순환(A -> A), 두 노드 순환(A -> B -> A), 더 깊은 순환(A -> B -> C -> A) 모두 같은 방식으로 감지된다.

forwardRef()를 사용한 경우에도 실제 생성 순환이면 이 에러가 발생한다. 다만 이때는 “forwardRef는 token lookup만 지연하며 true circular construction을 해결하지 않는다”는 detail message가 붙는다. 따라서 이 에러를 만나면 forwardRef()를 더 추가하기보다, shared service 추출, mediator 도입, dependency 방향 재설계 같은 구조적 해결을 먼저 고려해야 한다.

ContainerResolutionError

요청한 token에 대응되는 provider를 찾을 수 없을 때 발생한다. Instance Creation 입장에서는 더 이상 내려갈 provider record가 없는 상태다. 보통 module providers 누락, import/export 가시성 문제, 잘못된 token 사용이 원인이다.

이 에러는 “클래스를 만들다가 실패했다”기보다 “만들 대상을 찾지 못했다”에 가깝다. 따라서 constructor 내부를 보기 전에 provider registration과 module graph visibility를 먼저 확인하는 편이 빠르다.

ScopeMismatchError

Singleton provider가 request-scoped provider에 의존하려고 할 때 발생한다. 이 검사는 instantiate() 초입에서 수행되므로 실제 객체가 만들어지기 전에 실패한다.

해결 방향은 둘 중 하나다. Singleton이 정말 request 상태를 필요로 한다면 그 provider 자체를 request scope로 바꿔야 한다. 반대로 dependency가 request state를 가질 필요가 없다면 해당 dependency의 scope를 singleton 또는 transient로 조정해야 한다.

RequestScopeResolutionError

Request-scoped provider를 root container에서 바로 resolve하려고 할 때 발생한다. Request-scoped provider는 request boundary가 있는 child container에서만 의미가 있다. 따라서 container.createRequestScope()로 child를 만들고 그 안에서 resolve해야 한다.

이 에러는 scope guard의 또 다른 축이다. ScopeMismatchError가 “긴 lifetime이 짧은 lifetime을 붙잡는 문제”를 막는다면, RequestScopeResolutionError는 “짧은 lifetime을 만들 boundary 없이 request provider를 생성하는 문제”를 막는다.

InvariantError

정상적인 public API 사용에서는 잘 만나기 어렵다. Normalized provider가 factory인데 useFactory가 없거나, class인데 useClass가 없는 경우처럼 내부 불변식이 깨졌을 때 발생한다. 사용자 입력 검증은 registration/normalization 단계에서 대부분 끝나므로, 이 에러는 컨테이너 내부 모델이 잘못된 상태에 들어갔다는 신호에 가깝다.

마치며

fluo의 instance creation은 겉으로는 resolve() 하나지만, 내부적으로는 여러 단계의 계약을 순서대로 통과한다.

TXT
resolve(token)
  -> disposed hierarchy 검사
  -> resolveWithChain(token, chain, activeTokens)
  -> circular dependency 검사
  -> provider routing
  -> alias / multi / transient / cached scope 분기
  -> dependency entries resolve
  -> singleton-request scope mismatch 검사
  -> value / factory / class 실행
  -> Promise cache 저장 또는 즉시 반환

이 순서가 안정적이기 때문에 상위 runtime은 provider를 컨테이너에 등록해 두고, 실제 생성은 필요한 순간까지 미룰 수 있다. 동시에 fluo는 너무 늦게 실패하지 않도록 중요한 안전장치를 생성 직전에 배치한다. 순환 의존성은 provider 해석 전에 잡고, request scope 오용은 객체 생성 전에 잡고, async singleton 중복 생성은 Promise cache로 막는다.

이번 파트의 핵심은 new가 전부가 아니라는 점이다. DI 컨테이너에서 인스턴스 생성은 객체를 만드는 행위이면서, lifetime과 dependency graph의 계약을 검증하는 행위이기도 하다.

다음 포스트에서는 이 흐름의 반대 방향을 본다. 만들어진 인스턴스는 언제, 어떤 순서로 정리되는가? Request scope 생애주기와 disposeAll()의 역순 정리 메커니즘을 따라가며 fluo의 disposal 모델을 살펴볼 것이다.

더 읽어보기

댓글

댓글을 불러오는 중...