Skip to content

Commit 212e799

Browse files
SOIVclaude
andcommitted
feat(core): Event Bus · Service Registry 구현 및 AppServices 연동
- EventBus 싱글턴: 타입-안전 emit/on/once/off, 리스너 자동 해제 지원 - 이벤트 타입: subscription:payment · subscription:price-changed · ledger:created - ServiceRegistry 싱글턴: 모듈 간 직접 import 없이 서비스 인스턴스 공유 - AppServices에 eventBus · serviceRegistry 추가, initServices()에서 초기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1d04bcf commit 212e799

3 files changed

Lines changed: 169 additions & 1 deletion

File tree

apps/api/src/app.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import type { SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mo
2020
import { validateEnv } from './config/env';
2121
import { errorHandler } from './middleware/error';
2222
import { requestLogger } from './middleware/logger';
23+
import { EventBus } from './event-bus';
2324
import { ModuleRegistry } from './module-registry';
25+
import { ServiceRegistry } from './service-registry';
2426
import { createAdminRouter } from './routes/admin';
2527
import { createAuthRouter } from './routes/auth';
2628
import { createCoreRouter } from './routes/core';
@@ -41,6 +43,8 @@ export interface AppServices {
4143
settings: SystemSettingsService;
4244
// 모듈 라우터에서 사용할 DB 프로바이더 (모듈이 @fieldstack/core를 직접 import하지 않아도 되도록)
4345
db: DbProvider;
46+
eventBus: EventBus;
47+
serviceRegistry: ServiceRegistry;
4448
}
4549

4650
export function createApp(services?: AppServices) {
@@ -190,5 +194,8 @@ export async function initServices(db: DbProvider): Promise<AppServices> {
190194
const settings = new SystemSettingsService(db);
191195
const sharedLink = new SharedLinkService(db, settings, env.PUBLIC_URL ?? null);
192196

193-
return { jwtManager, whitelist, adminPin, totpService, userAuth, sharedLink, settings, db };
197+
const eventBus = EventBus.getInstance();
198+
const serviceRegistry = ServiceRegistry.getInstance();
199+
200+
return { jwtManager, whitelist, adminPin, totpService, userAuth, sharedLink, settings, db, eventBus, serviceRegistry };
194201
}

apps/api/src/event-bus.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { EventEmitter } from 'node:events';
2+
3+
// ── 이벤트 페이로드 타입 정의 ────────────────────────────────────
4+
//
5+
// 새 이벤트 추가 시 이 인터페이스에만 추가하면 emit/on/once 전체에 자동 반영.
6+
7+
export interface EventPayloads {
8+
/** 구독 결제일 도래 — Scheduler가 매일 자정 발행 */
9+
'subscription:payment': {
10+
subscriptionId: string;
11+
userId: string;
12+
serviceName: string;
13+
amount: number;
14+
currency: string;
15+
date: string; // YYYY-MM-DD
16+
priceHistoryId: string | null;
17+
};
18+
/** 구독 가격 변경 */
19+
'subscription:price-changed': {
20+
subscriptionId: string;
21+
userId: string;
22+
previousAmount: number;
23+
newAmount: number;
24+
currency: string;
25+
effectiveDate: string; // YYYY-MM-DD
26+
};
27+
/** 가계부 항목 생성 완료 */
28+
'ledger:created': {
29+
entryId: string;
30+
userId: string;
31+
};
32+
}
33+
34+
export type EventName = keyof EventPayloads;
35+
36+
type Handler<K extends EventName> = (payload: EventPayloads[K]) => void;
37+
38+
// ── EventBus 싱글턴 ───────────────────────────────────────────────
39+
//
40+
// Node.js EventEmitter 위에 타입-안전 래퍼를 얹은 구조.
41+
// 모든 모듈은 services.eventBus를 통해 접근한다.
42+
//
43+
// on() 반환값(unsubscribe 함수)을 모듈 종료 시 반드시 호출해
44+
// 메모리 누수를 방지할 것.
45+
46+
export class EventBus {
47+
private static _instance: EventBus | null = null;
48+
private readonly emitter = new EventEmitter();
49+
50+
private constructor() {
51+
// EventEmitter 기본 리스너 한도(10)를 늘려 대규모 모듈 환경 대비
52+
this.emitter.setMaxListeners(100);
53+
}
54+
55+
public static getInstance(): EventBus {
56+
if (!EventBus._instance) {
57+
EventBus._instance = new EventBus();
58+
}
59+
return EventBus._instance;
60+
}
61+
62+
/** 이벤트 발행 */
63+
public emit<K extends EventName>(event: K, payload: EventPayloads[K]): void {
64+
this.emitter.emit(event, payload);
65+
}
66+
67+
/**
68+
* 이벤트 구독.
69+
* @returns unsubscribe 함수 — 모듈 종료 시 반드시 호출할 것.
70+
*/
71+
public on<K extends EventName>(event: K, handler: Handler<K>): () => void {
72+
this.emitter.on(event, handler as (...args: unknown[]) => void);
73+
return () => this.off(event, handler);
74+
}
75+
76+
/**
77+
* 단발 이벤트 구독 (한 번 수신 후 자동 해제).
78+
* @returns unsubscribe 함수
79+
*/
80+
public once<K extends EventName>(event: K, handler: Handler<K>): () => void {
81+
this.emitter.once(event, handler as (...args: unknown[]) => void);
82+
return () => this.off(event, handler);
83+
}
84+
85+
/** 특정 핸들러 해제 */
86+
public off<K extends EventName>(event: K, handler: Handler<K>): void {
87+
this.emitter.off(event, handler as (...args: unknown[]) => void);
88+
}
89+
90+
/** 특정 이벤트의 모든 리스너 해제 (모듈 언로드 시 활용) */
91+
public removeAllListeners(event?: EventName): void {
92+
this.emitter.removeAllListeners(event);
93+
}
94+
95+
/** 현재 등록된 리스너 수 (디버깅용) */
96+
public listenerCount(event: EventName): number {
97+
return this.emitter.listenerCount(event);
98+
}
99+
}

apps/api/src/service-registry.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { log } from './middleware/logger';
2+
3+
// ── ServiceRegistry 싱글턴 ───────────────────────────────────────
4+
//
5+
// 모듈이 공개한 서비스 인스턴스를 중앙에서 관리한다.
6+
// 모듈 간 직접 import 금지 원칙을 지키면서 서비스를 공유할 수 있게 해준다.
7+
//
8+
// 사용 예:
9+
// // 등록 (모듈 초기화 시)
10+
// services.serviceRegistry.register('ledger', ledgerService);
11+
//
12+
// // 조회 (다른 모듈에서)
13+
// const ledger = services.serviceRegistry.getService<LedgerPublicApi>('ledger');
14+
//
15+
// ⚠️ getService()는 모듈이 로드되기 전이면 undefined를 반환한다.
16+
// 의존 모듈이 아직 없을 수 있으므로 항상 존재 여부를 확인할 것.
17+
18+
export class ServiceRegistry {
19+
private static _instance: ServiceRegistry | null = null;
20+
private readonly services = new Map<string, unknown>();
21+
22+
private constructor() {}
23+
24+
public static getInstance(): ServiceRegistry {
25+
if (!ServiceRegistry._instance) {
26+
ServiceRegistry._instance = new ServiceRegistry();
27+
}
28+
return ServiceRegistry._instance;
29+
}
30+
31+
/** 서비스 인스턴스 등록 */
32+
public register(name: string, service: unknown): void {
33+
if (this.services.has(name)) {
34+
log.warn('service-registry', `service "${name}" already registered — overwriting`);
35+
}
36+
this.services.set(name, service);
37+
log.info('service-registry', `service "${name}" registered`);
38+
}
39+
40+
/**
41+
* 등록된 서비스 조회.
42+
* @returns 등록된 서비스 또는 undefined (미등록 시)
43+
*/
44+
public getService<T>(name: string): T | undefined {
45+
return this.services.get(name) as T | undefined;
46+
}
47+
48+
/** 서비스 해제 (모듈 언로드 시 호출) */
49+
public unregister(name: string): boolean {
50+
const existed = this.services.has(name);
51+
if (existed) {
52+
this.services.delete(name);
53+
log.info('service-registry', `service "${name}" unregistered`);
54+
}
55+
return existed;
56+
}
57+
58+
/** 등록된 서비스 이름 목록 (디버깅용) */
59+
public list(): string[] {
60+
return Array.from(this.services.keys());
61+
}
62+
}

0 commit comments

Comments
 (0)