Search

이벤트 소싱의 이론부터 실전까지: NestJS로 구현하는 리모트 컨피그 서비스

Tags
Post
Event Sourcing
Last edited time
2025/04/02 08:49
2 more properties
이 포스팅은 당근 Server 밋업에서 원지혁님께서 발표한 영상을 바탕으로 작성되었습니다. 해당 영상의 내용을 NestJS로 구현하고 개인적으로 공부한 내용을 추가했습니다. 원본내용이 궁금하신 분들은 아래 링크를 참고해주세요. link iconYouTube이벤트 소싱을 활용해 확장 가능한 사내 플랫폼 만들기 | 당근 SERVER 밋업 2회 포스팅에서 작성된 소스코드는 아래 링크에서 확인 가능합니다. nest-event-sourcing-sample

1. 들어가기 앞서

이벤트 소싱(Event Sourcing) 패턴은 시스템의 모든 변경사항을 이벤트로 기록하여 확장 가능하고 유지보수가 용이한 애플리케이션을 구축할 수 있게 해주는 아키텍처 패턴입니다.
이 글에서는 이벤트 소싱 패턴의 기본 개념부터 실제 구현 방법, 그리고 이를 통해 사내 플랫폼을 효과적으로 구축하는 방법까지 상세히 알아보겠습니다.

2. 이벤트 소싱의 기본 개념

기존의 데이터베이스에서는 "현재 상태"만 저장하고 업데이트합니다. 사용자가 정보를 변경하면 이전 데이터는 사라지고 새 데이터로 덮어씁니다. 반면, 이벤트 소싱에서는 모든 변경사항이 새로운 이벤트로 추가되며, 이전 데이터는 절대 수정되지 않습니다.
이러한 접근법은 회계 장부와 유사합니다. 회계사는 잔액이 변경될 때마다 기존 항목을 지우고 새 잔액을 기록하는 대신, 모든 거래를 순차적으로 기록하고 필요할 때 합계를 계산합니다. 이렇게 하면 완전한 감사 추적이 가능하고, 어떤 거래가 특정 결과를 초래했는지 분석할 수 있습니다.
이벤트 소싱 시스템에서 데이터를 조회할 때는 관련된 모든 이벤트를 시간순으로 재생하여 현재 상태를 계산합니다. 이 "재생" 과정은 효율성을 위해 최적화될 수 있으며, 필요에 따라 상태의 스냅샷을 저장하여 모든 이벤트를 처음부터 다시 계산할 필요가 없도록 할 수 있습니다.
이벤트 소싱을 구성하는 요소는 총 4가지 입니다. 지금부터 구성요소들을 살펴보도록 하겠습니다.
이벤트(Event)
스테이트 (State)
리듀서 (Reducer)
엔티티 (Entity)

2.1. 이벤트(Event)

시스템에서 발생 가능한 사건을 의미합니다. 각 이벤트는 다음과 같은 요소를 포함합니다.
고유 ID (entityId)
생성 시간 (timestamp)
이벤트 타입 (type)
이벤트 생성 유저 (userId)
이벤트 내용(body)
이벤트는 이벤트 소싱의 핵심이며, 다음과 같은 특성을 갖습니다.
1.
불변성(Immutability)
이벤트는 한 번 생성되면 절대 변경되지 않습니다. 이는 데이터 정합성과 시스템 신뢰성의 기반이 됩니다.
2.
자기 완결성(Self-contained)
각 이벤트는 그 자체로 의미를 가지며, 필요한 모든 정보를 포함합니다.
3.
시간 순서(Temporal)
이벤트는 항상 시간 순서대로 저장되고 처리됩니다.
4.
도메인 중심(Domain-centric)
이벤트는 비즈니스 도메인의 언어와 개념을 반영합니다.
실제 구현에서 이벤트는 MongoDB와 같은 데이터베이스에 문서로 저장됩니다. 각 이벤트는 특정 엔티티에 속하며, 저장된 시간과 타입을 통해 어떤 변경이 발생했는지를 정확히 기록합니다. 또한 변경을 수행한 사용자 정보를 포함하여 감사 추적에 활용할 수 있습니다.
{ "entityId": "uuid-123", "type": "SetParameter", "timestamp": "2023-11-01T12:34:56Z", "userId": "1234" "body": { "key": "hello", "value": "world" } }
JSON
복사

2.2. 스테이트(State)

이벤트들을 모아서 계산한 객체의 최종 상태를 의미합니다. 아래는 파라미터를 설정한 이벤트를 적용한 후의 상태 예시입니다.
상태(State)는 이벤트 소싱에서 파생된 결과물입니다. 같은 이벤트 시퀀스를 처리하면 항상 동일한 상태가 생성되므로, 상태는 이벤트의 함수로 볼 수 있습니다. 이벤트 소싱에서 상태는 메모리에 유지되거나, 성능 향상을 위해 캐시나 스냅샷으로 저장될 수 있습니다. 이러한 상태는 이벤트 스트림의 특정 시점까지의 "뷰"를 나타내며, 다양한 형태로 최적화될 수 있습니다.
{ "parameters": { "hello": "world" } }
JSON
복사

2.3. 리듀서(Reducer)

이벤트와 이전 상태를 인자로 받아 다음 상태를 계산하는 함수입니다. 이는 불변성(immutability)을 유지하며 상태를 변경하는 순수 함수입니다.
리듀서는 이벤트 소싱 시스템의 "인터프리터"입니다. 이 함수는 이전 상태와 이벤트를 입력으로 받아 새로운 상태를 반환합니다. 리듀서의 주요 특징은 다음과 같습니다.
1.
순수 함수(Pure Function)
동일한 입력에 대해 항상 동일한 출력을 반환하며, 부수 효과(side effect)가 없습니다.
2.
불변성(Immutability)
입력 상태를 직접 수정하지 않고 항상 새로운 상태 객체를 반환합니다.
3.
결정론적(Deterministic)
외부 상태나 난수에 의존하지 않고 입력만으로 결과가 결정됩니다.
4.
도메인 로직 집중화
비즈니스 규칙과 도메인 로직이 리듀서에 집중됩니다.
리듀서를 설계할 때는 각 이벤트 타입별로 상태 변환 로직을 명확히 정의해야 합니다. 이벤트 타입에 따라 switch문이나 전략 패턴을 사용하여 적절한 로직을 실행하는 것이 일반적입니다. 리듀서 함수는 테스트하기 쉽고, 상태 변화의 일관성을 보장합니다.
let state; for (const event of events) { const nextState = reducer(state, event); state = nextState; } console.log(state); // 최종 상태 // Array.reduce를 쓰는게 일반적이나, 쉬운 이해를 위해 for 문 사용
TypeScript
복사

2.4. 엔티티 (Entity)

엔티티는 하나의 객체를 의미하며, 각 이벤트는 특정 엔티티에 속합니다. 엔티티 ID를 통해 특정 엔티티에 속한 이벤트만 조회할 수 있습니다.
이벤트 소싱 시스템에서 엔티티를 조회할 때는 엔티티 ID를 기준으로 관련 이벤트를 필터링하고, 이를 시간순으로 리듀서에 적용하여 최종 상태를 계산합니다. 이는 데이터의 일관성을 보장하면서도 특정 엔티티에 대한 정보를 효율적으로 조회할 수 있게 합니다.
// 기본 이벤트 인터페이스 export interface BaseEvent { entityId: string; timestamp: Date; type: string; userId?: string; // 감사 로그를 위한 사용자 ID }
TypeScript
복사

2.5. CRUD와 이벤트 소싱의 차이점

CRUD 모델에서는 객체와 데이터베이스 레코드가 1:1로 대응되어 직접 수정되지만, 이벤트 소싱에서는
생성은 생성 이벤트를 추가하는 것
수정은 수정 이벤트를 추가하는 것
삭제는 삭제 이벤트를 추가하는 것
읽기는 모든 관련 이벤트를 시간순으로 적용하여 현재 상태를 계산하는 것
이런 방식으로 데이터베이스 기능 중 Create와 Read만을 사용하여 개발하게 됩니다. 실제로 데이터를 수정하거나 삭제하는 대신, 새로운 이벤트를 추가하여 변경 사항을 표현합니다.
이벤트 소싱은 특히 비즈니스 트랜잭션의 이력이 중요하거나, 시간에 따른 상태 변화를 추적해야 하는 시스템에서 큰 이점을 제공합니다. 또한 마이크로서비스 아키텍처와 도메인 주도 설계(DDD)와 같은 현대적인 접근법과 자연스럽게 결합됩니다.

3. 인터널 플랫폼의 정의와 요구사항

인터널 플랫폼은 기업 내부 구성원(개발자, 디자이너 등)을 위한 제품으로, API 통합이나 워크플로우 통합 형태로 사용됩니다. 예를 들면 슬랙, 지라와 같은 업무 커뮤니케이션 툴, 노션과 같은 문서 플랫폼, 리모트 컨피그와 같은 배포 플랫폼 등이 있습니다.
핵심 요구사항은 다음과 같습니다.
감사 기능(Audit Trail): 모든 변경 사항과 그 주체를 기록하여 원인 파악과 보안 이슈 대응에 활용합니다.
롤백 용이성: 실수나 오류 발생 시 특정 시점으로 시스템을 되돌릴 수 있어야 합니다.
권한 관리: 사용자별로 다른 권한을 부여하여 중요한 시스템 변경을 안전하게 관리합니다.
확장성: 외부 도구와의 연동을 통해 플랫폼의 생산성을 향상시키고, API를 제공하여 개발 부담을 줄입니다.
이러한 요구사항들은 이벤트 소싱 패턴을 활용하면 적은 노력으로 구현할 수 있습니다.

4. 이벤트 소싱을 활용한 리모트 컨피그 서비스 구현

이제 Nest.js, TypeScript, MongoDB를 사용하여 이벤트 소싱 패턴으로 리모트 컨피그 서비스를 구현해보겠습니다.

4.1. 기본 환경 설정

import { NestFactory } from '@nestjs/core'; import { AppModule } from '@src/app.module'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();
TypeScript
복사

4.2. 엔티티 및 이벤트 정의

먼저 기본 이벤트 인터페이스와 설정 관련 이벤트들을 정의합니다. 리모트 컨피그 시스템에서 이벤트 인터페이스는 설정 변경의 모든 측면을 정확히 포착할 수 있도록 설계되어야 합니다. 타입스크립트의 인터페이스 상속과 union 타입을 활용하여 유연하면서도 명확한 이벤트 타입 시스템을 구축할 수 있습니다.
// 기본 이벤트 인터페이스 export interface BaseEvent { entityId: string; timestamp: Date; type: string; userId?: string; // 감사 로그를 위한 사용자 ID } // 설정 생성 이벤트 export interface ConfigCreatedEvent extends BaseEvent { type: 'ConfigCreated'; body: { name: string; description?: string; }; } // 파라미터 설정 이벤트 export interface SetParameterEvent extends BaseEvent { type: 'SetParameter'; body: { key: string; value: string | number | boolean; }; } // 파라미터 삭제 이벤트 export interface DeleteParameterEvent extends BaseEvent { type: 'DeleteParameter'; body: { key: string; }; } // 모든 설정 관련 이벤트 타입 export type ConfigEvent = ConfigCreatedEvent | SetParameterEvent | DeleteParameterEvent; // 설정 상태 인터페이스 export interface ConfigState { id?: string; name?: string; description?: string; parameters: Record<string, any>; webhooks: Array<{ url: string; events: string[]; }>; version?: number; createdAt?: Date; updatedAt?: Date; }
TypeScript
복사
다음으로 MongoDB 스키마를 정의합니다. NestJS와 Mongoose의 조합은 MongoDB와 TypeScript를 효율적으로 연동할 수 있게 해주며, 데코레이터를 통한 스키마 정의는 코드의 가독성과 유지보수성을 높입니다.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Document } from 'mongoose'; @Schema({ timestamps: true }) export class Event extends Document { @Prop({ required: true }) entityId: string; @Prop({ required: true }) type: string; @Prop({ required: true, default: Date.now }) timestamp: Date; @Prop() userId?: string; @Prop({ type: Object, required: true }) body: Record<string, any>; } export const EventSchema = SchemaFactory.createForClass(Event); // timestamp를 기준으로 정렬을 위한 인덱스 추가 EventSchema.index({ entityId: 1, timestamp: 1 });
TypeScript
복사

4.3. 리듀서 구현

리듀서는 이벤트를 적용하여 새로운 상태를 계산하는 순수 함수입니다.
리듀서 함수는 시스템의 비즈니스 규칙을 코드로 표현하며, 모든 상태 변경 로직이 한 곳에 집중되므로 유지보수와 테스트가 용이합니다. 또한 리듀서의 순수 함수 특성은 동일한 이벤트 시퀀스에 대해 항상 동일한 결과를 보장하며, 이는 시스템의 결정론적 동작과 재현 가능성을 제공합니다.
import { ConfigEvent, ConfigState } from '@src/configs/events/config-event'; // 초기 상태 정의. 기본 상태를 별도 상수로 정의하여 재사용성과 가독성을 높임 export const initialConfigState: ConfigState = { parameters: {}, webhooks: [], }; /** * 설정 리듀서 함수 * 이전 상태와 이벤트를 받아 새로운 상태를 반환합니다. */ export function configReducer( prevState: ConfigState = initialConfigState, event: ConfigEvent ): ConfigState { // 항상 새 객체를 반환하여 불변성 유지 const newState = { ...prevState }; // 이벤트 타입에 따라 상태 갱신 switch (event.type) { case 'ConfigCreated': return { ...newState, id: event.entityId, name: event.body.name, description: event.body.description, createdAt: event.timestamp, updatedAt: event.timestamp, }; case 'SetParameter': return { ...newState, parameters: { ...newState.parameters, [event.body.key]: event.body.value }, updatedAt: event.timestamp, }; case 'DeleteParameter': { const newParams = { ...newState.parameters }; delete newParams[event.body.key]; return { ...newState, parameters: newParams, updatedAt: event.timestamp, }; } default: return newState; } }
TypeScript
복사

4.4. API 엔드포인트 구현

구현한 API는 다음과 같습니다.
컨피그 생성
컨피그 조회
컨피그 내 파리미터 추가
컨피그 내 파라미터 제거
@Controller('api/v1/configs') export class ConfigsController { constructor(private readonly configsService: ConfigsService) {} @Post() @HttpCode(HttpStatus.CREATED) async createConfig( @Body() createConfigDto: { name: string; description?: string }) { return this.configsService.createConfig(createConfigDto); } @Get(':id') async getConfig(@Param('id') id: string) { return this.configsService.getConfig(id); } @Post(':id/parameters') async setParameter( @Param('id') id: string, @Body() body: { key: string; value: any } ) { return this.configsService.setParameter(id, body.key, body.value); } @Delete(':id/parameters/:key') async deleteParameter( @Param('id') id: string, @Param('key') key: string ) { return this.configsService.deleteParameter(id, key); } }
TypeScript
복사

4.5. 서비스 구현

dispatch-commit 패턴을 활용하여 비즈니스 로직을 처리하는 서비스 레이어를 구현합니다.
1.
먼저 dispatch를 호출하여 이벤트를 생성하고 새로운 상태를 계산합니다.
2.
계산된 상태를 검증하거나 추가 작업을 수행할 수 있습니다.
3.
마지막으로 commit을 호출하여 이벤트를 영구적으로 저장합니다.
@Injectable() export class ConfigsService { constructor( @Inject('CONFIG_EVENT_REPOSITORY') private readonly eventRepository: EventRepository<ConfigState>, ) {} /** * 새 컨피그 생성 */ async createConfig(createConfigDto: { name: string; description?: string }): Promise<ConfigState> { const { name, description } = createConfigDto; if (!name) { throw new BadRequestException('이름은 필수 항목입니다.'); } const entityId = uuidv4(); const { entity, commit } = await this.eventRepository.dispatch(entityId, { type: 'ConfigCreated', body: { name, description } }); await commit(); return entity; } /** * 컨피그 조회 */ async getConfig(id: string): Promise<ConfigState> { const config = await this.eventRepository.findById(id); if (!config) { throw new NotFoundException('설정을 찾을 수 없습니다.'); } return config; } /** * 컨피그 내 파라미터 추가 */ async setParameter(id: string, key: string, value: any): Promise<ConfigState> { if (!key) { throw new BadRequestException('키는 필수 항목입니다.'); } const config = await this.eventRepository.findById(id); if (!config) { throw new NotFoundException('설정을 찾을 수 없습니다.'); } const { entity, commit } = await this.eventRepository.dispatch(id, { type: 'SetParameter', body: { key, value } }); await commit(); return entity; } /** * 컨피그 내 파라미터 삭제 */ async deleteParameter(id: string, key: string): Promise<ConfigState> { const config = await this.eventRepository.findById(id); if (!config) { throw new NotFoundException('설정을 찾을 수 없습니다.'); } const { entity, commit } = await this.eventRepository.dispatch(id, { type: 'DeleteParameter', body: { key } }); await commit(); return entity; } }
TypeScript
복사

5. 추가 고도화

5.1. 제너릭을 활용한 공통 인터페이스 구현

반복적인 코드를 줄이기 위해 이벤트 소싱을 위한 인터페이스를 만들거나 활용할 수 있습니다. 아래는 간단한 이벤트 소싱 인터페이스 예시입니다.
이벤트 소싱 패턴은 다양한 도메인 엔티티에 적용될 수 있으므로, 코드 중복을 피하기 위해 제네릭 레포지토리를 구현하는 것이 효율적입니다. TypeScript의 제네릭 기능을 활용하면 타입 안전성을 유지하면서도 재사용 가능한 컴포넌트를 만들 수 있습니다.
@Injectable() export class EventRepository<TState> { constructor( private readonly eventModel: Model<Event>, private readonly reducer: (state: TState, event: any) => TState, private readonly initialState: TState, ) { } /** * 엔티티 ID로 상태 조회 */ async findById(entityId: string): Promise<TState | null> { const state: TState = { ...this.initialState } as TState; const events = await this.eventModel.find( { entityId } ).sort({ timestamp: 1 }).exec(); if (events.length === 0) { return null; // 엔티티가 존재하지 않음 } // 이벤트 적용하여 최종 상태 계산 const finalState = events.reduce(this.reducer, state); return finalState; } /** * 이벤트 발행 및 상태 계산 */ async dispatch( entityId: string, eventData: { type: string; body: any; userId?: string }): Promise<{ entity: TState; commit: () => Promise<void>; }> { const currentState = await this.findById(entityId) || { ...this.initialState } as TState; const event = new this.eventModel({ ...eventData, entityId, timestamp: new Date() }); const newState = this.reducer(currentState, event); return { entity: newState, commit: async () => { await event.save(); } }; } /** * 엔티티에 대한 모든 이벤트 조회 */ async getEvents(entityId: string): Promise<Event[]> { return this.eventModel.find({ entityId }).sort({ timestamp: 1 }).exec(); } }
TypeScript
복사
이벤트 레포지토리는 아래와 같은 팩토리 함수를 통해 제너릭 타입을 정의하고 객체를 생성할 수 있습니다. 해당 객체는 NestJS의 의존성 주입 시스템을 통해 동적으로 주입하며, 타입또한 런타임에 지정됩니다.
NestJS 모듈에서는 useFactory 옵션을 통해 팩토리 함수를 등록하고, inject 옵션으로 팩토리 함수에 필요한 의존성을 지정할 수 있습니다. 이를 통해 타입 안전성을 유지하면서도 유연한 컴포넌트 구성이 가능합니다.
function createConfigEventRepository( eventModel: Model<Event>, ): EventRepository<ConfigState> { const repository = new EventRepository<ConfigState>( eventModel, configReducer, initialConfigState, ); repository.registerHandler(eventHandler); return repository; } @Module({ imports: [ MongooseModule.forFeature([ { name: Event.name, schema: EventSchema }, ]) ], controllers: [ConfigsController], providers: [ ConfigsService, { provide: 'CONFIG_EVENT_REPOSITORY', useFactory: (eventModel) => { return createConfigEventRepository(eventModel); }, inject: [getModelToken(Event.name)], }, ], exports: ['CONFIG_EVENT_REPOSITORY', ConfigsService], }) export class ConfigsModule {}
TypeScript
복사

5.2. 외부 시스템 통합과 이벤트 처리

이벤트 소싱의 장점 중 하나는 이벤트 발생 시 외부 시스템과 쉽게 통합할 수 있다는 점입니다. 슬랙 웹훅 예시를 살펴보겠습니다.
export interface EventHandler<T> { handle(event: Event): Promise<void>; } @Injectable() export class ConfigEventHandler implements EventHandler<ConfigState> { constructor(private readonly webhooksService: WebhooksService) {} async handle(event: Event): Promise<void> { const eventType = event.type as ConfigEvent['type']; if (!['ConfigCreated', 'SetParameter', 'DeleteParameter'].includes(eventType)) { return; } const configEvent = event as unknown as ConfigEvent; switch (eventType) { case 'ConfigCreated': await this.handleConfigCreated(configEvent as ConfigCreatedEvent); break; case 'SetParameter': await this.handleSetParameter(configEvent as SetParameterEvent); break; case 'DeleteParameter': await this.handleDeleteParameter(configEvent as DeleteParameterEvent); break; } } private async handleConfigCreated(event: ConfigCreatedEvent): Promise<void> { const message = `새로운 설정이 생성되었습니다.\n*설정명:* ${event.body.name}\n${event.body.description ? `*설명:* ${event.body.description}` : ''}`; await this.webhooksService.notifySlack(message); } private async handleSetParameter(event: SetParameterEvent): Promise<void> { const message = `설정 파라미터가 변경되었습니다.\n*키:* ${event.body.key}\n*값:* ${event.body.value}`; await this.webhooksService.notifySlack(message); } private async handleDeleteParameter(event: DeleteParameterEvent): Promise<void> { const message = `설정 파라미터가 삭제되었습니다.\n*키:* ${event.body.key}`; await this.webhooksService.notifySlack(message); } }
TypeScript
복사
이벤트는 Repository에서 commit 된 후, 주입된 이벤트 핸들러를 통해 처리됩니다.
@Injectable() export class EventRepository<TState> { private snapshotFrequency: number; // EventHandler 프로퍼티 추가 private eventHandlers: EventHandler<TState>[] = []; // 이벤트 핸들러 등록 registerHandler(handler: EventHandler<TState>): void { this.eventHandlers.push(handler); } async dispatch(entityId: string, eventData: { type: string; body: any; userId?: string }): Promise<{ entity: TState; commit: () => Promise<void>; }> { // 중략 return { entity: newState, commit: async () => { await event.save(); // 이벤트 처리 요청 await Promise.all(this.eventHandlers.map(handler => handler.handle(event))); } }; }
TypeScript
복사
이벤트핸들러는 마찬가지로 NestJS 의존성 주입 시스템을 통해 동적으로 인스턴스를 생성하고 주입됩니다.
/** * 설정 이벤트 리포지토리 생성 팩토리 함수 */ function createConfigEventRepository( eventModel: Model<Event>, eventHandler: ConfigEventHandler ): EventRepository<ConfigState> { const repository = new EventRepository<ConfigState>( eventModel, configReducer, initialConfigState, ); repository.registerHandler(eventHandler); return repository; } @Module({ imports: [ MongooseModule.forFeature([ { name: Event.name, schema: EventSchema }, { name: Snapshot.name, schema: SnapshotSchema }, ]), WebhooksModule, ], controllers: [ConfigsController], providers: [ ConfigsService, ConfigEventHandler, { provide: 'CONFIG_EVENT_REPOSITORY', useFactory: (eventModel, eventHandler) => { return createConfigEventRepository(eventModel, eventHandler); }, inject: [getModelToken(Event.name), ConfigEventHandler], }, ], exports: ['CONFIG_EVENT_REPOSITORY', ConfigsService], }) export class ConfigsModule {}
TypeScript
복사

6. 성능 최적화 방안

이벤트 소싱 패턴의 주요 단점은 읽기 작업 시 모든 이벤트를 순회하여 상태를 계산해야 한다는 점입니다. 이는 이벤트가 많아질수록 성능 저하로 이어질 수 있습니다. 이를 해결하기 위한 방법으로는 스냅샷을 활용하거나, 캐시응활용할 수 있습니다.

6.1. 스냅샷(Snapshot) 활용

특정 시점의 엔티티 상태를 스냅샷으로 저장하여, 모든 이벤트를 다시 계산할 필요 없이 마지막 스냅샷 이후의 이벤트만 적용하는 방식입니다. 스냅샷은 이벤트 소싱 시스템의 핵심 최적화 기법으로, 특정 시점의 상태를 저장함으로써 이전 이벤트를 모두 재생하지 않고도 현재 상태를 빠르게 구성할 수 있게 해줍니다.
/** * 엔티티 ID로 상태 조회 */ async findById(entityId: string): Promise<TState | null> { // 최신 스냅샷 조회 const snapshot = await this.snapshotModel.findOne( { entityId }, {}, { sort: { version: -1 } } ); let state: TState; let startVersion = 0; if (snapshot) { state = snapshot.state as TState; startVersion = snapshot.version; } else { state = { ...this.initialState } as TState; } // 스냅샷 이후 이벤트 조회 (시간순 정렬) const events = await this.eventModel.find( { entityId } ).sort({ timestamp: 1 }).exec(); if (events.length === 0 && !snapshot) { return null; // 엔티티가 존재하지 않음 } // 이벤트 적용하여 최종 상태 계산 const finalState = events.reduce(this.reducer, state); return finalState; }
TypeScript
복사

6.2. 캐싱 전략

인메모리 캐시나 Redis와 같은 캐싱 솔루션을 활용하여 자주 조회되는 엔티티의 최신 상태를 저장하는 방법입니다. createConfigEventRepository 팩토리 함수에서 EventRepository 대신 CachedEventRepository 인스턴스를 생성함으로써 캐싱 전략을 적용할 수 있습니다.
@Injectable() export class CachedEventRepository<TState> extends EventRepository<TState> { private cache = new Map<string, TState>(); constructor( eventModel: Model<Event>, snapshotModel: Model<Snapshot>, reducer: (state: TState, event: any) => TState, initialState: TState, options: { snapshotFrequency?: number } = {} ) { super(eventModel, snapshotModel, reducer, initialState, options); } async findById(entityId: string): Promise<TState | null> { // Check cache first if (this.cache.has(entityId)) { return this.cache.get(entityId); } // If not in cache, get from database const state = await super.findById(entityId); // Store in cache if found if (state) { this.cache.set(entityId, state); } return state; } async dispatch(entityId: string, eventData: { type: string; body: any; userId?: string }): Promise<{ entity: TState; commit: () => Promise<void>; }> { const result = await super.dispatch(entityId, eventData); // Update cache after commit const originalCommit = result.commit; result.commit = async () => { await originalCommit(); this.cache.set(entityId, result.entity); }; return result; } // Optional: Method to clear cache for a specific entity clearCache(entityId: string): void { this.cache.delete(entityId); } // Optional: Method to clear entire cache clearAllCache(): void { this.cache.clear(); } }
TypeScript
복사

8. 결론

이벤트 소싱 패턴은 시스템의 모든 변경 사항을 이벤트로 기록함으로써 확장성, 유지보수성, 그리고 신뢰성 높은 애플리케이션을 구축할 수 있게 해줍니다. 특히 사내 플랫폼과 같이 신뢰성과 감사 기능이 중요한 시스템에서 그 가치가 더욱 빛납니다.
이벤트 소싱은 강력한 패턴이지만, 모든 시스템에 무조건 적용해야 하는 것은 아닙니다. 성공적인 도입을 위해서는 다음과 같은 실용적인 접근이 필요합니다.
점진적 도입
전체 시스템을 한 번에 변경하기보다는, 특정 바운디드 컨텍스트나 마이크로서비스부터 시작하여 점진적으로 확장하는 것이 좋습니다.
적합한 도메인 선택
이벤트의 시퀀스가 중요하고, 상태 변화의 히스토리가 비즈니스적 가치를 갖는 도메인을 우선 선택합니다. 예를 들어, 금융 거래, 워크플로우 관리, 설정 관리 등이 적합합니다.
팀 교육과 이해
이벤트 소싱은 패러다임의 전환을 요구하므로, 개발팀이 개념을 충분히 이해하고 실천할 수 있도록 교육이 필요합니다.
도구와 프레임워크 검토
이벤트 소싱을 지원하는 기존 도구나 프레임워크(EventStore, Axon, Lagom 등)를 검토하여 새로 구현하는 비용을 줄일 수 있는지 고려합니다.
성능 요구사항 고려
이벤트 소싱은 읽기 작업에서 추가적인 계산 비용이 발생할 수 있으므로, 성능 요구사항과 최적화 전략을 미리 고려해야 합니다.
이벤트 소싱 패턴은 학습 곡선이 있을 수 있지만, 일단 기본 개념을 이해하고 나면 복잡한 요구사항도 간결한 방식으로 구현할 수 있습니다. 이벤트 소싱은 단순히 데이터 저장 방식을 넘어, 시스템을 바라보는 새로운 관점을 제공합니다. 이는 소프트웨어의 상태 변화를 시간의 흐름 속에서 이해하고 관리하는 강력한 패러다임입니다.
이벤트 소싱을 통해 우리는 "무엇이 일어났는가"를 정확히 기록하고, 이를 바탕으로 "현재 상태가 어떠한가"를 파생시키는 방식으로 시스템을 구축합니다. 이러한 접근법은 비즈니스 도메인의 본질에 더 가깝게 소프트웨어를 설계할 수 있게 해주며, 변화하는 요구사항에도 유연하게 대응할 수 있는 기반을 제공합니다.