
Token — DI의 주소 체계
DI 컨테이너에게 "무언가를 주입해달라"고 요청할 때, 그 "무언가"를 식별하는 것이 토큰이다. fluo의 토큰 타입은 단순하다.
// packages/core/src/types.ts
export type Token<T = unknown> = string | symbol | Constructor<T>;세 가지뿐이다. 문자열, 심볼, 그리고 클래스 생성자.
// 문자열 토큰 — 환경변수나 설정값에 적합
const DB_URL = 'DATABASE_URL';
// 심볼 토큰 — 충돌 없는 전역 식별자
const LOGGER = Symbol('LOGGER');
// 클래스 토큰 — 클래스 자체가 토큰
class UsersService {}세 가지 중 클래스 토큰이 가장 흥미롭다. 클래스는 런타임에도 살아있는 값이기 때문에 별도 등록 없이 그 자체로 주소가 된다. typeof UsersService나 별도 심볼 없이, 그냥 UsersService라는 클래스 참조 자체가 토큰이다.
Constructor 타입의 설계 이유
토큰으로 사용되는 클래스는 Constructor<T> 타입으로 정의된다.
// 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()는 이 문제를 해결한다.
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'; }
}구현은 간단하다.
// 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가 주입된다.
import { optional } from '@fluojs/di';
import { Inject } from '@fluojs/core';
@Inject(optional(MetricsService))
class UsersService {
constructor(private readonly metrics?: MetricsService) {}
}// 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 — 클래스 그 자체
가장 간단한 형태. 클래스 생성자를 직접 넘기면, 그 클래스가 토큰이자 구현체가 된다.
container.register(UsersService);
// 토큰: UsersService, 구현: UsersService 인스턴스inject 목록과 scope는 클래스에 붙은 @Inject()/@Scope() 메타데이터에서 읽어온다.
2. ClassProvider — 토큰과 구현체를 분리
인터페이스 토큰에 다른 클래스를 연결할 때 쓴다.
container.register({
provide: ILogger,
useClass: PinoLogger,
scope: 'singleton',
});inject를 직접 명시하거나, 생략하면 useClass의 메타데이터에서 읽어온다. scope도 직접 명시하거나, 생략하면 useClass의 메타데이터 → 기본값(singleton) 순으로 결정된다.
3. FactoryProvider — 팩토리 함수
비동기 초기화가 필요하거나 동적으로 값을 만들어야 할 때 쓴다.
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 — 이미 만들어진 값
인스턴스 생성 없이 기존 객체를 그대로 주입한다.
container.register({
provide: 'CONFIG',
useValue: { port: 3000, host: 'localhost' },
});scope는 항상 singleton이다. 값이 이미 존재하기 때문에 생명주기를 컨테이너가 관리할 필요가 없다.
5. ExistingProvider — 별칭(alias)
이미 등록된 토큰을 다른 이름으로 참조한다.
container.register(PinoLogger);
container.register({
provide: ILogger, // ILogger로 resolve해도 PinoLogger 인스턴스가 반환된다
useExisting: PinoLogger,
});alias 체인은 여러 단계를 거칠 수 있다. 컨테이너는 resolveEffectiveProvider()를 통해 최종 구현체를 찾을 때까지 체인을 따라간다. 단, 체인에 순환이 있으면 CircularDependencyError를 던진다.
multi provider — 같은 토큰에 여러 구현체
multi: true를 붙이면 같은 토큰에 여러 provider를 쌓을 수 있다. resolve 결과는 배열이 된다.
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으로 관리한다.
private readonly registrations = new Map<Token, NormalizedProvider>();
private readonly multiRegistrations = new Map<Token, NormalizedProvider[]>();override 시 multi provider는 기존 목록 전체를 교체한다. 새로 넘기는 provider 배열이 기존 목록을 완전히 대체한다. 기존 목록에 하나만 추가하거나 제거하는 방식은 없다. 이 설계 이유는 다음 편에서 다룰 override 정책과 연결된다.
NormalizedProvider — 컨테이너의 내부 언어
개발자가 선언한 다섯 가지 형태는 컨테이너 내부에서 모두 NormalizedProvider로 변환된다.
// 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 형태별로 다른 기본값 결정 로직이 있다.
// 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은 DI의 주소 — 문자열, 심볼, 또는 클래스 생성자forwardRef()는 선언 순서 문제를 해결하지만 진짜 순환 생성은 해결하지 못한다optional()은 없으면 undefined를 반환하고, scope 분석에서 보수적으로 처리된다- 다섯 가지 provider 형태는 모두
NormalizedProvider로 통일된다 - inject 배열의 최종 우선순위는
provider.inject> 클래스 메타데이터 순이다
다음 편에서는 이 provider들이 어떻게 모듈 단위로 묶이고, imports/exports/global이 어떤 가시성 규칙을 만드는지에 대해 이야기해보려 한다.
더 읽어보기
2026.05.09
Resolution Planning — 조회 캐시와 스코프 라우팅
앞선 파트에서 bootstrapModule()이 모듈 그래프를 컴파일하고 provider를 Container에 등록하는 과정을 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 “이 토큰을 어떻게 찾고, 어느 캐시에 저장하며, 어떤 스코프로 라우팅할 것인가”를 결정하는 Resolution…
2026.05.09
Bootstrap & Lifecycle — 앱이 살아나는 6단계
bootstrapModule — 동기식 기반 작업 bootstrap의 출발점은 동기 함수 bootstrapModule()이다. 이 함수는 그래프 컴파일과 컨테이너 등록을 책임진다. 여기에는 비동기가 없다. lifecycle hook이나 HTTP 연결 같은 부수 효과는 상위 레이어가 담당…
2026.05.08
Module Graph 컴파일 — DFS, 가시성 검증, 에러 설계
컴파일의 전체 흐름 모듈 그래프 컴파일은 compileModuleGraph() 하나로 시작된다. export function compileModuleGraph(rootModule: ModuleType, options: BootstrapModuleOptions = {}): Compiled…
2026.05.08
Module Graph 설계 — 타입 체계와 가시성 모델
모듈이란 무엇인가 fluo에서 모듈은 “DI 컨테이너에 등록할 클래스 묶음”이라기보다, 토큰 가시성의 경계(boundary)를 선언하는 단위에 가깝다. 어떤 provider가 존재하는지만으로는 충분하지 않다. 그 provider의 토큰이 어느 모듈 안에서 소유되고, 어디까지 export…
2026.05.10
프레임워크 밖의 상태를 UI 안으로 들이는 법
외부 상태는 프레임워크 내부에서 만들어진 상태가 아니다. React의 useState, Vue의 ref, Svelte의 $state, Solid의 createSignal처럼 프레임워크가 직접 소유하고 추적하는 값이 아니라, 프레임워크 바깥에서 먼저 존재하고 바깥에서 변하는 값이다. We…
2026.05.10
Scope & Disposal — 인스턴스의 생애주기와 정리
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘…
2026.05.10
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.10
작은 Store 클래스가 상태 관리의 출발점이 되는 이유
상태 관리 라이브러리를 볼 때 먼저 눈에 들어오는 것은 보통 기능 목록이다. selector가 있는지, devtools와 연결되는지, persistence를 지원하는지, React 훅이 준비되어 있는지 같은 것들 말이다. 그런데 @ilokesto/store의 Store 클래스를 보면 질…
댓글
댓글을 불러오는 중...