왜 CQRS 인가? - Nest.js

영슈·2023년 9월 8일
0

Nest

목록 보기
4/6
post-thumbnail

CQRS 란?

  • Command and Query Responsibility Segregation
  • 명령 - 쿼리 역활 구분

Command

  • System 에 side effect ( 변경을 가함 )
    => System 상태는 변경 시키나 , 값을 반환은 시키지 않아야 함!

Query

  • System 의 상태 관찰 할 수 있는 행위
  • System 상태를 단지 봔한 하기만 하고 , 상태 변경 X

CQS

  • Command Query Seperation
  • CQRS 이전 개념
  • 함수는 Command 와 Query 중 하나의 역활만 수행

CQRS

  • CQS 에서 더 큰 레벨 단위 분리
  • 거시적 관점 ( <-> CQS 는 Code Level )

기존 Controller 의 문제점

1. 복잡성

  • 도메인 = 비즈니스 ( 점점 복잡도가 올라가고 , 요구사항 올라갈 가능성 존재 )
    => query 위한 처리가 도메인에 침투하는 경우 생길시 복잡해짐

2. 성능

  • write 연산은 consistency 에 더 많은 신경을 써야함
    => write 가 locking 을 걸 시 , read 는 모두 대기할 수 있어 성능 하락!

3. 확장성

  • write 와 read 는 매우 불균형적 ( 1: 1000 정도 까지 가능! )
    => 서로 다른 설계 요구! 하나의 데이터 소스 사용시 독립적 확장 ↓

예시 코드

  • 물론 디자인 패턴 자체가 정답이 없으며 , 개인적으로 공부하여 접목한 구조라
    정확하지 않을 수 있다.
  • DDD 구조를 차용하며 , 같이 CQRS Pattern 을 사용했다.
    참조 : https://github.com/Sairyss/domain-driven-hexagon

폴더 구조

  • 폴더는 Domain 단위로 구성을 했다.
- user
	- commands ( DB 에 수정을 가하는 Logic )
    	- create-user
        - update-user
    - database ( DB 모델 및 처리 담당 Repository Logic )
    	- user.repository.ts
        - user.repository.port.ts ( DDD 위한 파일 , 불필요 )
    - domain ( Business Logic들 포함한 폴더 )
    	- user.entity.ts ( Business 를 포함한 파일
        - user.error.ts ( Logic 내 발생하는 Error 모은 파일 )
        - user.types.ts ( Logic 에 필요한 Type 모은 파일 )
    - queris ( DB 에 조회를 가하는 Logic )
    - user-di.token.ts
    - user.module.ts

command / create-user

  • 폴더 내 , 세가지 파일로 다시 구성 했다.
create-user-command.ts

import { Command, CommandProps } from '@src/libs/ddd/command-base';

export class CreateUserCommand extends Command {
  readonly email: string;
  readonly password: string;
  readonly nickname: string;
  constructor(props: CommandProps<CreateUserCommand>) {
    super(props);
    this.email = props.email;
    this.password = props.password;
    this.nickname = props.nickname;
  }
}
  • Command , CommandProps 는 위 참조 링크에 있는 코드를 활용
  • 수행할 Command 선언
create-post.controller.ts

import { CommandBus } from '@nestjs/cqrs';
import { Result, match } from 'oxide.ts';
async create(@Body() createUserProps: CreateUserProps): Promise<ResponseBase<{id:string}>> {
    const command = new CreateUserCommand(createUserProps);
    const result: Result<string, UserAlreadyExistsError> =
      await this.commandBus.execute(command);
    return match(result, {
      
      Ok: (id: string) => new ResponseBase({id}),
      Err: (error: UserAlreadyExistsError) => {
        throw error;
      },
    });
  }
  • commandBus 라는 nestjs 제공 Provider가 Command 를 처리해줌.
  • execut 결과는 any 로 나오나 , Type 지정
  • Result 와 match 는 oxide 라는 Library 에서 나옴 , Rust에서 있는 기능을 사용하는 Library
create-post.service.ts

import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Err, Ok, Result } from 'oxide.ts';
@CommandHandler(CreatePostCommand)
export class CreatePostCommandHandler implements ICommandHandler {
  constructor(
    @Inject(POST_REPOSITORY)
    private readonly postRepository: PostRepositoryPort,
  ) {}
  async execute(command: CreatePostCommand): Promise<Result<string, Error>> {
    const { content, title, userId, imageUrl } = command;
    const post = PostEntity.create({ title, content, userId, imageUrl });
    try {
      await this.postRepository.createPost(post);
      return Ok(post.id);
    } catch (error: any) {
      throw Err(new DBInsertError(error));
    }
  }
}
  • CommandHandler 를 선언해서 , Nest 에서 해당 Command 를 처리하게 지정
  • Command 를 수행하는 Logic 작성

Query / read-post

read-post.service.ts

import { Inject } from '@nestjs/common';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Err, Ok, Result } from 'oxide.ts';
import { JwtProvider } from '@src/providers/jwt.provider';
import { POST_REPOSITORY } from '../../post.di-token';
import PostRepositoryPort from '../../database/post.repository.port';
import { ReadPostQuery } from './read-post-query';
import { PostNotExistsError } from '../../post.errors';
import { PostProps } from '../../domain/post.types';

@QueryHandler(ReadPostQuery)
export class ReadPostQueryHandler implements IQueryHandler {
  constructor(
    @Inject(POST_REPOSITORY)
    private readonly postRepository: PostRepositoryPort,
    private readonly jwtProvider: JwtProvider,
  ) {}
  async execute(query: ReadPostQuery): Promise<Result<PostProps, Error>> {
    try {
      const record = await this.postRepository.readPost(query.postId);
      if (!record) return Err(new PostNotExistsError());
      return Ok(record);
    } catch (err) {
      throw err;
    }
  }
}
  • Command 와 유사함.

장점

  • 도메인 로직에만 집중 가능! ( Command-Query 분리하므로 , OCP 원칙 준수 가능! )
  • 데이터 소스 독립적 크기 조정 가능 ( Read Database 에 더 많은 투자 가능 )
  • 단순 쿼리 사용 가능 ( Query 측에서 Materialized View 이용해 , 복잡한 Join Query 없이 , 단순 쿼리 가능! )
    => 많은 사용자가 동일 데이터 병렬 접근 & read 연산 더 많을수록 효과적

사담

  • 그렇게 까지 , 효율적인거는 모르겠으나 , 이렇게 로직 하나하나 전부 분리하는게 가독성이나 , import 하는 코드 부분이나 더 깔끔하다고 느꼈다.
  • 한 Controller 안에 , 여러 Service 가 들어가는 것을 최대한 지양 할 수 있는거 같다. ( AuthControler -> UserService , AuthService, EmailProvdier... )

결론

사실 , DataSource 와 크게 연관이 X!
핵심은 , 비즈니스 로직에 존재하는 도메인 모델에 Query 침투 방지가 핵심!

공부한 메모

출처

0개의 댓글