icon

CQRS 패턴 구현


 CQRS(Command Query Responsibility Segregation) 패턴은 애플리케이션의 읽기 작업(Query)과 쓰기 작업(Command)을 분리하는 아키텍처 패턴입니다.

 전통적인 CRUD 기반 아키텍처와 달리, CQRS는 데이터의 읽기와 쓰기에 대해 서로 다른 모델과 인터페이스를 사용합니다.

CQRS의 장단점

 장점

  1. 성능 최적화: 읽기와 쓰기 작업을 독립적으로 확장 가능
  2. 복잡성 관리: 비즈니스 로직과 쿼리 로직의 분리
  3. 유연성: 각 모델을 독립적으로 진화 가능

 단점

  1. 구현 복잡성 증가
  2. 데이터 일관성 관리의 어려움
  3. 학습 곡선

NestJS에서 CQRS 구현

  1. @nestjs/cqrs 모듈 설치
npm install @nestjs/cqrs
  1. 모듈 설정
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
 
@Module({
  imports: [CqrsModule],
  // ...
})
export class AppModule {}
  1. 명령(Command) 구현
export class CreateUserCommand {
  constructor(public readonly username: string, public readonly email: string) {}
}
  1. 명령 핸들러(CommandHandler) 구현
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from './create-user.command';
 
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  async execute(command: CreateUserCommand) {
    // 사용자 생성 로직
  }
}
  1. 쿼리(Query) 구현
export class GetUserQuery {
  constructor(public readonly userId: string) {}
}
  1. 쿼리 핸들러(QueryHandler) 구현
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { GetUserQuery } from './get-user.query';
 
@QueryHandler(GetUserQuery)
export class GetUserHandler implements IQueryHandler<GetUserQuery> {
  async execute(query: GetUserQuery) {
    // 사용자 조회 로직
  }
}
  1. 이벤트(Event) 및 이벤트 핸들러(EventHandler) 구현
export class UserCreatedEvent {
  constructor(public readonly userId: string) {}
}
 
@EventHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
  handle(event: UserCreatedEvent) {
    // 이벤트 처리 로직
  }
}

명령과 쿼리의 분리 전략

  1. 명령 모델 : 상태 변경을 위한 로직
  2. 쿼리 모델 : 데이터 조회를 위한 최적화된 구조

 예시

// 명령 모델
class User {
  constructor(private id: string, private username: string, private email: string) {}
 
  changeEmail(newEmail: string) {
    // 이메일 변경 로직 및 유효성 검사
    this.email = newEmail;
  }
}
 
// 쿼리 모델
interface UserView {
  id: string;
  username: string;
  email: string;
}
 
@QueryHandler(GetUserQuery)
class GetUserHandler implements IQueryHandler<GetUserQuery> {
  async execute(query: GetUserQuery): Promise<UserView> {
    // 최적화된 쿼리 실행
  }
}

이벤트 소싱과 CQRS

 이벤트 소싱은 애플리케이션의 상태 변화를 일련의 이벤트로 저장하는 방식입니다.

 CQRS와 함께 사용하면 다음과 같은 이점이 있습니다.

  1. 완벽한 감사 추적
  2. 시스템 상태의 시간 기반 재구성 가능
  3. 복잡한 도메인 모델 지원

 NestJS에서 이벤트 소싱 구현

import { EventStoreDBClient } from '@eventstore/db-client';
 
@Injectable()
export class EventStore {
  private client: EventStoreDBClient;
 
  constructor() {
    this.client = EventStoreDBClient.connectionString('esdb://localhost:2113?tls=false');
  }
 
  async saveEvent(streamName: string, event: any) {
    await this.client.appendToStream(streamName, [
      {
        type: event.constructor.name,
        data: JSON.parse(JSON.stringify(event)),
      },
    ]);
  }
 
  async getEvents(streamName: string) {
    const events = this.client.readStream(streamName);
    return events;
  }
}

데이터 일관성 관리

  1. 최종 일관성(Eventual Consistency) 모델 채택
  2. 이벤트 핸들러를 통한 읽기 모델 업데이트
@EventHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
  constructor(private readonly readModelRepository: ReadModelRepository) {}
 
  async handle(event: UserCreatedEvent) {
    await this.readModelRepository.createUserView(event.userId, event.username, event.email);
  }
}

읽기 모델과 쓰기 모델 동기화

  1. 비동기 이벤트 처리를 통한 동기화
  2. 배치 프로세스를 통한 주기적 동기화
@Injectable()
export class SynchronizationService {
  @Cron('0 * * * *') // 매 시간 실행
  async synchronizeModels() {
    const events = await this.eventStore.getAllEvents();
    for (const event of events) {
      await this.processEvent(event);
    }
  }
 
  private async processEvent(event: any) {
    // 이벤트 타입에 따른 처리 로직
  }
}

CQRS 패턴 테스트 전략

  1. 단위 테스트 : 각 Command, Query, Event Handler 개별 테스트
  2. 통합 테스트 : 전체 CQRS 흐름 테스트
describe('CreateUserHandler', () => {
  it('should create a user and emit UserCreatedEvent', async () => {
    const command = new CreateUserCommand('john', '[email protected]');
    const handler = new CreateUserHandler(mockRepository, mockEventBus);
 
    await handler.execute(command);
 
    expect(mockRepository.save).toHaveBeenCalled();
    expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(UserCreatedEvent));
  });
});

확장성 및 성능 최적화

  1. 읽기와 쓰기 모델의 독립적 확장
  2. 캐싱 전략 구현
  3. 이벤트 스트리밍 플랫폼(e.g., Kafka) 활용
@Injectable()
export class UserQueryService {
  constructor(
    private readonly cacheManager: Cache,
    private readonly userRepository: UserRepository,
  ) {}
 
  async getUser(userId: string): Promise<UserView> {
    const cachedUser = await this.cacheManager.get(`user:${userId}`);
    if (cachedUser) {
      return cachedUser;
    }
 
    const user = await this.userRepository.findById(userId);
    await this.cacheManager.set(`user:${userId}`, user, { ttl: 3600 });
    return user;
  }
}

Best Practices 및 주의사항

  1. 도메인 주도 설계(DDD) 원칙 적용
  2. 명확한 경계를 가진 컨텍스트 정의
  3. 이벤트 버전 관리 전략 수립
  4. 성능 모니터링 및 최적화
  5. 장애 대응 전략 수립 (e.g., 이벤트 재처리)
  6. 보안 고려 (e.g., 명령과 쿼리에 대한 적절한 인증/인가)
  7. 문서화 : CQRS 흐름 및 이벤트 스키마 문서화
  8. 점진적 도입 : 복잡한 도메인부터 CQRS 적용 시작

 CQRS는 읽기와 쓰기 작업을 분리함으로써 각 작업에 대해 최적화된 모델을 사용할 수 있게 해주며 이는 성능상의 이점을 제공합니다.

 @nestjs/cqrs 모듈은 CQRS 패턴 구현을 위한 강력한 도구를 제공합니다. 명령, 쿼리, 이벤트 및 각각의 핸들러를 명확하게 정의하고 관리할 수 있어, 복잡한 비즈니스 로직을 더 쉽게 구조화하고 유지보수할 수 있습니다.

 이벤트 소싱을 CQRS와 함께 사용하면 시스템의 모든 상태 변화를 추적하고 재구성할 수 있는 강력한 기능을 제공합니다.