분산 이벤트 시스템 아키텍처
아키텍처 개요
문제점: 프로세스 내 Event Emitter의 한계
Cloud Run과 같은 스케일 아웃 환경에서는 단일 프로세스/컨테이너 내에서만 작동하는 EventEmitter를 사용하면 다음과 같은 문제가 발생합니다:
- 격리된 이벤트: 한 컨테이너에서 발생한 이벤트는 다른 컨테이너로 전파되지 않습니다.
- 데이터 일관성 문제: 사용자 요청이 다른 컨테이너로 라우팅되면 이전 이벤트 컨텍스트를 알 수 없습니다.
- 손실된 이벤트: 컨테이너 재시작 시 처리 중이던 이벤트가 유실됩니다.
해결책: EventEmitter + Pub/Sub 연동 아키텍처
아키텍처 구성:
DTA-WIDE는 다음과 같은 구조로 분산 이벤트 시스템을 구현합니다:
- 도메인 내부 채널:
questionnaire.internal.*패턴으로 도메인 내부 이벤트 처리 - 크로스 도메인 채널:
'event'채널로 모든 크로스 도메인 이벤트 처리 - 이벤트 브릿지:
'event'채널 이벤트만 GCP Pub/Sub 단일 토픽으로 전달 - 단일 Pub/Sub 토픽: 모든 크로스 도메인 이벤트 타입이 하나의 중앙화된
'events'토픽으로 발행 - Push 구독 방식: Cloud Run의 zero scaling을 활용하기 위해 Push 구독을 사용
- 단일 엔드포인트: 각 서비스는
/api/events엔드포인트를 통해 이벤트를 수신하고 적절한 로컬 이벤트로 변환
이 문서는 DTA-WIDE의 이벤트 기반 시스템 구현 방법 및 모범 사례를 설명합니다.
장점 및 이점
이 EventEmitter 기반 단일 아키텍처의 주요 장점:
도메인 내부 (EventEmitter 내부 채널)
- 강타입 안전성: TypeScript 기반 타입 체크와 인터페이스 활용
- 세밀한 제어: 내부 상태 관리 및 비즈니스 로직 최적화
- 개발자 경험: IDE 지원, 자동완성, 일관된 이벤트 패턴
- 성능 최적화: 동일 프로세스 내 빠른 이벤트 처리
크로스 도메인 (EventEmitter + Pub/Sub)
- 관리 용이성: 단일 토픽과 구독으로 관리 포인트 최소화
- 확장성: 새로운 이벤트 타입 추가 시 인프라 변경 불필요
- Zero Scaling 최적화: Cloud Run의 특성을 활용한 비용 효율적 구조
- 단순화된 권한 관리: 하나의 토픽과 구독에 대한 권한만 관리
- 유연한 필터링: 서비스별로 관심 있는 이벤트만 처리 가능
- 정보 보안: 도메인 간 통신에 적합한 데이터만 노출
전체 시스템 (EventEmitter 단일 시스템)
- 일관된 API: 모든 이벤트가 동일한 EventEmitter API 사용
- 단순한 아키텍처: 하나의 이벤트 시스템으로 복잡성 최소화
- 학습 곡선 단축: 개발자가 하나의 이벤트 패턴만 학습하면 됨
- 디버깅 용이성: 통일된 이벤트 로깅 및 모니터링
- 채널 기반 분리: 명확한 책임 분리와 적절한 정보 필터링
- 안정적인 계약: 크로스 도메인 인터페이스로 안정적인 도메인 간 통신
중복 처리 방지 및 멱등성 보장
분산 환경에서는 동일한 이벤트가 여러 컨테이너에서 중복 처리될 수 있는 문제가 발생합니다. GCP Pub/Sub은 At-least-once 배송을 보장하므로, 애플리케이션 레벨에서 멱등성을 보장해야 합니다.
핵심 원리
- 이벤트 고유 식별자: 모든 이벤트는 고유한
eventId를 포함해야 합니다. - Redis 기반 중복 체크: 원자적 연산을 통한 중복 처리 방지
- 멱등성 우선: 컨테이너 출처와 관계없이 동일한
eventId는 한 번만 처리 - 적절한 TTL 관리: 메모리 효율성과 중복 방지의 균형
주의: 자기 참조(self-reference) 방지는 하지 않습니다. Cloud Run 단일 컨테이너 환경에서는 자기 참조를 막으면 이벤트가 유실될 수 있습니다. 대신 Redis 기반 멱등성으로 중복 처리를 방지합니다.
표준 이벤트 구조
interface StandardEvent<T = any> {
type: string; // 이벤트 타입 (예: 'user-created', 'order-completed')
eventId: string; // 고유 이벤트 ID (필수)
timestamp: string; // ISO 형식 타임스탬프
source: string; // 이벤트 소스 (서비스/모듈 이름)
containerId?: string; // 컨테이너 ID (자기 참조 탐지용)
payload: T; // 이벤트 페이로드
version?: string; // 이벤트 스키마 버전
}
중복 처리 방지 구현
1. Redis 기반 멱등성 서비스
@Injectable()
export class EventDeDuplicationService {
private readonly ttl: number;
private readonly keyPrefix: string;
constructor(
private readonly redisService: RedisService,
private readonly configService: ConfigService,
) {
this.ttl = this.configService.get('EVENT_DEDUPLICATION_TTL', 86400); // 24시간 기본값
this.keyPrefix = 'events:processed:';
}
async isDuplicate(eventId: string): Promise<boolean> {
if (!eventId) return false;
const key = `${this.keyPrefix}${eventId}`;
const exists = await this.redisService.exists(key);
return exists === 1;
}
async markAsProcessed(eventId: string): Promise<void> {
if (!eventId) return;
const key = `${this.keyPrefix}${eventId}`;
await this.redisService.set(key, '1', this.ttl);
}
}
2. 이벤트 수신 컨트롤러의 중복 체크
@Controller('api/events')
export class EventsController {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly deduplicationService: EventDeDuplicationService,
) {}
@Post()
async handlePubSubEvent(@Body() pubsubMessage: PubsubMessage): Promise<void> {
try {
// 메시지 디코딩
const event = this.decodePubSubMessage(pubsubMessage);
const { type: eventType, eventId } = event;
if (!eventType || !eventId) {
this.logger.warn('Event type or eventId missing in message');
return; // ACK 처리하여 재시도 방지
}
// 1. 중복 이벤트 확인 (멱등성 보장)
if (await this.deduplicationService.isDuplicate(eventId)) {
this.logger.debug(`Skipping duplicate event: ${eventId}`);
return; // 이미 처리된 이벤트는 ACK
}
// 2. 이벤트를 로컬 이벤트 버스로 발행
// 컨테이너 출처와 관계없이 모든 이벤트 처리 (Cloud Run 단일 컨테이너 대응)
this.eventEmitter.emit(eventType, event.payload);
// 3. 처리 완료 기록
await this.deduplicationService.markAsProcessed(eventId);
} catch (error) {
// 재시도 가능한 에러와 불가능한 에러 구분
if (this.isRetryableError(error)) {
throw new HttpException('Event processing failed, will retry', HttpStatus.INTERNAL_SERVER_ERROR);
} else {
this.logger.warn('Non-retryable error, acknowledging message', { eventId });
// 재시도 불가능한 에러는 ACK 처리
}
}
}
private decodePubSubMessage(pubsubMessage: PubsubMessage): StandardEvent {
const data = Buffer.from(pubsubMessage.message.data, 'base64').toString('utf-8');
return JSON.parse(data) as StandardEvent;
}
private isRetryableError(error: any): boolean {
// 네트워크 오류, 일시적 DB 연결 실패 등은 재시도 가능
// 데이터 형식 오류, 비즈니스 로직 오류 등은 재시도 불가능
return error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT' ||
error.message.includes('connection');
}
}
3. 원자적 중복 체크 (더 강력한 방법)
Redis SET NX를 사용한 원자적 중복 체크:
private async isDuplicateEvent(eventId: string): Promise<boolean> {
const key = `processed_events:${eventId}`;
// SET NX: Set if Not Exists (원자적 연산)
const result = await this.redisService.set(key, '1', 'NX', 'EX', 86400);
return result === null; // null이면 이미 키가 존재함 (중복)
}
모범 사례
- 이벤트 발행 시: 항상 고유한
eventId생성 및 포함 - 이벤트 수신 시: 중복 체크를 비즈니스 로직 처리 전에 실행
- 처리 완료 후: 반드시 처리 완료 마킹
- 에러 핸들링: 재시도 가능/불가능 에러 명확히 구분
- 로깅: 중복 처리 건수와 패턴 모니터링
- TTL 관리: 적절한 TTL 설정으로 메모리 효율성 확보 (기본 24시간)
- Cloud Run 대응: 단일 컨테이너 환경을 고려하여 자기 참조 방지 금지
주의사항
- 비즈니스 로직 멱등성: 중복 체크 외에도 비즈니스 로직 자체가 멱등적이어야 함
- 동시성: 동일
eventId로 동시에 여러 요청이 오는 경우 Redis 원자적 연산으로 해결 - 장애 복구: Redis 장애 시 중복 처리가 발생할 수 있으므로 비즈니스 로직에서도 방어 코드 필요
- 자기 참조: Cloud Run 스케일링 특성상 자기 참조를 막으면 이벤트 유실 위험이 있으므로 금지
이벤트 스키마 관리
스키마 버전 관리
모든 이벤트는 스키마 버전을 포함하여 하위 호환성을 보장합니다:
interface VersionedEvent extends StandardEvent {
version: string; // 예: "1.0", "1.1", "2.0"
schemaUrl?: string; // 스키마 정의 URL (선택적)
}
하위 호환성 규칙
- 필드 추가: 기존 필드는 유지하고 새 필드만 추가 (옵셔널)
- 필드 제거: 기존 필드는 deprecated 마킹 후 점진적 제거
- 타입 변경: 새로운 버전으로 처리, 이전 버전 지원 유지
- 필드 이름 변경: 새 필드 추가 + 기존 필드 deprecated
실제 이벤트 흐름 예시: UserCycleStartedEvent
이 섹션에서는 UserCycleStartedEvent를 예시로 분산 이벤트 시스템의 전체 라이프사이클을 설명합니다.
시나리오
신규 사용자가 가입하면서 새로운 치료주기(User Cycle)가 시작되고, 이 정보가 Sleep, Time Machine, Questionnaire, Auth 등 다른 도메인들에게 전파되는 과정입니다.
전체 흐름 다이어그램
1. 내부 이벤트 발행
사용자 주기 생성 후 내부 도메인 이벤트를 발행합니다.
// libs/feature/user/src/lib/application/commands/handlers/create-user-cycle.handler.ts
// UserCycle 저장 후 내부 이벤트 발행
const cycleStartedEvent = new UserCycleStartedEvent(
userId,
savedCycle.id,
planId,
startedAt,
timezoneId,
{ offsetInMinutes, source: 'create-user-cycle-command' }
);
// 내부 도메인 채널로 발행
this.eventEmitter.emit(
USER_INTERNAL_EVENT_CHANNELS.USER_CYCLE_STARTED, // 'user.internal.cycle.started'
cycleStartedEvent
);
2. 내부 이벤트 처리 및 크로스 이벤트 준비
내부 이벤트 핸들러에서 도메인 내 처리 후 크로스 도메인 이벤트를 발행합니다.
// libs/feature/user/src/lib/application/events/handlers/user-internal-event-handlers.ts
@OnEvent(USER_INTERNAL_EVENT_CHANNELS.USER_CYCLE_STARTED)
async handleUserCycleStarted(event: UserCycleStartedEvent): Promise<void> {
try {
// 1. 도메인 내부 처리 (캐시 무효화 등)
await this.userTreatmentService.invalidateCacheOnCycleStatusChange(event.userId);
// 2. 크로스 도메인 이벤트 발행
await this.crossEventPublisher.publishUserCycleStarted(
event.userId,
event.userCycleId,
event.planId,
event.startedAt,
event.timezoneId
);
await this.logger.info(`User cycle started event processed successfully for user ${event.userId}`);
} catch (error: unknown) {
await this.logAndThrowError('Failed to process user cycle started event', error, event, event.userId);
}
}
3. 크로스 도메인 이벤트 발행
표준화된 크로스 이벤트를 GCP Pub/Sub으로 발행합니다.
// libs/feature/user/src/lib/application/events/handlers/user-cross-event-publisher.ts
async publishUserCycleStarted(
userId: string,
userCycleId: string,
planId: string,
startedAt: Date,
timezoneId?: string
): Promise<void> {
try {
// 표준 크로스 이벤트 구조 생성
const event = this.createStandardEvent(
USER_CROSS_EVENT_TYPES.USER_CYCLE_STARTED, // 'user.cycle.started'
{
userId,
userCycleId,
planId,
startedAt: startedAt.toISOString(),
timezoneId,
}
) as UserCycleStartedCrossEvent;
// 'event' 채널로 발행 → GCP Pub/Sub으로 전송
this.eventEmitter.emit('event', event);
await this.loggerService.debug(`Published user cycle started cross event for user ${userId}`);
} catch (error) {
await this.loggerService.error('Failed to publish user cycle started cross event', error);
throw error;
}
}
4. 중앙화된 분산 이벤트 수신
GCP Pub/Sub에서 전송된 이벤트를 중앙 컨트롤러에서 수신하고 처리합니다.
// apps/dta-wide-api/src/app/events/services/events.service.ts
async processEvent(pubsubMessage: PubsubMessage): Promise<EventProcessingResult> {
try {
// 1. 메시지 디코딩
const event = this.decodePubSubMessage(pubsubMessage);
const eventId = event.eventId;
const eventType = event.type; // 'user.cycle.started'
// 2. 중복 이벤트 확인 (Redis 기반 멱등성)
if (await this.isDuplicateEvent(eventId)) {
await this.logger.info(`Skipping duplicate event - eventId: ${eventId}, eventType: ${eventType}`);
return { success: true, eventId, eventType, reason: 'Duplicate event' };
}
// 3. 로컬 EventEmitter로 재발행 (구체적인 이벤트 타입으로)
this.eventEmitter.emit(eventType, event.payload, event);
// → Sleep, Auth 등 도메인의 @OnEvent(USER_CROSS_EVENT_TYPES.USER_CYCLE_STARTED) 핸들러로 전달
// 4. 처리 완료 마킹
await this.markEventAsProcessed(eventId);
return { success: true, eventId, eventType };
} catch (error) {
// 에러 처리 로직...
}
}
5. 각 도메인에서 이벤트 수신
다른 도메인들이 구체적인 이벤트 타입을 구독하여 처리합니다.
Sleep 도메인 수신 예시
// libs/feature/sleep/src/lib/application/events/handlers/sleep-cross-event-receiver.ts
@OnEvent(USER_CROSS_EVENT_TYPES.USER_CYCLE_STARTED) // 'user.cycle.started'
async handleUserCycleStarted(event: StandardEvent): Promise<void> {
try {
const { userId, cycleId, startDate } = event.payload;
await this.logger.info(
`Processing user cycle started: ${userId} cycle ${cycleId} starting on ${startDate}`,
String(userId)
);
// Sleep 도메인 특화 처리
await this.initializeSleepDataForNewCycle(userId, cycleId, startDate);
await this.logger.debug('User cycle started processed successfully', String(userId));
} catch (error: unknown) {
await this.logger.error('Failed to process user cycle started', error, { event, domain: 'sleep' });
}
}
Auth 도메인 수신 예시
// libs/feature/auth/src/lib/application/events/handlers/auth-cross-event-receiver.ts
@OnEvent(USER_CROSS_EVENT_TYPES.USER_CYCLE_STARTED) // 'user.cycle.started'
async handleUserCycleStarted(event: StandardEvent): Promise<void> {
try {
const { userId, cycleId, startDate } = event.payload;
await this.logger.info(
`Processing user cycle started: ${userId} cycle ${cycleId} starting on ${startDate}`,
String(userId)
);
// Auth 도메인 특화 처리
await this.setupAuthForNewCycle(userId, cycleId, startDate);
await this.logger.debug('User cycle started processed successfully', String(userId));
} catch (error: unknown) {
await this.logger.error('Failed to process user cycle started', error, { event, domain: 'auth' });
}
}
주요 특징 정리
1. 도메인 내부 vs 크로스 도메인 분리
- 내부 이벤트:
'user.internal.cycle.started'채널로 도메인 내부에서만 처리 - 크로스 이벤트:
'user.cycle.started'타입으로 다른 도메인들이 수신
2. 멱등성 보장
- Redis 기반 중복 처리 방지로 동일한
eventId는 한 번만 처리 - 여러 컨테이너에서 동시에 수신해도 안전
3. 느슨한 결합
- User 도메인은 다른 도메인의 존재를 모름
- 각 도메인은 필요한 이벤트만 선택적으로 구독
4. 확장성
- 새로운 도메인 추가 시 기존 코드 변경 없이 이벤트 구독 가능
- 이벤트 스키마 버전 관리로 하위 호환성 보장
이 예시를 통해 DTA-WIDE의 분산 이벤트 시스템이 어떻게 실제로 동작하는지 이해할 수 있습니다.
모니터링 및 관찰성
핵심 메트릭
- 이벤트 처리량: 초당 처리되는 이벤트 수
- 중복 처리율: 전체 이벤트 대비 중복 처리된 이벤트 비율
- 처리 지연 시간: 이벤트 발행부터 처리 완료까지의 시간
- 에러율: 처리 실패 이벤트 비율
- 재시도율: 재시도된 이벤트 비율
로깅 표준
// 이벤트 수신 로그
this.logger.debug('Received Pub/Sub event', {
eventType,
eventId,
source: event.source,
messageId: pubsubMessage.message.messageId
});
// 중복 처리 로그
this.logger.log('Skipping duplicate event', { eventId, eventType });
// 처리 완료 로그
this.logger.log('Event processed successfully', {
eventId,
eventType,
processingTimeMs: endTime - startTime
});
이러한 가이드라인을 모든 도메인에서 일관되게 적용하여 안정적이고 확장 가능한 분산 이벤트 시스템을 구축할 수 있습니다.
이벤트 핸들러 구조 표준화
파일 구조 표준
모든 도메인은 일관된 이벤트 핸들러 파일 구조를 따라야 합니다:
libs/feature/{domain}/src/lib/application/events/handlers/
├── index.ts # 모든 핸들러 export
├── {domain}-internal-event-handlers.ts # 도메인 내부 이벤트 통합 핸들러
├── {domain}-cross-event-publisher.ts # 크로스 도메인 이벤트 발행
└── {domain}-cross-event-receiver.ts # 크로스 도메인 이벤트 수신
이벤트 핸들러 역할 분리
1. Internal Event Handlers (내부 이벤트 핸들러)
파일명: {domain}-internal-event-handlers.ts
책임:
- 모든 도메인 내부 이벤트를 단일 클래스에서 처리
@OnEvent데코레이터 방식으로 일관된 이벤트 처리- 도메인 내부 로직 (캐시 무효화, 데이터 처리 등)
- 필요시 크로스 도메인 이벤트 발행
구현 예시:
@Injectable()
export class QuestionnairInternalEventHandlers {
constructor(
private readonly cacheService: QuestionnaireCacheService,
private readonly logger: LoggerService,
private readonly crossEventPublisher: QuestionnaireCrossEventPublisher
) {}
/**
* 설문 응답 완료 이벤트 처리 (도메인 내부 + 크로스 도메인)
*/
@OnEvent(QUESTIONNAIRE_INTERNAL_EVENT_CHANNELS.RESPONSE_COMPLETED)
async handleResponseCompleted(event: QuestionnaireResponseCompletedEvent): Promise<void> {
try {
// 1. 도메인 내부 처리 (캐시 무효화)
await this.cacheService.invalidateUserCache(event.userId);
await this.performResponseCompletionTasks(event);
// 2. 크로스 도메인 이벤트 발행 (필요시)
await this.crossEventPublisher.publishResponseCompleted(
event.userId,
event.userCycleId,
event.questionnaireId,
event.questionnaireType,
event.roundNumber,
event.responses,
event.score
);
await this.logger.debug('Response completed processed successfully', event.userId);
} catch (error: unknown) {
await this.logAndThrowError('Failed to process response completion', error, event, event.userId);
}
}
// 다른 내부 이벤트 핸들러들...
}
2. Cross Event Publisher (크로스 도메인 이벤트 발행자)
파일명: {domain}-cross-event-publisher.ts
책임:
- 도메인 내부 이벤트를 크로스 도메인 이벤트로 변환 및 발행
EventEmitter2의'event'채널로만 발행- 도메인별 데이터 필터링 및 변환
구현 예시:
@Injectable()
export class QuestionnaireCrossEventPublisher {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService
) {}
/**
* 설문 응답 완료 이벤트 발행 (크로스 도메인)
*/
public async publishResponseCompleted(
userId: string,
userCycleId: string,
questionnaireId: string,
questionnaireType: string,
roundNumber: number,
responses: any[], // 내부→외부 변환 시 필터링됨
score: number
): Promise<void> {
try {
const crossEvent = createQuestionnaireResponseCompletedCrossEvent(
userId,
userCycleId,
questionnaireId,
questionnaireType,
roundNumber,
responses,
score
);
// 'event' 채널로만 발행 (Pub/Sub으로 전달됨)
this.eventEmitter.emit('event', crossEvent);
await this.logger.debug(`Published response completed cross event: ${questionnaireType}`, userId);
} catch (error) {
await this.logger.error('Failed to publish response completed cross event', error, { userId, questionnaireId });
throw error;
}
}
}
3. Cross Event Receiver (크로스 도메인 이벤트 수신자)
파일명: {domain}-cross-event-receiver.ts
책임:
- 외부 도메인에서 오는 크로스 도메인 이벤트 수신
- 자신의 도메인에 관련된 이벤트만 처리
- 적절한 내부 이벤트로 변환 및 처리
구현 예시:
@Injectable()
export class QuestionnaireCrossEventReceiver {
constructor(
private readonly logger: LoggerService
) {}
/**
* 사용자 일시정지 상태 변경 이벤트 수신 (외부 도메인에서)
*/
@OnEvent(USER_SUSPENSION_STATUS_CHANGED_EVENT_TYPE)
async handleUserSuspensionStatusChanged(event: StandardEvent<UserSuspensionStatusCheckedEvent>): Promise<void> {
try {
const { userId, isSuspended, reason } = event.payload;
await this.logger.info(
`Processing user suspension status change: user ${userId}, suspended: ${isSuspended}, reason: ${reason}`,
userId
);
if (isSuspended) {
// 설문 참여 제한 로직
await this.handleUserSuspension(userId, reason);
} else {
// 설문 참여 재개 로직
await this.handleUserReactivation(userId);
}
await this.logger.debug('User suspension status change processed successfully', userId);
} catch (error: unknown) {
await this.logger.error('Failed to process user suspension status change', error, { event });
throw error;
}
}
private async handleUserSuspension(userId: string, reason: string): Promise<void> {
// 사용자 일시정지에 따른 설문 관련 처리
}
private async handleUserReactivation(userId: string): Promise<void> {
// 사용자 재활성화에 따른 설문 관련 처리
}
}
모듈 구성 표준
각 도메인의 feature 모듈에서는 다음과 같이 이벤트 핸들러를 구성합니다:
// Event Handlers
const eventHandlers: Provider[] = [
// 통합된 내부 이벤트 핸들러
DomainInternalEventHandlers,
// 크로스 도메인 이벤트 관리
DomainCrossEventReceiver,
DomainCrossEventPublisher,
];
@Module({
imports: [
CqrsModule,
CoreRedisModule,
CoreLoggingModule,
EventEmitterModule.forRoot()
],
providers: [
...commandHandlers,
...queryHandlers,
...eventHandlers,
...repositoryProviders,
],
exports: [
DomainCrossEventPublisher,
DomainCrossEventReceiver,
...repositoryProviders
],
})
export class FeatureDomainModule {}
이벤트 채널 명명 규칙
내부 이벤트 채널
export const DOMAIN_INTERNAL_EVENT_CHANNELS = {
// 데이터 라이프사이클
DATA_ARCHIVED: 'domain.internal.data.archived',
DATA_RESTORED: 'domain.internal.data.restored',
DATA_DELETED_INTERNAL: 'domain.internal.data.deleted',
// 도메인 특화 이벤트
RESPONSE_COMPLETED: 'domain.internal.response.completed',
ROUND_COMPLETED: 'domain.internal.round.completed',
// 관리 이벤트
ENTITY_CREATED: 'domain.internal.entity.created',
ENTITY_UPDATED: 'domain.internal.entity.updated',
ENTITY_DELETED: 'domain.internal.entity.deleted',
} as const;
크로스 도메인 이벤트 타입
export const DOMAIN_CROSS_EVENT_TYPES = {
RESPONSE_COMPLETED: 'domain-response-completed',
ROUND_COMPLETED: 'domain-round-completed',
DATA_DELETED: 'domain-data-deleted',
} as const;
구현 가이드라인
1. 중복 제거 원칙
- 동일한 이벤트를 여러 곳에서 처리하지 않음
- 모든 내부 이벤트는 하나의 통합 핸들러에서 처리
- EventEmitter 직접 구독 대신
@OnEvent데코레이터 사용
2. 책임 분리 원칙
- Internal Handlers: 도메인 내부 로직 + 필요시 크로스 도메인 발행
- Cross Publishers: 내부→외부 이벤트 변환 및 발행만
- Cross Receivers: 외부→내부 이벤트 수신 및 변환만
3. 일관성 원칙
- 모든 도메인이 동일한 파일 구조와 명명 규칙 사용
- 이벤트 처리 패턴의 일관성 유지
- 로깅 및 에러 처리 방식 통일
4. 확장성 원칙
- 새로운 이벤트 추가 시 기존 구조 변경 최소화
- 이벤트 스키마 버전 관리로 하위 호환성 보장
- 도메인별 독립적인 이벤트 핸들러 진화 가능
마이그레이션 가이드
기존 이벤트 핸들러를 표준 구조로 변경하는 절차:
- 현재 구조 분석: 기존 이벤트 핸들러들의 역할과 중복 확인
- 통합 계획: Internal/Cross Publisher/Cross Receiver로 역할 분리
- Internal Handlers 통합: 모든 내부 이벤트를 하나의 클래스로 통합
- Cross Event 분리: 크로스 도메인 이벤트 발행/수신 로직 분리
- 모듈 구성 변경: 새로운 핸들러 구조로 DI 구성 변경
- 기존 파일 제거: 중복된 핸들러 파일들 정리
이 표준화를 통해 모든 도메인에서 일관되고 유지보수 가능한 이벤트 아키텍처를 구축할 수 있습니다.