
컴파일의 전체 흐름
모듈 그래프 컴파일은 compileModuleGraph() 하나로 시작된다.
export function compileModuleGraph(rootModule: ModuleType, options: BootstrapModuleOptions = {}): CompiledModule[] {
// 1. 캐시 확인
const cacheKey = options.moduleGraphCache === true
? createModuleGraphCacheKey(rootModule, options)
: undefined;
if (cacheKey !== undefined) {
const cachedModules = moduleGraphCompileCache.get(cacheKey);
if (cachedModules !== undefined) {
return cloneCompiledModules(cachedModules);
}
}
// 2. DFS 탐색으로 모듈 수집
const ordered: CompiledModule[] = [];
const runtimeProviders = options.providers ?? [];
const runtimeProviderTokens = mergeRuntimeTokenSets(runtimeProviders, options.validationTokens ?? []);
compileModule(rootModule, runtimeProviderTokens, new Map(), new Set(), ordered);
// 3. 가시성·메타데이터 검증
validateCompiledModules(ordered, runtimeProviders, runtimeProviderTokens);
// 4. 캐시 저장 (선택적)
if (cacheKey !== undefined) {
const cacheSnapshot = createModuleGraphCacheSnapshot(ordered);
moduleGraphCompileCache.set(cacheKey, cacheSnapshot);
return cloneCompiledModules(cacheSnapshot);
}
return ordered;
}크게 세 단계다. DFS로 모듈을 수집하고, 검증을 돌리고, 결과를 캐시에 저장한다.
여기서 중요한 점은 compileModuleGraph()가 컨테이너를 직접 만들지 않는다는 것이다. 이 함수의 결과는 CompiledModule[]이고, 이 배열은 이후 bootstrap 단계가 소비할 “승인된 설계도”에 가깝다. provider 인스턴스 생성이나 controller 등록은 아직 시작되지 않았다. 대신 런타임은 이 시점에 모듈 순서, provider 소유권, export 가능 여부, 주입 가능한 토큰 범위를 먼저 확정한다.
즉 이 단계의 목적은 빠른 실패다. 잘못된 import cycle, 빠진 @Inject(...), 보이지 않는 token, 말이 안 되는 export 선언은 애플리케이션이 request를 받기 훨씬 전에 드러난다. 뒤쪽 DI container를 단순하게 만들기 위해 앞쪽 module graph compiler가 더 많은 책임을 가져가는 구조다.
1단계: DFS 탐색 — compileModule()
function compileModule(
moduleType: ModuleType,
runtimeProviderTokens: Set<Token>,
compiled = new Map<ModuleType, CompiledModule>(),
visiting = new Set<ModuleType>(),
ordered: CompiledModule[] = [],
) {
// 이미 컴파일됐으면 재사용
if (compiled.has(moduleType)) {
const existing = compiled.get(moduleType);
if (existing) {
return existing;
}
}
// 지금 방문 중인 모듈이면 순환 참조
if (visiting.has(moduleType)) {
throw new ModuleGraphError(
`Circular module import detected for ${moduleType.name}.`,
{
module: moduleType.name,
phase: 'module graph compilation',
hint: 'Break the import cycle by extracting shared providers into a separate module that both sides can import independently.',
},
);
}
visiting.add(moduleType);
const definition = normalizeModuleDefinition(getModuleMetadata(moduleType));
// imports를 먼저 재귀 컴파일 (post-order)
for (const imported of definition.imports ?? []) {
compileModule(imported, runtimeProviderTokens, compiled, visiting, ordered);
}
const providerTokens = new Set((definition.providers ?? []).map((provider) => providerToken(provider)));
const compiledModule: CompiledModule = {
type: moduleType,
definition,
accessibleTokens: new Set<Token>(),
exportedTokens: new Set<Token>(),
importedExportedTokens: new Set<Token>(),
providerTokens,
};
compiled.set(moduleType, compiledModule);
visiting.delete(moduleType);
ordered.push(compiledModule); // 의존성 먼저, 루트 마지막
return compiledModule;
}두 개의 Map/Set이 알고리즘을 제어한다.
- compiled: 이미 처리한 모듈. 다이아몬드 의존성(
A → B, A → C, B → D, C → D)에서 D를 중복 처리하지 않도록 한다. - visiting: 현재 DFS 스택.
A → B → A같은 순환을 감지한다.
결과 ordered 배열은 post-order다. 의존성이 먼저, 루트 모듈이 마지막에 온다. 이 순서는 나중에 bootstrap이 providers를 컨테이너에 등록할 때 중요해진다.
예를 들어 AppModule이 UsersModule을 import하고, UsersModule이 다시 DatabaseModule을 import한다면 결과 배열은 보통 DatabaseModule → UsersModule → AppModule 순서가 된다. 루트에서 출발했지만 루트가 먼저 들어가지 않는다. 각 모듈은 자신이 의존하는 하위 트리가 닫힌 뒤에야 compiled record로 확정된다.
이 방식은 “부분적으로만 이해된 그래프”를 피하게 해준다. 어떤 모듈이 ordered에 들어왔다는 것은 그 모듈의 imports는 이미 순회되었고, local provider token도 계산되었다는 뜻이다. 아직 validation은 남아 있지만, 최소한 그래프의 모양과 순서는 안정화된 상태로 다음 단계에 넘어간다.
2단계: 검증 — validateCompiledModules()
모든 모듈이 수집된 후 한 번에 검증한다. 두 번의 패스로 나뉜다.
function validateCompiledModules(
modules: CompiledModule[],
runtimeProviders: Provider[],
runtimeProviderTokens: Set<Token>,
): void {
const compiledByType = new Map(modules.map((compiledModule) => [compiledModule.type, compiledModule]));
// 패스 1: global 모듈의 exports 수집
const globalExportedTokens = new Set<Token>();
for (const provider of runtimeProviders) {
validateProviderInjectionMetadata(provider, 'bootstrap runtime');
}
for (const compiledModule of modules) {
if (!compiledModule.definition.global) {
continue;
}
for (const token of compiledModule.definition.exports ?? []) {
globalExportedTokens.add(token);
}
}
// 패스 2: 모듈별 accessibleTokens 계산 + 검증
for (const compiledModule of modules) {
const scope = `module ${compiledModule.type.name}`;
memoizeAccessibleTokenSet(
compiledModule,
resolveImportedModules(compiledModule, compiledByType),
runtimeProviderTokens,
globalExportedTokens,
);
validateProviderVisibility(compiledModule, scope, compiledModule.accessibleTokens);
validateControllerVisibility(compiledModule, scope, compiledModule.accessibleTokens);
compiledModule.exportedTokens = createExportedTokenSet(compiledModule, compiledModule.importedExportedTokens);
}
}패스 1이 먼저인 이유: 어떤 모듈이 global인지는 전체 그래프를 봐야 알 수 있다. 패스 1에서 globalExportedTokens를 모아두면, 패스 2에서 각 모듈이 global 토큰을 포함한 완전한 accessibleTokens를 계산할 수 있다.
검증 단계는 컴파일 단계와 성격이 조금 다르다. DFS가 “어떤 모듈들이 어떤 순서로 존재하는가”를 정했다면, validation은 “그 모듈들이 서로에게 무엇을 보여줘도 되는가”를 결정한다. 그래서 이 함수 안에서 compiledByType, globalExportedTokens, accessibleTokens, exportedTokens가 차례로 만들어진다.
특히 globalExportedTokens를 먼저 모으는 선택이 중요하다. global module은 특정 feature module이 직접 import하지 않아도 토큰을 보이게 만들기 때문이다. 다만 global이라고 해서 provider 전체가 열리는 것은 아니고, exports에 적힌 토큰만 후보가 된다. 이후 pass에서 export 검증을 통과해야 실제로 안전한 공개 surface가 된다.
가시성 검증 — 단 하나의 판단
provider와 controller의 가시성 검증 로직은 동일한 원리다.
function validateProviderVisibility(
compiledModule: CompiledModule,
scope: string,
accessibleTokens: Set<Token>,
): void {
for (const provider of compiledModule.definition.providers ?? []) {
// 메타데이터 완전성 검증 (inject 개수 확인)
validateProviderInjectionMetadata(provider, scope);
for (const rawToken of providerDependencies(provider)) {
const token = resolveInjectionToken(rawToken); // forwardRef/optional 해제
if (!accessibleTokens.has(token)) {
throw new ModuleVisibilityError(
`Provider ${String(providerToken(provider))} in module ${compiledModule.type.name} cannot access token ${String(
token,
)} because it is not local, not exported by an imported module, and not visible through a global module.`,
{
module: compiledModule.type.name,
token,
phase: 'provider visibility validation',
hint: `Add ${String(token)} to the exports array of the module that owns it, then import that module into ${compiledModule.type.name}. Alternatively, mark the owning module with @Global() to make its exports universally visible.`,
},
);
}
}
}
}판단은 accessibleTokens.has(token) 하나다. token이 접근 가능한 집합에 없으면 즉시 에러. optional 토큰은 resolveInjectionToken()에서 내부 token으로 벗겨지지만 여전히 동일한 검증을 받는다. 없어도 에러가 아닌 건 resolution 시점의 컨테이너 책임이지, 컴파일 시점의 그래프 검증 책임이 아니다.
이 단순함이 오히려 핵심이다. provider 검증과 controller 검증은 서로 다른 특권 모델을 갖지 않는다. controller도 결국 module 안에서 선언된 클래스이고, constructor injection을 받는다면 같은 accessibleTokens 규칙을 따라야 한다. HTTP entrypoint라는 이유로 module boundary를 건너뛰게 만들지 않는 것이다.
그래서 ModuleVisibilityError 메시지는 “토큰이 없어요”라고만 말하지 않는다. “local도 아니고, imported module의 export도 아니고, global module을 통해 보이지도 않는다”라고 말한다. 에러 문장 자체가 accessible token 공식의 역순 설명이 된다.
inject 개수 검증
가시성만 검증하면 충분하지 않다. 생성자 파라미터 개수와 inject 토큰 개수가 맞지 않으면 런타임에 인스턴스가 잘못 생성된다. fluo는 이를 컴파일 시점에 잡는다.
function validateClassInjectionMetadata(
subject: string,
implementation: Function,
inject: readonly InjectionToken[],
scope: string,
remedy: string,
): void {
const required = requiredConstructorParameters(implementation);
if (required === 0 || inject.length >= required) {
return;
}
const missingIndex = inject.length;
const configured = inject.length;
const parameterWord = required === 1 ? 'parameter' : 'parameters';
const tokenWord = configured === 1 ? 'token is' : 'tokens are';
throw new ModuleInjectionMetadataError(
`${subject} in ${scope} declares ${required} constructor ${parameterWord} but only ${configured} injection ${tokenWord} configured. Add ${remedy} for constructor parameter #${missingIndex}.`,
{
module: scope,
phase: 'injection metadata validation',
hint: `Ensure ${subject} has a matching @Inject(...) decorator or provider.inject array that covers all ${required} constructor parameters. Use @Inject() for an explicit empty override.`,
},
);
}requiredConstructorParameters()의 판단 기준은 이전에 한 번 이야기했던 바와 같이 동작한다.
function requiredConstructorParameters(target: Function): number {
// @Inject()가 명시적으로 달려 있으면 (inject: [] 포함) → 0으로 처리
if (getOwnClassDiMetadata(target)?.inject !== undefined) {
return 0;
}
// 데코레이터가 없으면 실제 파라미터 개수
return target.length;
}@Inject()를 빈 배열로라도 달면 "개발자가 의도적으로 주입 목록을 0개로 정했다"고 해석하고 검증을 통과시킨다. 반면 데코레이터가 아예 없으면 생성자의 실제 파라미터 개수(Function.length)를 요구 수로 본다.
이 검증은 fluo의 “명시적 DI” 철학과 연결된다. fluo는 emitDecoratorMetadata에 기대어 constructor parameter type을 몰래 읽지 않는다. 대신 개발자가 @Inject(...) 또는 provider 객체의 inject 배열로 토큰을 명시해야 한다. 그러니 생성자 파라미터는 있는데 토큰 선언이 부족하다면, 그것은 런타임 추론의 대상이 아니라 작성자가 고쳐야 할 계약 위반이다.
흥미로운 부분은 실패 시점이다. 이 에러는 provider instance를 만들다가 터지는 것이 아니라 module graph validation 안에서 터진다. 즉 “어떤 클래스를 만들 수 없는가”가 아니라 “이 module graph는 아직 DI를 시작할 준비가 안 되었다”는 의미로 다뤄진다.
export 검증 — 없는 걸 줄 수 없다
모듈의 exports는 외부 모듈에게 공개할 토큰 목록이다. 따라서 여기에 적힌 토큰은 반드시 현재 모듈이 실제로 제공할 수 있는 값이어야 한다.
function createExportedTokenSet(
compiledModule: CompiledModule,
importedExportedTokens: Set<Token>,
): Set<Token> {
const exportedTokens = new Set<Token>();
for (const token of compiledModule.definition.exports ?? []) {
if (!compiledModule.providerTokens.has(token) && !importedExportedTokens.has(token)) {
throw new ModuleVisibilityError(
`Module ${compiledModule.type.name} cannot export token ${String(
token,
)} because it is neither local nor re-exported from an imported module.`,
{
module: compiledModule.type.name,
token,
phase: 'export validation',
hint: `Either add a provider for ${String(token)} to ${compiledModule.type.name}'s providers array, or import a module that exports ${String(token)} so it can be re-exported.`,
},
);
}
exportedTokens.add(token);
}
return exportedTokens;
} 검증 규칙은 단순하다. 어떤 토큰을 exports에 넣으려면 둘 중 하나를 만족해야 한다.
- 현재 모듈의
providers에 직접 등록된 토큰이어야 한다. - import한 다른 모듈이 이미 export한 토큰이어야 한다. 후자의 경우에는 현재 모듈이 그 토큰을 다시 외부로 re-export할 수 있다.
반대로 말하면, 현재 모듈이 만들지도 않았고 가져오지도 않은 토큰은 export할 수 없다. 이런 토큰이 exports에 들어가 있으면 fluo는 컴파일 단계에서 바로 실패시킨다.
이 검증은 모듈의 공개 API를 느슨한 선언이 아니라 실제 의존성 그래프와 일치하는 계약으로 만든다. exports에 적혀 있다는 것은 “이 모듈을 import하면 이 토큰을 사용할 수 있다”는 의미이고, fluo는 그 약속이 실제로 지켜질 수 있는지 먼저 확인한다.
이 규칙 덕분에 re-export도 안전하게 동작한다. 어떤 모듈이 직접 provider를 만들지 않더라도, import한 모듈이 이미 공개한 token이라면 다시 export할 수 있다. 이는 여러 feature module 앞에 얇은 facade module을 두는 패턴에 유용하다. 하지만 facade가 아무 token이나 선언할 수 있는 것은 아니다. 반드시 아래쪽 import graph에서 올라온 공개 token이어야 한다.
결국 export 검증은 module을 작은 package처럼 다루게 만든다. 내부 구현은 providers, 외부 계약은 exports, 의존하는 외부 계약은 imports다. 이 셋이 어긋나면 graph compile은 성공해도 validation에서 멈춘다.
에러 타입 설계
세 가지 에러 클래스가 각기 다른 상황을 담당한다.
- ModuleGraphError: 그래프 구조 자체의 문제. 순환 임포트, 미컴파일 모듈 참조.
- ModuleVisibilityError: 접근 권한 문제. 가시성 위반, export 위반.
- ModuleInjectionMetadataError: 메타데이터 불완전. 파라미터-inject 개수 불일치.
세 클래스 모두 FluoError를 상속하고 module, token, phase, hint 컨텍스트를 에러 메시지에 포함시킨다. 특히 hint 필드가 실질적인 해결 방향을 알려준다.
ModuleVisibilityError: Provider UsersService in module UsersModule
cannot access token DatabaseService because it is not local,
not exported by an imported module, and not visible through a global module.
Module: UsersModule
Token: DatabaseService
Phase: provider visibility validation
Hint: Add DatabaseService to the exports array of the module that owns it,
then import that module into UsersModule.에러를 읽으면 무엇을 해야 하는지 바로 알 수 있다. 다음 파트에서는 컴파일된 모듈 그래프를 받아 실제 애플리케이션을 띄우는 bootstrap 흐름을 따라가보려 한다.
더 읽어보기
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 설계 — 타입 체계와 가시성 모델
모듈이란 무엇인가 fluo에서 모듈은 “DI 컨테이너에 등록할 클래스 묶음”이라기보다, 토큰 가시성의 경계(boundary)를 선언하는 단위에 가깝다. 어떤 provider가 존재하는지만으로는 충분하지 않다. 그 provider의 토큰이 어느 모듈 안에서 소유되고, 어디까지 export…
2026.05.07
Token과 Provider — DI의 주소 체계와 등록 단위
Token — DI의 주소 체계 DI 컨테이너에게 "무언가를 주입해달라"고 요청할 때, 그 "무언가"를 식별하는 것이 토큰이다. fluo의 토큰 타입은 단순하다. // packages/core/src/types.ts export type Token<T = unknown> = string…
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 클래스를 보면 질…
댓글
댓글을 불러오는 중...