
모듈이란 무엇인가
fluo에서 모듈은 “DI 컨테이너에 등록할 클래스 묶음”이라기보다, 토큰 가시성의 경계(boundary)를 선언하는 단위에 가깝다. 어떤 provider가 존재하는지만으로는 충분하지 않다. 그 provider의 토큰이 어느 모듈 안에서 소유되고, 어디까지 export되며, 어떤 모듈이 그 export를 import했는지가 함께 결정되어야 한다.
그래서 module graph는 단순한 폴더 구조나 네임스페이스가 아니다. 런타임이 부트스트랩 초기에 먼저 승인해야 하는 접근 제어 그래프다. 이 그래프가 통과되어야만 그 다음 단계에서 DI container가 provider를 등록하고, controller와 middleware가 붙고, request dispatcher가 만들어진다. 즉 fluo의 bootstrap은 “일단 컨테이너를 만들고 나중에 실패하는” 방식이 아니라, 모듈 위상과 토큰 접근 규칙을 먼저 검증한 뒤 컨테이너를 조립하는 방식이다.
// packages/runtime/src/types.ts
export type ModuleType = Constructor & { definition?: ModuleDefinition };이 타입이 흥미로운 이유는 definition? 속성 때문이다. @Module() 데코레이터는 core metadata store에 모듈 설정을 기록하고, defineModule() 같은 프로그래매틱 API는 클래스 타입에 같은 의미의 definition을 붙여 런타임이 읽을 수 있게 만든다. 작성 방식은 다르지만 bootstrap 입장에서는 둘 다 “컴파일 가능한 module type”이다.
이 설계 덕분에 정적 모듈과 동적 모듈이 같은 파이프라인으로 들어온다. 사용자는 @Module({ ... })로 선언적인 코드를 쓸 수도 있고, 패키지 작성자는 PrismaModule.forRoot(...)처럼 옵션을 받아 새로운 module class를 만들 수도 있다. 하지만 어느 쪽이든 마지막에는 ModuleDefinition이라는 동일한 shape로 수렴한다.
여기서 중요한 관점은 “module class 자체가 인스턴스화되는가?”가 아니다. CompiledModule에는 module instance가 들어가지 않는다. module class는 그래프의 node identity로 쓰이고, definition은 그 node에 연결된 edge와 공개 surface를 설명한다. provider instance의 생성은 그보다 뒤의 DI container 단계에서 일어난다.
ModuleDefinition — 모듈의 구성 요소
여섯 개 필드가 모듈의 전부다. 작아 보이지만, 이 여섯 필드만으로 “무엇을 소유하고”, “무엇을 공개하고”, “무엇을 가져와서 쓸 수 있는지”가 결정된다. fluo의 module graph compiler는 이 metadata를 먼저 정규화한다. 빠진 필드는 []나 false처럼 다루기 쉬운 기본값으로 바뀌고, 이후 알고리즘은 undefined 체크가 아니라 정규화된 edge와 token set만 다룬다.
export interface ModuleDefinition {
imports?: ModuleType[];
providers?: Provider[];
controllers?: ControllerType[];
exports?: Token[];
middleware?: MiddlewareLike[];
global?: boolean;
}- imports: 이 모듈이 의존하는 다른 모듈 목록이다. 단, import했다고 해서 그 모듈의 모든 provider가 보이는 것은 아니다. import한 모듈이
exports로 공개한 token만 현재 모듈의importedExportedTokens에 들어온다. 그래서imports는 “모듈 전체를 열어젖히는 문”이 아니라 “상대가 공개한 API surface를 연결하는 edge”에 가깝다. - providers: 이 모듈이 직접 소유하는 DI provider 선언이다. 클래스 shortcut provider라면 클래스 자체가 token이 되고,
{ provide, useClass },{ provide, useFactory },{ provide, useValue },{ provide, useExisting }같은 provider 객체라면provide가 공개 token이 된다. compiler는 여기서providerTokens를 만든다. - controllers: HTTP 요청을 처리하는 클래스 목록이다. controller도 constructor injection을 받을 수 있으므로 module visibility 검증 대상이다. 다만 controller는 provider처럼
exports로 외부에 공개되는 대상이 아니다. controller는 해당 모듈의 request-facing entrypoint이고, token surface의 일부는 아니다. - exports: 외부 모듈에게 공개할 token 목록이다. 이 필드는 module boundary의 핵심이다. fluo는 export token이 반드시 이 모듈의 local provider token이거나, import한 모듈에서 이미 export된 token인지 검사한다. 즉 자기 소유도 아니고 import를 통해 받은 것도 아닌 token을 임의로 export할 수 없다.
- middleware: HTTP 요청 파이프라인에 붙는 middleware 목록이다. module definition 안에 함께 있지만,
accessibleTokens계산의 중심은 아니다. middleware는 request 처리 구조에 관여하고, provider/controller injection visibility와는 다른 층에서 소비된다. - global:
true이면 이 모듈의 exported token들이 앱 전체에서 import 없이 보인다. 하지만 global module이라고 해서 providers 전체가 공개되는 것은 아니다. 여전히exports에 들어간 token만 전역 surface가 된다. global은 “모든 것을 공개”가 아니라 “export surface를 전역으로 배포”하는 플래그다.
이 구조는 의도적으로 보수적이다. 기본값은 private이다. 어떤 provider가 DataModule에 등록되어 있어도 DataModule이 그 token을 export하지 않으면 BillingModule은 주입받을 수 없다. 반대로 SharedModule이 Logger를 export하고, ReExportModule이 SharedModule을 import한 뒤 Logger를 다시 export하면, 그 re-export를 import한 AppModule은 Logger를 사용할 수 있다. fluo는 이런 관계를 런타임 부트스트랩 시점에 명확히 검증한다.
작게 보면 ModuleDefinition은 설정 객체지만, 크게 보면 package public API와 비슷하다. providers는 내부 구현, exports는 공개 API, imports는 의존하는 공개 API, global은 공개 API의 배포 범위를 의미한다. 이 비유로 보면 module graph 설계가 왜 단순한 컨테이너 등록 목록보다 엄격해야 하는지 이해하기 쉽다.
CompiledModule — 컴파일 결과의 4종 Set
모듈 그래프를 컴파일하면 각 모듈은 CompiledModule record로 변환된다. 이 record는 컨테이너가 아니고, provider instance도 아니다. 런타임이 “이 모듈은 무엇을 소유하고, 무엇을 import했고, 무엇을 export하며, 무엇을 주입받을 수 있는가”를 빠르게 판단하기 위한 분석 결과다.
export interface CompiledModule {
type: ModuleType;
definition: ModuleDefinition;
accessibleTokens: Set<Token>;
exportedTokens: Set<Token>;
importedExportedTokens: Set<Token>;
providerTokens: Set<Token>;
}여기서 type은 원래 module class다. graph node의 identity 역할을 한다. definition은 정규화된 module metadata다. 그리고 나머지 네 개의 Set<Token>이 실제 visibility 모델을 만든다.
- providerTokens: 이 모듈이 local로 소유한 provider token 집합이다.
definition.providers를 순회하면서providerToken(provider)로 뽑는다. 클래스 provider라면 클래스 자체가 들어가고, 객체 provider라면provide값이 들어간다. export validation에서 “이 token이 이 모듈의 것인가?”를 판단하는 기준이다. - importedExportedTokens: 직접 import한 모듈들의
exportedTokens를 합친 집합이다. import graph를 따라 모든 provider를 무제한으로 끌어오는 것이 아니라, import한 모듈이 공개한 token만 합쳐진다. re-export가 가능한 이유도 여기에 있다. 어떤 token이 local은 아니지만importedExportedTokens안에 있으면, 현재 모듈은 그 token을 다시 export할 수 있다. - exportedTokens: 이 모듈이 최종적으로 외부에 공개한다고 검증된 token 집합이다. 단순히
definition.exports를 복사하지 않는다. compiler는 각 export token이providerTokens또는importedExportedTokens에 있는지 확인한 뒤에만exportedTokens에 넣는다. 그래서 잘못된 export는 조용히 무시되지 않고ModuleVisibilityError로 실패한다. - accessibleTokens: 이 모듈 내부의 provider와 controller가 주입받을 수 있는 전체 token 집합이다. local provider, imported export, global export, bootstrap runtime provider token이 모두 합쳐진다. provider visibility와 controller visibility 검증은 결국 이 set membership 검사로 귀결된다.
이 네 Set은 비슷해 보이지만 각각의 책임이 다르다. providerTokens는 ownership, importedExportedTokens는 inbound public surface, exportedTokens는 outbound public surface, accessibleTokens는 injection 가능 범위를 뜻한다.
특히 exportedTokens와 accessibleTokens를 구분하는 것이 중요하다. 어떤 token이 현재 모듈에서 접근 가능하다고 해서 자동으로 외부에 export되는 것은 아니다. 예를 들어 global module이 공개한 ConfigService는 여러 모듈의 accessibleTokens에 들어갈 수 있지만, 각 모듈이 그것을 자기 export처럼 외부에 내보낸다는 뜻은 아니다. 반대로 어떤 모듈이 token을 export하려면 그 token이 local provider이거나 imported export여야 한다.
컴파일 순서도 이 Set의 의미와 연결된다. compileModule()은 depth-first로 import를 먼저 컴파일하고, 현재 모듈의 providerTokens를 만든 뒤 ordered 배열에 push한다. 그 다음 validation pass에서 import된 모듈들의 exportedTokens를 모아 importedExportedTokens를 만들고, accessibleTokens를 메모이즈한 뒤 provider/controller visibility와 export legality를 검증한다. 즉 CompiledModule은 “발견된 모듈 목록”이 아니라 “검증된 그래프 계약”에 가깝다.
accessibleTokens의 계산식
각 모듈의 accessibleTokens는 네 가지 출처의 합집합이다.
// packages/runtime/src/module-graph.ts
function createAccessibleTokenSet(
runtimeProviderTokens: Set<Token>, // bootstrap options와 validation token이 제공한 런타임 token
moduleProviderTokens: Set<Token>, // 이 모듈 자신의 providers
importedExportedTokens: Set<Token>, // 직접 import한 모듈들의 exports
globalExportedTokens: Set<Token>, // global module의 exports
): Set<Token> {
return new Set<Token>([
...runtimeProviderTokens,
...moduleProviderTokens,
...importedExportedTokens,
...globalExportedTokens,
]);
}수식으로 쓰면 다음과 같다.
accessibleTokens(M)
= runtimeProviderTokens
∪ providerTokens(M)
∪ importedExportedTokens(M)
∪ globalExportedTokens이 공식에는 의도적으로 “앱 전체 provider 목록”이 없다. AppModule 어딘가에 provider가 등록되어 있다는 사실만으로 다른 모듈이 접근할 수 있는 것은 아니다. 반드시 네 출처 중 하나로 현재 모듈에 들어와야 한다.
예를 들어 BillingService가 InternalRepository를 주입받는다고 하자. InternalRepository가 DataModule의 providers에만 있고 exports에 없다면, BillingModule이 DataModule을 import해도 실패한다. import edge는 생겼지만, InternalRepository가 DataModule.exportedTokens에 없으므로 BillingModule.importedExportedTokens에 들어오지 않는다. 결과적으로 BillingModule.accessibleTokens.has(InternalRepository)가 false가 되고, bootstrap은 ModuleVisibilityError를 던진다.
반대로 다음 경로 중 하나를 만들면 접근 가능해진다.
BillingModule.providers에InternalRepository를 직접 등록한다. 그러면 localproviderTokens에 들어간다.DataModule.exports에InternalRepository를 추가하고,BillingModule.imports에DataModule을 둔다. 그러면importedExportedTokens에 들어간다.DataModule을global: true로 표시하고,InternalRepository를 exports에 넣는다. 그러면globalExportedTokens에 들어간다.- bootstrap options의 runtime providers나 validation tokens로 제공한다. 그러면
runtimeProviderTokens에 들어간다.
이 네 경로가 fluo의 visibility contract 전부다. 그래서 디버깅할 때도 질문을 이렇게 바꾸면 좋다. “이 provider가 등록되어 있나?”가 아니라, “현재 모듈의 accessibleTokens에 이 token이 어떤 경로로 들어오는가?”를 물어야 한다.
global도 이 공식 안에서만 이해해야 한다. global module은 편리하지만, private-by-default 원칙을 무너뜨리는 만능키가 아니다. global module의 providers 전체가 들어오는 것이 아니라, global module의 exports에 있는 token만 globalExportedTokens로 수집된다. 따라서 global module을 쓸 때도 공개 surface를 작게 유지하는 것이 좋다.
마지막으로 runtimeProviderTokens는 framework bootstrap이 제공하는 런타임 토큰을 설명한다. 예를 들어 application bootstrap에서는 HTTP adapter, compiled modules, runtime container 같은 내부 token이 validation token으로 들어갈 수 있다. 이것들은 사용자가 어떤 feature module의 imports에 적는 대상은 아니지만, bootstrap이 의도적으로 주입 가능하다고 선언한 런타임 surface다.
이 계산이 왜 중요한가? “왜 내 서비스를 주입받지 못하지?”라는 질문의 답이 이 공식에 있기 때문이다. token이 네 출처 중 어디에도 없다면 fluo는 컨테이너 resolution 단계까지 가지 않고 module graph validation 단계에서 실패시킨다. 실패를 앞당기는 대신, 에러 메시지는 “local도 아니고, imported module의 export도 아니고, global module을 통해 보이지도 않는다”는 식으로 경계 위반의 위치를 직접 알려 준다.
캐시 키 설계 — 함수와 심볼을 ID로
모듈 그래프 컴파일은 생각보다 비용이 큰 작업이다. 모듈을 순회하면서 provider를 수집하고, import 관계를 따라가며, 의존성 그래프를 구성해야 하기 때문이다. fluo는 이 비용을 줄이기 위해 선택적으로 컴파일 결과를 캐시할 수 있게 했다.
여기서 문제는 캐시 키를 어떻게 만들 것인가였다.
fluo의 모듈 정의에는 클래스, 함수, 심볼 같은 값이 자연스럽게 등장한다. 하지만 이런 값들은 JSON으로 안정적으로 직렬화할 수 없다. 클래스나 함수는 문자열로 바꾸면 구현 내용이 섞여 들어가고, 심볼은 같은 description을 가져도 서로 다른 값일 수 있다. 결국 단순히 JSON.stringify에 기대는 방식으로는 안전한 캐시 키를 만들기 어렵다.
그래서 fluo는 프로세스 안에서만 유효한 정수 ID를 부여하는 방식을 사용한다.
// module-graph.ts
const objectTokenIds = new WeakMap<Function, number>();
const symbolTokenIds = new Map<symbol, number>();
let nextTokenId = 0;
function getFunctionTokenId(token: Function): number {
const existing = objectTokenIds.get(token);
if (existing !== undefined) {
return existing;
}
nextTokenId += 1;
objectTokenIds.set(token, nextTokenId);
return nextTokenId;
}함수나 클래스는 WeakMap에 저장하고, 심볼은 Map에 저장한다. 이미 ID가 부여된 값이면 기존 ID를 재사용하고, 처음 보는 값이면 새로운 정수 ID를 할당한다. 이렇게 하면 직렬화할 수 없는 런타임 값을 캐시 키에 직접 넣지 않고도, 같은 프로세스 안에서는 동일한 값을 안정적으로 식별할 수 있다.
예를 들어 UsersModule 클래스를 루트 모듈로 컴파일하면 캐시 키는 다음과 같은 형태가 된다.
root:fn:1;module:3;class-di:7;algorithm:1;runtime:;validation:키의 각 세그먼트 의미:
root:fn:1— 루트 모듈 클래스의 프로세스 IDmodule:3—@Module()메타데이터 write 횟수 (변경 감지)class-di:7—@Inject()메타데이터 write 횟수algorithm:1— 컴파일 알고리즘 버전 상수runtime:...— bootstrap options의 runtime providersvalidation:...— validationTokens
메타데이터 버전 카운터(getModuleMetadataVersion(), getClassDiMetadataVersion())가 핵심이다. 어디서든 @Module()이나 @Inject()가 추가되면 버전이 올라가고 캐시 미스가 발생한다. 이 덕분에 캐시는 절대 stale해지지 않는다.
Clone + Freeze — 캐시 불변성 전략
캐시에 저장되는 CompiledModule 배열은 깊은 복사 후 완전히 동결된다.
function createModuleGraphCacheSnapshot(
modules: readonly CompiledModule[]
): readonly CompiledModule[] {
return Object.freeze(
cloneCompiledModules(modules).map((m) => freezeCompiledModule(m))
);
}그리고 캐시에서 꺼낼 때도 반드시 깊은 복사를 한다.
if (cachedModules !== undefined) {
return cloneCompiledModules(cachedModules); // 동결된 캐시를 복사해서 반환
}왜 이렇게 할까? 이는 각 bootstrap 호출은 독립적인 CompiledModule을 받아야 하기 때문이다. 호출자가 자신의 복사본을 수정해도 캐시가 오염되지 않는다. 반대로 캐시 snapshot이 변경되면 다음 호출자가 이상한 상태를 받게 된다. Clone-on-read + Freeze-on-store 패턴이 이를 깔끔하게 해결한다.
moduleGraphCache 옵션
캐시는 기본적으로 비활성화돼 있다. BootstrapModuleOptions의 moduleGraphCache: true를 전달해야 활성화된다.
const app = await fluoFactory.create(AppModule, {
adapter: createNodejsAdapter({ port: 3000 }),
moduleGraphCache: true, // 선택적 opt-in
});테스트 격리를 위해 캐시를 명시적으로 초기화하는 함수도 공개돼 있다.
import { clearModuleGraphCompileCacheForTesting } from '@fluojs/runtime';
beforeEach(() => {
clearModuleGraphCompileCacheForTesting();
});포스트가 너무 길어져서 나머지 내용은 다음 파트에서 진행하고자 한다. 다음 파트에서는 이 타입 체계를 바탕으로 실제 컴파일·검증 알고리즘이 어떻게 작동하는지 따라가본다.
더 읽어보기
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.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 클래스를 보면 질…
댓글
댓글을 불러오는 중...