Module Graph 설계 — 타입 체계와 가시성 모델

작성일:2026.05.08|조회수:5

Module Graph 설계 — 타입 체계와 가시성 모델

모듈이란 무엇인가

fluo에서 모듈은 “DI 컨테이너에 등록할 클래스 묶음”이라기보다, 토큰 가시성의 경계(boundary)를 선언하는 단위에 가깝다. 어떤 provider가 존재하는지만으로는 충분하지 않다. 그 provider의 토큰이 어느 모듈 안에서 소유되고, 어디까지 export되며, 어떤 모듈이 그 export를 import했는지가 함께 결정되어야 한다.

그래서 module graph는 단순한 폴더 구조나 네임스페이스가 아니다. 런타임이 부트스트랩 초기에 먼저 승인해야 하는 접근 제어 그래프다. 이 그래프가 통과되어야만 그 다음 단계에서 DI container가 provider를 등록하고, controller와 middleware가 붙고, request dispatcher가 만들어진다. 즉 fluo의 bootstrap은 “일단 컨테이너를 만들고 나중에 실패하는” 방식이 아니라, 모듈 위상과 토큰 접근 규칙을 먼저 검증한 뒤 컨테이너를 조립하는 방식이다.

TS
// 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만 다룬다.

TS
export interface ModuleDefinition {
  imports?: ModuleType[];
  providers?: Provider[];
  controllers?: ControllerType[];
  exports?: Token[];
  middleware?: MiddlewareLike[];
  global?: boolean;
}

이 구조는 의도적으로 보수적이다. 기본값은 private이다. 어떤 provider가 DataModule에 등록되어 있어도 DataModule이 그 token을 export하지 않으면 BillingModule은 주입받을 수 없다. 반대로 SharedModuleLogger를 export하고, ReExportModuleSharedModule을 import한 뒤 Logger를 다시 export하면, 그 re-export를 import한 AppModuleLogger를 사용할 수 있다. fluo는 이런 관계를 런타임 부트스트랩 시점에 명확히 검증한다.

작게 보면 ModuleDefinition은 설정 객체지만, 크게 보면 package public API와 비슷하다. providers는 내부 구현, exports는 공개 API, imports는 의존하는 공개 API, global은 공개 API의 배포 범위를 의미한다. 이 비유로 보면 module graph 설계가 왜 단순한 컨테이너 등록 목록보다 엄격해야 하는지 이해하기 쉽다.

CompiledModule — 컴파일 결과의 4종 Set

모듈 그래프를 컴파일하면 각 모듈은 CompiledModule record로 변환된다. 이 record는 컨테이너가 아니고, provider instance도 아니다. 런타임이 “이 모듈은 무엇을 소유하고, 무엇을 import했고, 무엇을 export하며, 무엇을 주입받을 수 있는가”를 빠르게 판단하기 위한 분석 결과다.

TS
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 모델을 만든다.

이 네 Set은 비슷해 보이지만 각각의 책임이 다르다. providerTokens는 ownership, importedExportedTokens는 inbound public surface, exportedTokens는 outbound public surface, accessibleTokens는 injection 가능 범위를 뜻한다.

특히 exportedTokensaccessibleTokens를 구분하는 것이 중요하다. 어떤 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는 네 가지 출처의 합집합이다.

TS
// 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,
  ]);
}

수식으로 쓰면 다음과 같다.

TXT
accessibleTokens(M)
  = runtimeProviderTokens
  ∪ providerTokens(M)
  ∪ importedExportedTokens(M)
  ∪ globalExportedTokens

이 공식에는 의도적으로 “앱 전체 provider 목록”이 없다. AppModule 어딘가에 provider가 등록되어 있다는 사실만으로 다른 모듈이 접근할 수 있는 것은 아니다. 반드시 네 출처 중 하나로 현재 모듈에 들어와야 한다.

예를 들어 BillingServiceInternalRepository를 주입받는다고 하자. InternalRepositoryDataModule의 providers에만 있고 exports에 없다면, BillingModuleDataModule을 import해도 실패한다. import edge는 생겼지만, InternalRepositoryDataModule.exportedTokens에 없으므로 BillingModule.importedExportedTokens에 들어오지 않는다. 결과적으로 BillingModule.accessibleTokens.has(InternalRepository)가 false가 되고, bootstrap은 ModuleVisibilityError를 던진다.

반대로 다음 경로 중 하나를 만들면 접근 가능해진다.

  1. BillingModule.providersInternalRepository를 직접 등록한다. 그러면 local providerTokens에 들어간다.
  2. DataModule.exportsInternalRepository를 추가하고, BillingModule.importsDataModule을 둔다. 그러면 importedExportedTokens에 들어간다.
  3. DataModuleglobal: true로 표시하고, InternalRepository를 exports에 넣는다. 그러면 globalExportedTokens에 들어간다.
  4. 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를 부여하는 방식을 사용한다.

TS
// 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 클래스를 루트 모듈로 컴파일하면 캐시 키는 다음과 같은 형태가 된다.

CSS
root:fn:1;module:3;class-di:7;algorithm:1;runtime:;validation:

키의 각 세그먼트 의미:

메타데이터 버전 카운터(getModuleMetadataVersion(), getClassDiMetadataVersion())가 핵심이다. 어디서든 @Module()이나 @Inject()가 추가되면 버전이 올라가고 캐시 미스가 발생한다. 이 덕분에 캐시는 절대 stale해지지 않는다.

Clone + Freeze — 캐시 불변성 전략

캐시에 저장되는 CompiledModule 배열은 깊은 복사 후 완전히 동결된다.

TS
function createModuleGraphCacheSnapshot(
  modules: readonly CompiledModule[]
): readonly CompiledModule[] {
  return Object.freeze(
    cloneCompiledModules(modules).map((m) => freezeCompiledModule(m))
  );
}

그리고 캐시에서 꺼낼 때도 반드시 깊은 복사를 한다.

TS
if (cachedModules !== undefined) {
  return cloneCompiledModules(cachedModules); // 동결된 캐시를 복사해서 반환
}

왜 이렇게 할까? 이는 각 bootstrap 호출은 독립적인 CompiledModule을 받아야 하기 때문이다. 호출자가 자신의 복사본을 수정해도 캐시가 오염되지 않는다. 반대로 캐시 snapshot이 변경되면 다음 호출자가 이상한 상태를 받게 된다. Clone-on-read + Freeze-on-store 패턴이 이를 깔끔하게 해결한다.

moduleGraphCache 옵션

캐시는 기본적으로 비활성화돼 있다. BootstrapModuleOptionsmoduleGraphCache: true를 전달해야 활성화된다.

TS
const app = await fluoFactory.create(AppModule, {
  adapter: createNodejsAdapter({ port: 3000 }),
  moduleGraphCache: true,  // 선택적 opt-in
});

테스트 격리를 위해 캐시를 명시적으로 초기화하는 함수도 공개돼 있다.

TS
import { clearModuleGraphCompileCacheForTesting } from '@fluojs/runtime';

beforeEach(() =&gt; {
  clearModuleGraphCompileCacheForTesting();
});

포스트가 너무 길어져서 나머지 내용은 다음 파트에서 진행하고자 한다. 다음 파트에서는 이 타입 체계를 바탕으로 실제 컴파일·검증 알고리즘이 어떻게 작동하는지 따라가본다.

더 읽어보기

댓글

댓글을 불러오는 중...