NestJS์—์„œ CQRS ํŒจํ„ด ์™„๋ฒฝ ๊ฐ€์ด๋“œ: ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์ถ•ํ•˜๊ธฐ

NestJS์—์„œ CQRS ํŒจํ„ด ์™„๋ฒฝ ๊ฐ€์ด๋“œ: ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์ถ•ํ•˜๊ธฐ

D
dongAuthor
5 min read

๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ๋†’์€ ์„ฑ๋Šฅ์„ ์š”๊ตฌํ•˜๋Š” ๋ฐฑ์—”๋“œ ์‹œ์Šคํ…œ์„ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด ๊ธฐ์กด์˜ CRUD ํŒจํ„ด์ด ํ•œ๊ณ„๋ฅผ ๋“œ๋Ÿฌ๋‚ด๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์ด๋•Œ CQRS(Command Query Responsibility Segregation) ํŒจํ„ด์ด ํ•ด๊ฒฐ์ฑ…์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ฐ€์ด๋“œ์—์„œ๋Š” NestJS์—์„œ CQRS ํŒจํ„ด์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹จ๊ณ„๋ณ„๋กœ ์‚ดํŽด๋ณด๊ณ , ์‹ค์ œ ํ”„๋กœ์ ํŠธ์— ์ ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์‹ค๋ฌด ๋…ธํ•˜์šฐ๋ฅผ ๊ณต์œ ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

CQRS ํŒจํ„ด์˜ ํ•ต์‹ฌ ๊ฐœ๋…๋ถ€ํ„ฐ NestJS์—์„œ์˜ ์‹ค์ œ ๊ตฌํ˜„, ํ…Œ์ŠคํŒ… ์ „๋žต๊นŒ์ง€ ์ข…ํ•ฉ์ ์œผ๋กœ ๋‹ค๋ฃจ์–ด ์—ฌ๋Ÿฌ๋ถ„์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ํ•œ ๋‹จ๊ณ„ ๋Œ์–ด์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋„๋ก ๋„์™€๋“œ๋ฆฝ๋‹ˆ๋‹ค.


CQRS ํŒจํ„ด์ด๋ž€? ๊ธฐ์กด CRUD์™€์˜ ์ฐจ์ด์ 

CQRS(Command Query Responsibility Segregation)๋Š” ๋ช…๋ น(Command)๊ณผ ์กฐํšŒ(Query)์˜ ์ฑ…์ž„์„ ๋ถ„๋ฆฌํ•˜๋Š” ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์ž…๋‹ˆ๋‹ค. ์ด ํŒจํ„ด์€ ์ฝ๊ธฐ์™€ ์“ฐ๊ธฐ ์ž‘์—…์„ ์™„์ „ํžˆ ๋ณ„๋„์˜ ๋ชจ๋ธ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ฐ๊ฐ์„ ๋…๋ฆฝ์ ์œผ๋กœ ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

์ „ํ†ต์ ์ธ CRUD ๋ฐฉ์‹์˜ ํ•œ๊ณ„

๊ธฐ์กด์˜ CRUD ์•„ํ‚คํ…์ฒ˜์—์„œ๋Š” ํ•˜๋‚˜์˜ ์„œ๋น„์Šค์—์„œ ๋ชจ๋“  ์ž‘์—…์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค:

@Injectable()
export class UserService {
  createUser(userData: CreateUserDto) { /* ... */ }
  getUser(id: string) { /* ... */ }
  updateUser(id: string, userData: UpdateUserDto) { /* ... */ }
  deleteUser(id: string) { /* ... */ }
}

์ด๋Ÿฐ ๊ตฌ์กฐ๋Š” ์ž‘์€ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ฌธ์ œ์—†์ง€๋งŒ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๋ณต์žกํ•ด์ง€๋ฉด์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ๋“ค์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค:

  • ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์˜ ์–ฝํž˜: ์ฝ๊ธฐ์™€ ์“ฐ๊ธฐ ๋กœ์ง์ด ํ•˜๋‚˜์˜ ์„œ๋น„์Šค์— ํ˜ผ์žฌ
  • ํ™•์žฅ์„ฑ ์ œ์•ฝ: ์ฝ๊ธฐ์™€ ์“ฐ๊ธฐ ์ž‘์—…์˜ ์„ฑ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์ด ๋‹ค๋ฅธ๋ฐ๋„ ํ•จ๊ป˜ ๋ฌถ์—ฌ ์žˆ์Œ
  • ํ…Œ์ŠคํŠธ ๋ณต์žก์„ฑ: ๋ชจ๋“  ๊ธฐ๋Šฅ์ด ํ•˜๋‚˜์˜ ํด๋ž˜์Šค์— ์žˆ์–ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ์–ด๋ ค์›€

CQRS๊ฐ€ ์ œ๊ณตํ•˜๋Š” ํ•ด๊ฒฐ์ฑ…

CQRS ํŒจํ„ด์€ ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค:

  • ์ฑ…์ž„ ๋ถ„๋ฆฌ: ์ƒํƒœ ๋ณ€๊ฒฝ ์ž‘์—…(Command)๊ณผ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ž‘์—…(Query)์„ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ
  • ๋…๋ฆฝ์  ํ™•์žฅ: ์ฝ๊ธฐ์™€ ์“ฐ๊ธฐ ์ž‘์—…์„ ๊ฐ๊ฐ ์ตœ์ ํ™”ํ•˜๊ณ  ๋…๋ฆฝ์ ์œผ๋กœ ํ™•์žฅ ๊ฐ€๋Šฅ
  • ๋ช…ํ™•ํ•œ ํ”Œ๋กœ์šฐ: "๋ฌด์—‡์„ ํ•ด์•ผ ํ•˜๋Š”์ง€"๊ฐ€ ๋ช…ํ™•ํ•˜๊ฒŒ ๋“œ๋Ÿฌ๋‚จ
  • ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ: ๊ฐ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋…๋ฆฝ์ ์ด์–ด์„œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ์‰ฌ์›Œ์ง

NestJS CQRS ๋ชจ๋“ˆ ์„ค์ •ํ•˜๊ธฐ

NestJS์—์„œ CQRS ํŒจํ„ด์„ ๊ตฌํ˜„ํ•˜๋ ค๋ฉด @nestjs/cqrs ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ด ํŒจํ‚ค์ง€๋Š” CommandBus, QueryBus, EventBus ๋“ฑ CQRS ๊ตฌํ˜„์— ํ•„์š”ํ•œ ํ•ต์‹ฌ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

ํ”„๋กœ์ ํŠธ ์„ค์ •

๋จผ์ € ํ•„์š”ํ•œ ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค:

npm install --save @nestjs/cqrs

๋‹ค์Œ์œผ๋กœ ๋ฃจํŠธ ๋ชจ๋“ˆ์— CqrsModule์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค:

// app.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { UserModule } from './user/user.module';

@Module({
  imports: [
    CqrsModule.forRoot(),
    UserModule,
  ],
})
export class AppModule {}
๐Ÿ’ก

CqrsModule์€ CommandBus, QueryBus, EventBus๋ฅผ ์ž๋™์œผ๋กœ ์ฃผ์ž…ํ•˜์—ฌ CQRS ํŒจํ„ด์˜ ๊ธฐ๋ฐ˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.


Command์™€ Query ๊ตฌํ˜„ํ•˜๊ธฐ

CQRS์˜ ํ•ต์‹ฌ์€ Command์™€ Query๋ฅผ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

  • Command โ†’ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” ์ž‘์—… (Create, Update, Delete)
  • Query โ†’ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๋Š” ์ž‘์—… (Read)

๊ฐ๊ฐ์˜ ์˜ˆ์‹œ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

// commands/create-user.command.ts
export class CreateUserCommand {
  constructor(
    public readonly name: string,
    public readonly email: string,
  ) {}
}

// queries/get-user.query.ts
export class GetUserQuery {
  constructor(public readonly id: string) {}
}

Command & Query Handler ๊ตฌํ˜„

ํ•ธ๋“ค๋Ÿฌ๋Š” ์‹ค์ œ ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•˜๋Š” ํ•ต์‹ฌ ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.

// commands/handlers/create-user.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from '../create-user.command';
import { UserService } from '../../user.service';

@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
  constructor(private readonly userService: UserService) {}

  async execute(command: CreateUserCommand): Promise<any> {
    const { name, email } = command;
    return this.userService.create({ name, email });
  }
}
// queries/handlers/get-user.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { GetUserQuery } from '../get-user.query';
import { UserService } from '../../user.service';

@QueryHandler(GetUserQuery)
export class GetUserHandler implements IQueryHandler<GetUserQuery> {
  constructor(private readonly userService: UserService) {}

  async execute(query: GetUserQuery): Promise<any> {
    return this.userService.findById(query.id);
  }
}

ํ•ธ๋“ค๋Ÿฌ๋Š” ๋‹จ์ผ ์ฑ…์ž„ ์›์น™(SRP)์— ๋”ฐ๋ผ ๊ฐ๊ฐ์˜ Command๋‚˜ Query๋งŒ ๋‹ด๋‹นํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.


Controller์—์„œ CQRS ์ ์šฉ

Controller๋Š” CommandBus์™€ QueryBus๋ฅผ ํ†ตํ•ด ๊ฐ ํ•ธ๋“ค๋Ÿฌ๋กœ ์š”์ฒญ์„ ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค.

@Controller('users')
export class UserController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus,
  ) {}

  @Post()
  async createUser(@Body() userData: { name: string; email: string }) {
    return this.commandBus.execute(
      new CreateUserCommand(userData.name, userData.email)
    );
  }

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return this.queryBus.execute(new GetUserQuery(id));
  }
}

Controller๋Š” CQRS ํ™˜๊ฒฝ์—์„œ โ€˜์ž…๋ ฅ ํฌํŠธโ€™ ์—ญํ• ๋งŒ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, ๋กœ์ง์€ ๋ชจ๋‘ ํ•ธ๋“ค๋Ÿฌ๋กœ ์œ„์ž„๋ฉ๋‹ˆ๋‹ค.


Event Handling๊ณผ Sagas

CQRS์˜ ์ง„์ •ํ•œ ํž˜์€ Event ์‹œ์Šคํ…œ๊ณผ ๊ฒฐํ•ฉ๋  ๋•Œ ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.

// events/user-created.event.ts
export class UserCreatedEvent {
  constructor(
    public readonly userId: string,
    public readonly email: string,
  ) {}
}

// events/handlers/user-created.handler.ts
@EventsHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
  handle(event: UserCreatedEvent) {
    console.log(`์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: ${event.userId}`);
  }
}
๐Ÿงฉ

Event๋Š” Command/Query์˜ ์‹คํ–‰ ๊ฒฐ๊ณผ๋กœ ๋ฐœ์ƒํ•˜๋ฉฐ, ์‹œ์Šคํ…œ์˜ ๋‹ค๋ฅธ ๋ถ€๋ถ„๋“ค์ด ์ด๋ฅผ ๊ฐ์ง€ํ•˜๊ณ  ๋ฐ˜์‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


CQRS ๊ตฌํ˜„ ํ…Œ์ŠคํŒ…

CQRS๋Š” ํ•ธ๋“ค๋Ÿฌ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๋งค์šฐ ์‰ฝ์Šต๋‹ˆ๋‹ค.

describe('CreateUserHandler', () => {
  it('userService.create๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํ˜ธ์ถœํ•ด์•ผ ํ•จ', async () => {
    const command = new CreateUserCommand('Alice', 'alice@email.com');
    const result = await handler.execute(command);
    expect(result).toBeDefined();
  });
});

๊ฐ ํ•ธ๋“ค๋Ÿฌ๋Š” ๋…๋ฆฝ์ ์ธ ์œ ๋‹›์ด๊ธฐ ๋•Œ๋ฌธ์—, ์™ธ๋ถ€ ์˜์กด์„ฑ์„ Mockingํ•˜์—ฌ ์†์‰ฝ๊ฒŒ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


CQRS Best Practices

  • Command์™€ Query๋ฅผ ๋ฐ˜๋“œ์‹œ ๋ถ„๋ฆฌํ•  ๊ฒƒ
  • EventBus๋ฅผ ์ ๊ทน์ ์œผ๋กœ ํ™œ์šฉํ•ด ๋น„๋™๊ธฐ ํ”Œ๋กœ์šฐ ๊ตฌ์„ฑ
  • ํ•ธ๋“ค๋Ÿฌ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์Šต๊ด€ํ™”ํ•  ๊ฒƒ
  • ํ•ธ๋“ค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ตœ์†Œํ™”: ์„œ๋น„์Šค ๊ณ„์ธต์— ์œ„์ž„
๐Ÿฉผ

CQRS๋Š” ์ž‘์€ ํ”„๋กœ์ ํŠธ์—๋Š” ๊ณผ๋„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, ์ด๋ฒคํŠธ ์ค‘์‹ฌ ์•„ํ‚คํ…์ฒ˜, ๋Œ€๊ทœ๋ชจ ํŠธ๋ž˜ํ”ฝ ํ™˜๊ฒฝ์ผ ๋•Œ ๋„์ž…์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

References

NestJS์—์„œ CQRS ํŒจํ„ด ์™„๋ฒฝ ๊ฐ€์ด๋“œ: ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์•„ํ‚คํ…์ฒ˜ ๊ตฌ์ถ•ํ•˜๊ธฐ