
이전 포스트를 통해 우리는 인스턴스가 어떻게 만들어지는지 살펴봤다. 이번 파트는 그 반대 방향, 인스턴스가 어떻게 살고 어떻게 사라지는가를 다룬다. fluo DI의 세 가지 스코프(singleton, request, transient)의 생애주기와 dispose()의 역순 정리 메커니즘을 코드 레벨에서 추적한다.
DI 컨테이너에서 생성만큼 중요한 것이 정리다. Singleton은 애플리케이션 전체에 걸쳐 오래 살고, request scope는 요청 하나의 수명에 묶이며, transient는 컨테이너가 추적하지 않는다. 이 차이를 명확히 하지 않으면 파일 핸들, DB connection, watcher, subscription 같은 리소스가 어디서 닫혀야 하는지 흐려진다.
fluo의 disposal 설계는 크게 세 가지 원칙을 따른다.
- Root container가 살아 있는 request child를 먼저 닫는다.
- Cached instance는 생성된 순서의 역순으로 닫는다.
- 하나의 cleanup이 실패해도 나머지 cleanup은 계속 진행하고, 에러를 모아 보고한다.
이 세 원칙은 단순한 구현 취향이 아니다. 생성 방향과 정리 방향은 서로 반대여야 한다. 어떤 service가 repository와 logger에 의존한다면, service가 먼저 닫히고 repository/logger가 나중에 닫혀야 안전하다. fluo의 scope/disposal 코드는 이 원칙을 컨테이너 레벨에서 고정한다.
1. 세 가지 스코프의 생애
fluo DI는 Scope.DEFAULT(singleton), Scope.REQUEST, Scope.TRANSIENT 세 가지 스코프를 제공한다. vocabulary가 작다는 점이 중요하다. “module scope”, “session scope”, “pooled scope” 같은 추가 lifetime을 암묵적으로 만들지 않는다. 컨테이너가 이해하는 lifetime은 세 가지뿐이고, 각 lifetime은 cache policy와 disposal ownership으로 연결된다.
- Singleton (
Scope.DEFAULT): root container의singletonCache에 저장된다. 애플리케이션이 살아 있는 동안 token별로 하나의 instance promise를 공유한다. Rootcontainer.dispose()가 호출될 때 정리 대상이 된다. - Request (
Scope.REQUEST):createRequestScope()로 생성된 child container의requestCache에 저장된다. 해당 child container가 dispose될 때 정리된다. HTTP 요청, message 처리, background job 단위처럼 독립된 실행 context에 적합하다. - Transient (
Scope.TRANSIENT): cache에 저장되지 않는다.resolve()호출마다 새 instance가 생성되고, 컨테이너는 그 instance를 나중에 다시 찾을 수 없다. 따라서 정리 책임은 호출자나 해당 instance를 소유한 상위 객체에 있다.
이 구분은 “몇 번 생성되는가”만의 문제가 아니다. 더 중요한 질문은 “누가 정리 책임을 가지는가”다. Singleton과 request-scoped instance는 컨테이너 cache에 들어간다. 그러므로 컨테이너는 dispose 시점에 cache entry를 모아 onDestroy()를 호출할 수 있다. Transient는 cache에 남지 않기 때문에 컨테이너가 추적할 수 없다.
따라서 리소스 ownership이 있는 객체라면 무심코 transient로 만들면 안 된다. 예를 들어 DB connection, file watcher, interval timer, message subscription을 여는 객체가 transient라면, 매 resolve마다 새 리소스가 생기고 컨테이너는 그것을 자동으로 닫지 못한다. 이런 객체는 singleton이나 request scope 안에 두고, onDestroy()로 cleanup 계약을 제공하는 편이 안전하다.
반대로 transient가 적합한 경우도 있다. Stateless formatter, 작은 command object, 매 호출마다 새 상태가 필요한 계산 객체처럼 소유 리소스가 없고 짧게 쓰고 버리는 값이라면 transient가 자연스럽다. 핵심은 lifetime label을 성능 옵션이 아니라 ownership 선언으로 읽는 것이다.
2. Request Scope 컨테이너 생성
Request scope는 root container 안의 특별한 flag가 아니라 실제 child container다.
createRequestScope(): Container {
if (this.isDisposedInHierarchy()) {
throw new ContainerResolutionError(
'Container has been disposed and can no longer create request scopes.',
{ hint: 'Create request scopes before calling container.dispose().' },
);
}
return new Container(this, true, this.root().singletonCache);
}Child container는 세 가지 정보를 가지고 생성된다.
- Parent container reference
requestScopeEnabled = true- Root의
singletonCache
세 번째가 특히 중요하다. Request container가 만들어진다고 해서 singleton cache가 복사되는 것은 아니다. Child는 root singleton cache를 공유한다. 그래서 request container 안에서 root singleton provider를 resolve해도 root와 동일한 instance를 받는다.
Request scope는 “모든 provider를 복제한 작은 container”가 아니다. Root singleton cache를 공유하면서, request-local provider만 자기 cache에 저장하는 얇은 lifetime boundary다.
Child container는 생성되자마자 root의 childScopes에 등록되지 않는다. 실제 request-local cache write가 발생하는 순간 lazy하게 등록된다.
private ensureTrackedRequestScope(): void {
if (!this.requestScopeEnabled || !this.parent || this.trackedByRoot) {
return;
}
const root = this.root();
root.childScopes ??= new Set<Container>();
root.childScopes.add(this);
this.trackedByRoot = true;
}
private requestCacheForWrite(): Map<Token, Promise<unknown>> {
this.ensureTrackedRequestScope();
this.requestCache ??= new Map<Token, Promise<unknown>>();
return this.requestCache;
}이 lazy tracking은 작은 최적화처럼 보이지만 의미가 있다. 어떤 요청은 request scope를 만들었지만 실제 request-scoped provider를 resolve하지 않을 수 있다. 이런 child를 root가 굳이 추적할 필요는 없다. Request-local state가 materialize된 child만 shutdown 시점에 root가 알고 있으면 된다.
Request child에 직접 등록한 provider는 request-local로 취급될 수 있다. 이 패턴은 테스트나 요청별 override에는 유용하다. 하지만 애플리케이션 공통 singleton을 request child에 직접 등록하면 기대와 다른 lifetime을 가질 수 있다. 공통 singleton은 root에 등록하고, request-local 값은 request scope로 명시하는 것이 안전하다.
정리하면 request scope는 다음 구조다.
root container
owns singletonCache
tracks live child scopes only when they materialize request-local state
request child container
has parent reference
shares root singletonCache
owns requestCache and multiRequestCache3. dispose()의 흐름
dispose()는 container shutdown의 공개 진입점이다. 이 함수는 중복 호출을 제어하고, dispose가 시작되는 순간부터 새 작업을 막는다.
async dispose(): Promise<void> {
if (this.disposePromise) {
await this.disposePromise;
return;
}
this.disposed = true;
this.advanceGraphRevision();
this.disposePromise = this.disposeAll();
try {
await this.disposePromise;
} catch (error) {
this.disposePromise = undefined;
throw error;
}
}먼저 disposePromise가 이미 있으면 기존 dispose 작업을 기다리기만 한다. 여러 shutdown hook이 동시에 dispose()를 호출해도 cleanup이 중복 실행되지 않는다.
그 다음 disposed = true를 먼저 세운다. Cleanup이 아직 끝나지 않았더라도, 컨테이너는 이미 닫히기 시작한 상태다. 이 시점 이후의 register(), override(), resolve(), createRequestScope()는 거부되어야 한다. 닫는 중인 컨테이너에서 새 provider를 만들면, cleanup이 끝나기도 전에 새 리소스가 생길 수 있기 때문이다.
advanceGraphRevision()도 함께 호출된다. Dispose는 provider graph와 cache의 의미를 바꾼다. 따라서 planning cache를 그대로 둘 수 없다. 이후 혹시라도 같은 hierarchy를 바라보는 child가 남아 있더라도, lineage revision이 달라져 stale plan cache를 쓰지 않게 된다.
실패 시 disposePromise를 undefined로 되돌리는 것도 운영상 중요하다. Cleanup 중 하나가 실패했다고 해서 컨테이너가 영원히 “dispose 중” 상태에 갇히면 안 된다. 실패는 caller에게 알려야 하지만, 다음 dispose() 호출이 남은 정리를 다시 시도할 수 있어야 한다.
4. disposeAll(): 자식 먼저, 그리고 에러 수집
Root container의 disposal은 request child부터 시작한다.
private async disposeAll(): Promise<void> {
const errors: unknown[] = [];
try {
if (!this.parent && this.childScopes && this.childScopes.size > 0) {
const childResults = await Promise.allSettled(
Array.from(this.childScopes).map((child) => child.dispose()),
);
for (const result of childResults) {
if (result.status === 'rejected') {
this.collectDisposalError(result.reason, errors);
}
}
this.childScopes.clear();
}
try {
await this.disposeCache(this.disposalCacheEntries());
} catch (error) {
this.collectDisposalError(error, errors);
}
this.throwDisposalErrors(errors);
} finally {
if (this.parent && this.trackedByRoot) {
this.root().childScopes?.delete(this);
this.trackedByRoot = false;
}
}
}순서는 명확하다.
root.dispose()
-> live request child scopes dispose
-> root-owned singleton/multi cache dispose
-> collected errors throw자식을 먼저 닫는 이유는 request-local 객체가 root singleton을 참조하고 있을 가능성이 높기 때문이다. Request service가 logger, config, repository facade 같은 root singleton을 주입받았다면, root singleton을 먼저 닫으면 request cleanup 중 의존성이 이미 닫힌 상태가 된다. Child-first disposal은 의존자가 먼저 내려가고, 의존성이 나중에 내려가도록 만든다.
Child들은 Promise.allSettled()로 병렬 dispose된다. Shutdown에서 중요한 목표는 첫 에러에서 멈추는 것이 아니라 가능한 많은 리소스를 정리하는 것이다. 하나의 request child cleanup이 실패해도 다른 child cleanup은 계속 진행되어야 한다. 실패 정보는 errors 배열에 모아 마지막에 한 번에 보고한다.
finally 블록도 중요하다. Child container가 자기 자신을 dispose한 경우, root의 childScopes에서 자신을 제거한다. 이렇게 해야 오래 살아남은 root가 이미 닫힌 child를 계속 추적하지 않는다.
즉 fluo의 disposal은 “best effort cleanup + 정확한 에러 보고” 모델이다. 실패를 숨기지 않지만, 실패 하나 때문에 다른 cleanup 기회를 버리지도 않는다.
5. disposeCache(): 역순 정리
Cache disposal은 세 단계로 나뉜다.
- stale disposal 작업이 끝나기를 기다린다.
- cache에 있는 promise들을 settle시켜 disposable instance를 수집한다.
- 수집한 instance를 생성 순서의 역순으로 정리한다.
private async disposeCache(
entries: Array<[NormalizedProvider | Token, Promise<unknown>]>,
): Promise<void> {
await this.waitForStaleDisposalTasks();
const { disposables, errors } = await this.collectDisposableInstances(entries);
errors.push(...this.staleDisposalErrors.splice(0, this.staleDisposalErrors.length));
errors.push(...(await this.disposeInstancesInReverseOrder(disposables)));
this.clearDisposalCaches();
this.throwDisposalErrors(errors);
}collectDisposableInstances()는 cache entry의 promise를 모두 settle시킨다. Promise가 reject되면 그 error를 수집하고, fulfill되면 instance가 disposable인지 확인한다.
private async collectDisposableInstances(
entries: Array<[NormalizedProvider | Token, Promise<unknown>]>,
): Promise<{ disposables: Disposable[]; errors: unknown[] }> {
const disposables: Disposable[] = [];
const seenInstances = new Set<unknown>();
const errors: unknown[] = [];
const settled = await Promise.allSettled(entries.map(([, p]) => p));
for (const result of settled) {
if (result.status === 'rejected') {
errors.push(result.reason);
continue;
}
const instance = result.value;
if (this.isDisposable(instance) && !seenInstances.has(instance)) {
seenInstances.add(instance);
disposables.push(instance);
}
}
return { disposables, errors };
}seenInstances가 있는 이유는 같은 object가 여러 경로에서 관찰될 수 있기 때문이다. Alias나 multi-provider 구성에 따라 동일한 instance가 여러 cache entry를 통해 보일 수 있다. 같은 object에 onDestroy()가 두 번 호출되면 cleanup이 idempotent하지 않은 경우 문제가 된다. fluo는 instance identity를 기준으로 중복 disposal을 막는다.
실제 정리는 역순으로 수행된다.
private async disposeInstancesInReverseOrder(
disposables: readonly Disposable[],
): Promise<unknown[]> {
const errors: unknown[] = [];
for (const instance of [...disposables].reverse()) {
try {
await instance.onDestroy();
} catch (error) {
errors.push(error);
}
}
return errors;
}역순 정리는 생성 순서가 dependency order를 어느 정도 반영한다는 점에 기대고 있다. Repository가 먼저 만들어지고 Service가 나중에 만들어졌다면, cleanup은 Service를 먼저 닫고 Repository를 나중에 닫아야 한다. 의존자가 먼저 정리되고, 의존성이 나중에 정리되는 방향이다.
각 onDestroy()는 순차적으로 await된다. 하나가 실패해도 다음 disposable cleanup은 계속 진행된다. 실패는 errors에 모아 마지막에 보고한다. Shutdown 단계에서 에러를 숨기지 않으면서도 cleanup 진행을 최대화하는 패턴이다.
6. Disposable 인터페이스
fluo는 특정 base class 상속을 요구하지 않는다. Runtime에서 onDestroy() 메서드가 있는지만 확인한다.
interface Disposable {
onDestroy(): void | Promise<void>;
}private isDisposable(value: unknown): value is Disposable {
return typeof value === 'object'
&& value !== null
&& 'onDestroy' in value
&& typeof value.onDestroy === 'function';
}TypeScript interface는 런타임에 남지 않는다. 따라서 컨테이너는 “이 클래스가 Disposable을 implements 했는가”를 볼 수 없다. 대신 duck typing으로 onDestroy 메서드 존재 여부를 확인한다.
이 방식은 가볍고 유연하다. 어떤 클래스든 onDestroy()만 제공하면 disposal 대상이 된다. 동기 cleanup도 가능하고, Promise를 반환하는 비동기 cleanup도 가능하다.
하지만 이 유연성은 책임도 함께 준다. 메서드 이름이 런타임 계약이다. destroy(), close(), dispose() 같은 이름을 가진 객체는 컨테이너가 자동으로 닫지 않는다. 컨테이너 disposal에 참여하려면 onDestroy()를 제공해야 한다.
리소스가 있는 provider라면 다음처럼 작성하는 것이 안전하다.
class ConnectionPool {
async onDestroy(): Promise<void> {
await this.close();
}
private async close() {
// DB pool, subscription, watcher cleanup
}
}이렇게 하면 provider가 singleton이든 request scope든, 해당 scope의 container가 dispose될 때 cleanup에 참여한다.
7. override()와 stale disposal
override()는 테스트와 request-local customization에서 자주 사용된다. 하지만 이미 instance가 만들어진 뒤 provider를 override하면 기존 cached instance는 어떻게 해야 할까?
fluo는 cache entry를 삭제하기 전에 stale disposal 작업을 예약한다.
private invalidateCachedEntry(token: Token, scope: Scope): void {
if (this.requestCache?.has(token)) {
const cached = this.requestCache.get(token);
if (cached) {
this.scheduleStaleDisposal(cached);
}
this.requestCache.delete(token);
}
// singletonCache, multiSingletonCache, multiRequestCache도 같은 방식으로 처리한다.
}scheduleStaleDisposal()은 cache에서 밀려난 instance promise를 기다린 뒤, disposable이면 onDestroy()를 호출한다.
private scheduleStaleDisposal(instancePromise: Promise<unknown>): void {
let task: Promise<void>;
task = (async () => {
try {
const instance = await instancePromise;
if (this.isDisposable(instance)) {
await instance.onDestroy();
}
} catch (error) {
this.staleDisposalErrors.push(error);
}
})().finally(() => {
this.staleDisposalTasks.delete(task);
});
this.staleDisposalTasks.add(task);
}이 설계가 필요한 이유는 cache entry가 항상 완성된 instance가 아니라 Promise<unknown>이기 때문이다. Override 시점에 기존 provider가 아직 생성 중일 수 있다. 즉시 onDestroy()를 호출할 수 없으므로, promise가 settle될 때까지 기다렸다가 cleanup을 시도해야 한다.
Stale disposal은 background task로 진행되지만, 완전히 분리된 작업은 아니다. 나중에 container가 dispose될 때 waitForStaleDisposalTasks()가 이 작업들이 끝날 때까지 기다린다. 그리고 stale cleanup 중 발생한 에러는 staleDisposalErrors에 모였다가 최종 disposal error와 함께 보고된다.
이 구조 덕분에 override는 단순한 Map 교체가 아니라 lifecycle-aware replacement가 된다. 기존 instance를 cache에서 제거하면서도 리소스 누수를 피하고, cleanup 실패도 잃어버리지 않는다.
8. isDisposedInHierarchy(): 부모 체인 전파
Request child는 parent provider와 root singleton cache에 의존한다. 따라서 parent가 dispose된 뒤 child만 계속 살아 있는 상태는 안전하지 않다.
private isDisposedInHierarchy(): boolean {
if (this.disposed) {
return true;
}
return this.parent?.isDisposedInHierarchy() ?? false;
}이 함수는 현재 컨테이너뿐 아니라 parent chain 전체를 검사한다. Root가 dispose되면 아직 직접 dispose되지 않은 child도 disposed hierarchy 안에 있는 것으로 취급된다. 그러면 child에서 resolve()나 register()를 시도해도 즉시 ContainerResolutionError가 발생한다.
이 전파 규칙이 없으면 root shutdown 이후에도 오래 살아남은 request child가 parent provider를 lookup하거나 root singleton cache를 사용할 수 있다. 하지만 root cache는 이미 비워졌거나 정리 중일 수 있다. fluo는 parent chain 중 하나라도 닫힌 상태라면 hierarchy 전체를 닫힌 것으로 보고, 늦은 작업을 차단한다.
이것은 cleanup의 안전성뿐 아니라 에러 메시지의 명확성에도 도움이 된다. Root가 닫힌 뒤 child에서 이상한 missing provider나 stale cache 문제가 발생하는 대신, “container has been disposed”라는 lifecycle boundary 에러가 바로 나온다.
마치며
이번 글에서는 fluo가 scope와 disposal을 어떻게 다루는지 살펴봤다. 겉으로 보면 disposal은 단순히 캐시된 인스턴스를 정리하는 작업처럼 보인다. 하지만 실제로는 생성된 객체들의 lifetime graph를 거꾸로 따라가는 과정에 가깝다. 의존성은 먼저 만들어지고, 그 의존성을 사용하는 객체는 나중에 만들어진다. 반대로 정리할 때는 사용자가 먼저 닫히고, 그 뒤에 의존성이 닫혀야 한다.
fluo의 disposal 설계는 이 순서를 코드 수준에서 보장한다. request-local cache는 필요해지는 순간 lazy하게 child scope로 연결되고, root가 닫힐 때는 살아 있는 child scope부터 먼저 정리된다. 각 scope 안에서는 생성 순서의 역순으로 cached instance를 dispose하기 때문에, 의존자가 의존성보다 먼저 닫힌다. override로 cache에서 밀려난 stale instance 역시 별도 disposal task로 빠지지 않고 정리 대상에 포함된다.
그래서 fluo의 cleanup은 “메모리 정리”라기보다 “lifetime의 반대 방향 실행”에 가깝다. 이 원칙이 지켜져야 request scope, singleton cache, provider override가 섞여도 예측 가능한 종료 순서를 유지할 수 있다.
더 읽어보기
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
Instance Creation — resolve에서 new까지
이전 포스트에서 우리는 “어느 provider를 어느 캐시에 저장할 것인가”를 결정하는 Planning 레이어를 살펴봤다. 이번 파트에서는 그 다음 단계, 즉 실제 인스턴스를 생성하는 Execution 레이어를 따라간다. 출발점은 container.resolve(token)이고, 마지막…
2026.05.10
작은 Store 클래스가 상태 관리의 출발점이 되는 이유
상태 관리 라이브러리를 볼 때 먼저 눈에 들어오는 것은 보통 기능 목록이다. selector가 있는지, devtools와 연결되는지, persistence를 지원하는지, React 훅이 준비되어 있는지 같은 것들 말이다. 그런데 @ilokesto/store의 Store 클래스를 보면 질…
2026.05.09
TanStack Router 실무 가이드, 파일 기반 라우팅과 타입 안전하게 React 구조 잡기
React 애플리케이션이 커질수록 라우팅은 단순한 화면 전환 문제가 아니라, URL 설계, 데이터 로딩, 권한 처리, 상태 동기화까지 함께 다뤄야 하는 구조의 문제가 된다. TanStack Router는 이 지점을 정면으로 다룬다. 파일 기반 라우팅을 중심에 두고, URL 파라미터와 검…
댓글
댓글을 불러오는 중...