NestJS Layered Architecture에 CQRS 패턴 적용하기!

영자이다·2024년 10월 11일
post-thumbnail

작년에 회사에서 CQRS 패턴을 적용했는데 그 결과가 만족스러워, 어떤 과정을 통해 CQRS 패턴을 적용하게 되었는지 그리고 그로인해 어떤 변화가 있었는지 정리해보고자 한다.

도입하게 된 배경

회사에서 런칭한 서비스의 프로젝트는 NestJS를 사용해 만들어졌다. NestJS는 기본적으로 layered architecture를 따라 구성되어 있다.
NestJS에서 제공하는 디렉토리 구조는 다음과 같은데 (link), 우리는 typeorm을 사용하여 entities라는 폴더에 각 테이블별 스키마와 entity에 대한 정보를 저장했다.

- entities
	- cats.entity.ts
- src
	- cats
		- dto
			- create-cat.dto.ts
		- cats.controller.ts
		- cats.module.ts
		- cats.service.ts
	- app.module.ts
	- main.ts

해당 layered architecture는 네가지 계층으로 구분된다.
before-cqrs

Presentation Layer

  • Controller에 해당. 사용자의 요청을 처리하고, 서비스 계층과 상호작용하여 데이터를 반환 한다

Business Layer

  • Service에 해당. 비즈니스 로직을 처리하는 계층으로, 실제 데이터를 가져오거나 조작하는 작업을 담당한다.

Persistence Layer

  • Entity에 해당. Entity를 통해 DataBase에 접근하여 상호작용한다

Database Layer

  • Database가 위치한 계층

기존 구조의 문제점

해당 구조를 유지하며 개발을 하는 것은 어렵지 않았지만, 서비스가 점점 커지며 비즈니스 로직이 복잡해지기 시작하니 여러 문제점이 생기기 시작했다.

  1. 읽기와 쓰기 로직이 동일한 서비스 안에 존재하기 때문에 유지보수가 어려워졌고, 성능에 부하가 생기는 경우가 왕왕 생겼다.

  2. 하나의 서비스 파일에 굉장히 다양한 로직이 구현되어있다보니, 하나의 파일에 2-3천 줄의 코드가 작성되어 원하는 로직을 찾아 작업하는 것이 꽤나 불편했다.

  3. 새로운 기능이 추가되며 복잡한 도메인 로직이 요구될 때 하나의 계층에서 너무 많은 책임을 가지고 있는 것도 문제였다. 하나의 계층에서 너무 많은 책임을 가지게 되니 확장성이 많이 떨어졌고, 어느 시점부터는 기능 개발을 진행하는 것 자체가 레거시를 낳는 듯한 느낌이었다

이러한 문제점을 느끼던 와중, 나보다 좀 더 경력이 많으신 개발자 분께 우리 팀의 코드에 대한 피드백을 받을 기회가 생겼는데. 그 때 그 분께서 CQRS 패턴을 도입해보는 것이 어떻겠냐 말해주셨다. 직접 코드를 보여주시며 CQRS 패턴에 대한 깊은 설명을 해주셨는데. 그 때 CQRS 패턴을 도입하는 것이 당시 회사 프로젝트의 여러 문제점을 해결하는데 도움이 될 것이라고 판단했다. 그 도움을 바탕으로 CQRS 패턴을 프로젝트에 적용하기로 결정했다.


CQRS?

CQRS는 Command and Query Responsibility Segregation 의 줄임말이다. 명령과 쿼리의 역할구분으로 해석할 수 있는데. 즉, 명령과 쿼리의 책임을 분리하는 것을 의미한다.

여기서 명령(Command)는 CRUD에서 데이터를 변경하는 CUD(Create, Update, Delete)을 의미하고 쿼리(Query)는 데이터를 조회하는 R(Read)을 의미한다.

다시 말해 CQRS의 뜻처럼 명령과 쿼리의 책임을 분리 한다는 것은, 데이터를 변경하는 Command와 데이터를 조회하는 Query를 구분하여 따로 만든다는 말이다.

CQRS를 도입했을 때의 장점

CQRS를 도입을 고려하면서, 크게 세가지의 장점을 기대했다.

복잡성 감소

명령(Command)과 조회(Query) 작업을 분리하여 비즈니스 로직을 명확하게 만듦으로써 각 기능과 시스템의 복잡성을 줄여준다. 이는 코드의 가독성을 높이고 유지 보수를 용이하게 만드는 데에도 큰 도움이 된다.

성능 최적화

읽기 작업과 쓰기 작업을 분리하게 되면 각각의 요구 사항에 따라 독립적으로 성능을 최적화할 수 있다. 예를 들어, 읽기 작업이 많은 애플리케이션에서 부하가 생기는 경우 읽기 전용 데이터베이스를 추가하여 성능을 향상시킬 수도 있고. 쓰기 작업은 데이터베이스의 트랜잭션 무결성을 유지하면서 처리하는 등. 각 작업에 맞는 최적화 전략을 따로 적용할 수 있다

유연한 데이터 모델

쓰기 모델과 읽기 모델을 분리함으로써, 데이터 모델을 독립적으로 관리할 수 있게 된다. 일반적으로, Command와 Query 작업에서 필요한 데이터 형식은 다르기 마련이다. 예를 들어 읽기 작업의 경우 집계 함수를 사용한다든지, 정렬을 하는 등의 조회 로직에 맞게 새로운 attribute가 필요한 경우가 있는데. 읽기 모델과 쓰기 모델을 분리하게 되면 Entity의 변경 없이, 각각의 요구 사항에 맞도록 데이터의 표현 방식을 변경하기가 더 쉬워진다.


그래서 어떻게 도입했는가?

CQRS Layered Architecture를 도입한 우리 팀의 구조는 다음처럼 바뀌었다. (지금부터의 설명은 회원가입과 탈퇴, 회원 조회를 할 수 있는 user라는 도메인이 있다고 가정하여 작성하였다.)

// 디렉토리의 구조는 설명을 위해 간략화해서 작성함. 
// 더 자세한 구조는 레포지토리 참조

- src
	- common
		- database
			- entities
				- user.entity.ts
	- modules
		- user
			// command 작업에서 사용되는 custom repository 
			- database
				- user.repository.impl.ts
				- user.repository.ts
			// user module에서 사용하는 command 작업들
			- commands
				- create-user
					- dtos
						- create-user.request.dto.ts
					- create-user.command.ts
					- create-user.controller.ts
					- create-user.service.ts
			// user module에서 사용하는 query 작업들
			- queries
				- get-user-detail
					- dtos
						- get-user-detail.request.dto.ts
						- get-user-detail.response.dto.ts
					- get-user-detail.controller.ts
					- get-user.detail.query-handler.ts
			// custom repository를 di 할때 사용되는 토큰
			- user.di-tokens.ts
			- user.module.ts
	- app.module.ts
	- main.ts

기존에는 user 모듈 하위에 controller 와 service만 존재하고 그 두 개의 파일에서 모든 로직을 처리해주었지만. 바뀐 구조에서는 user 모듈 하위에 commands, queries 디렉토리를 분리하여 각각의 요청 및 로직을 구분하도록 수정하였다.

after-cqrs

결과적으로 바뀐 구조의 계층도는 위처럼 수정되었다!

NestJS의 CQRS 모듈 활용하기

자세한 코드는 예시 코드에서 확인이 가능합니다!

CQRS 패턴을 도입하면서 @nestjs/cqrs를 import하여 사용하였다.

// user.module.ts
import { CqrsModule } from '@nestjs/cqrs';
...

@Module({
  imports: [CqrsModule, TypeOrmModule.forFeature(entityList)],
 ...
})
export class UserModule {}

@CommandHandler()

Command 작업을 처리하기 위해서는 @nestjs/cqrs@CommandHandler()를 사용한다

// user/commands/create-user/create-user.command.ts

export class CreateUserCommand {
  readonly firstName: string;
  readonly lastName: string;
  readonly phone: string;
  readonly email: string;

  constructor(props: CommandProps<CreateUserCommand>) {
    super(props);
    this.firstName = props.firstName;
    this.lastName = props.lastName;
    this.phone = props.phone;
    this.email = props.email;
  }
}

위와 같이 유저 생성을 위한 Command를 하나 만들고, 유저 생성 로직을 실행하기 위해 Command를 발송하면 CommandHandler가 이를 받아 처리하도록 만든다

// user/commands/create-user/create-user.controller.ts

@Controller(routesV1.version)
@ApiTags(ApiTagsSet.user)
export class CreateUserController {
  // @nestjs/cqrs의 CommandBus를 주입한다
  constructor(private readonly commandBus: CommandBus) {}

  @ApiOperation({ summary: 'Create a user' })
  @Post(routesV1.user.create)
  async create(@Body() body: CreateUserRequestDto) {
 // 앞서 생성했던 CreateUserCommand
    const command = new CreateUserCommand(body);
 // CreateUserCommand를 주입한 commandBus로 전송한다
    const result: Result<IdResponse, CreateUserFailException> =
      await this.commandBus.execute(command);

    ...
    
  }
}

이전의 구조였다면 UserService를 주입하여, 유저 생성 로직을 처리하는 함수를 직접 호출하였겠지만. CQRS 모듈을 적용하고나서는, 요청이 왔을때 Command를 전달하여 로직을 처리한다. 이전에 비해 보다 결합도가 낮아진 형태가 되었다!

// user/commands/create-user/create-user.service.ts

...
// @nestjs/cqrs 패키지의 @CommandHandler()를 사용한다
@CommandHandler(CreateUserCommand)
export class CreateUserService implements ICommandHandler {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepo: UserRepository,
    @Inject(Logger)
    private readonly logger = new Logger(CreateUserService.name),
  ) {}

  ...
  
}

commandHandler의 역할을 하는 service 파일은 CreateUserCommand가 발송되었을 때 유저를 생성하는 로직을 처리한다.
이전 구조에서 controller는 userService에 의존하는 형태였지만, 이제는 controller에서 해당 서비스 계층에 의존하지 않고 원하는 기능을 처리하는 Command를 발송하기만 하면된다. 동작은 CommandHandler에서 받아 처리한다

마지막으로 user module에 해당 작업을 처리하는 controller와 commandHandler를 넣어준다

// user.module.ts
import { CqrsModule } from '@nestjs/cqrs';
...

const controllers = [
  CreateUserController,
];
const commandHandlers: Provider[] = [CreateUserService];
const repositories: Provider[] = [
  { provide: USER_REPOSITORY, useClass: UserRepositoryImpl },
];

@Module({
...
  controllers: [...controllers],
  providers: [Logger, ...repositories, ...commandHandlers],
})
export class UserModule {}

@QueryHandler()

Query 작업 또한 Command 작업과 거의 유사하다.
이번에는 @nestjs/cqrs@QueryHandler()를 사용한다

// user/queries/get-user-detail/get-user-detail.controller.ts
...

@Controller(routesV1.version)
@ApiTags(ApiTagsSet.user)
export class GetUserDetailController {
  // @nestjs/cqrs의 QueryBus를 주입한다

  constructor(private readonly queryBus: QueryBus) {}

  ...
  async getUserDetail(
    @Query() queryParams: GetUserDetailRequestDto,
  ): Promise<ResponseBase<GetUserDetailResponseDto>> {
    const query = new GetUserDetailQuery({
      ...queryParams,
    });
// 유저 정보를 조회하기 위한 Query를 queryBus로 전송한다
    const result: Result<GetUserDetailResponseDto, Error> = await this.queryBus.execute(query);
...
  }
}
// user/queries/get-user-detail/get-user-detail.query-handler.ts

export class GetUserDetailQuery {
  readonly id: number;
  constructor(props: GetUserDetailQuery) {
    this.id = props.id;
  }
}

...
// @nestjs/cqrs 패키지의 @QueryHandler()를 사용한다
@QueryHandler(GetUserDetailQuery)
export class GetUserDetailQueryHandler implements IQueryHandler {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
  ) {}
...
}

코드를 보면 알겠지만 command 와 동작하는 방식은 유사하고, 사용하는 데코레이터 정도의 차이만 있다.

하지만 여기서 주목해야할 차이점이 하나 있는데,
CommandHandler와 QueryHandler에서 주입하는 repository가 다르다는 것이다!

Persistence Layer 분리하기

CQRS 패턴을 적용하여 구조를 바꿀 때 가장 큰 작업은 Persistence Layer를 두 개로 분리하는 것이다. 사실상 Command와 Query 명령을 구분하는 것의 핵심은 repository layer의 구분이기 때문이다.

CQRS 패턴을 적용하기 전에는 모든 로직을 처리하는 Service에 @InjectRepository() 데코레이터를 사용하여 TypeORM 모듈을 통해 자동으로 제공되는 repository를 주입받아 데이터베이스에 접근하여 모든 로직을 처리했었다.

하지만 Persistence Layer를 Command와 Query 별로 분리 하면서 command 작업에서 사용하는 repository와 query 작업에서 사용하는 repository를 구분했다.

// user/database/user.repository.impl.ts
// command 작업에서 사용하는 custom repository

...

@Injectable()
export class UserRepositoryImpl
  extends TypeOrmRepositoryBase<UserEntity>
  implements UserRepository
{
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
  ) {
    super(userRepo, new Logger(UserRepositoryImpl.name));
  }

  async findOneByEmail(
    email: string,
  ): Promise<Result<UserEntity, UserIsNotExistException>> {
    const user = await this.userRepo.findOne({
      where: {
        email,
      },
    });

    if (user) {
      return Ok(user);
    } else {
      return Err(new UserIsNotExistException());
    }
  }
}

위와 같은 custom repository를 만들어서, command 작업을 처리하는 service 파일에 주입해주었다

// user/commands/create-user/create-user.service.ts
...
@CommandHandler(CreateUserCommand)
export class CreateUserService implements ICommandHandler {
  // 
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepo: UserRepository,
    @Inject(Logger)
    private readonly logger = new Logger(CreateUserService.name),
  ) {}

  ...
  
}

하지만 Query 작업의 경우 별도의 custom repository를 만들지 않고 아래처럼 UserEntity를 바로 주입해주어 TypeORM 모듈에서 제공되는 repository 그대로 사용했다.

// user/queries/get-user-detail/get-user-detail.query-handler.ts

@QueryHandler(GetUserDetailQuery)
export class GetUserDetailQueryHandler implements IQueryHandler {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
  ) {}
...
}

Query용 repository를 따로 생성하지 않은 이유는, persistence 로직을 분리할 정도로 business 로직이 복잡하지 않았기 때문이다. 불필요한 계층 이동을 방지를 위해 business 로직과 persistence 로직을 query handler에서 한번에 처리를 해주었다. 추후 로직이 복잡해진다면 command 작업을 구성한 것 처럼 계층을 분리하면 된다.

작업중 생긴 궁금증

작업을 하다보니, Persistence Layer를 두개로 분리하기만 해도 CQRS를 어느 정도 구현하는 것이 아닌가? 하는 생각이 들었다. 굳이 @nestjs/cqrs 모듈의 @CommandHandler(), @QueryHandler() 데코레이터를 사용하지 않고 Command와 Query에서 사용하는 Repository를 구분하는 것 만으로도 CQRS 패턴을 적용했다고 할 수 있는것 아닌가?

내 생각에 이것도 틀린 말은 아닌 것 같다. 해당 모듈을 사용하지 않아도 Repository layer 분리를 통해 CQRS를 어느정도 구현했다고도 분명 말할 수 있다. 하지만 @nestjs/cqrs를 사용하면 CQRS를 더 체계적으로 적용할 수 있다.@CommandHandler(), @QueryHandler() 데코레이터는 각 작업을 이벤트 기반으로 분리하여 처리하도록 도와주고, 이를 통해 더 명확하게 명령과 조회 로직을 독립적인 단위로 처리하게 되어 CQRS 패턴이 더 체계적이고 완전하게 적용되는 것이다!


마무리

CQRS를 도입하기 위해 찾아본 이 포스트에서는 CQRS 패턴을 적용하기 위한 방법을 크게 세가지로 구분한다. 나는 글에서 설명하는 가장 간단한 방식을 채택하여 DB를 분리하지 않고 코드레벨에만 패턴을 적용하였다.

그럼에도 CQRS 패턴을 적용하여 얻는 이점은 뚜렷했다. 맨 처음 설명했던 기본 Layered Architecture의 문제점을 대부분 해결했을 뿐만 아니라, Command와 Query를 분리하여 각각의 책임을 분리하고나니 테스트의 범위도 작아져 테스트 코드를 작성하기도 용이했다.

만일 서비스가 더욱 커진다고 하면 요구사항에 맞는 각기 다른 DB들을 동시에 활용하는 Polyglot 구조도 쉽게 연결이 가능하다. 이 또한 CQRS 패턴을 적용하면서 각각의 책임을 분리하고 결합도를 낮추었기 때문에 가능한 것이다!

물론 CQRS 패턴을 적용하면서 변경된 구조에 단점도 존재한다. 우선, 프로젝트 내에 파일의 개수가 많아지고 디렉토리의 depth가 깊어져서 디렉토리 구조가 비교적 복잡해졌다. 하지만 나름대로 정한 디렉토리 구조의 컨벤션을 아직까지는 직관적으로 잘 따르고 있기 때문에, 해당 구조를 따라 작업을 하거나 원하는 파일을 찾는 것이 어렵지는 않다. 만일 서비스가 훨씬 커지고 비즈니스 로직이 복잡해진다면MSA를 도입하여 서비스를 분리한다면 어느 정도 해소가 되지 않을까 싶다.


예시 코드 - github

0개의 댓글