Skip to content
Go back

NestJS DDD‧CQRS로 모듈 간 의존성 최소화하기 - 급여 시스템 아키텍처 가이드

Published:  at  10:00 PM

급여 시스템 같은 복잡한 시스템을은 스케줄, 출퇴근, 휴가, 급여 지급… 모든 모듈이 서로 얽혀있어서 하나만 수정해도 전체 시스템에 영향을 미치게 됩니다.

이런 상황에서 DDD와 CQRS 패턴을 적용해보았습니다. 실제로 적용해본 결과 생각보다 효과가 좋았습니다.

복잡한 시스템에서 흔히 겪는 문제들

DDD‧CQRS 적용 방법

  1. 도메인별로 경계를 명확히 나누고 (스케줄, 출퇴근, 휴가, 급여)
  2. 읽기와 쓰기를 완전히 분리해서
  3. 의존성을 한 방향으로만 플로우하게 하고
  4. 각 모듈을 독립적으로 테스트 가능하게 만든다면

실제로 적용해본 결과 꽤 만족스러웠습니다.


적용 가능한 핵심 원칙들

1. 읽기와 쓰기 분리 전략

명령과 조회를 완전히 다른 세계로 나눠보면:

// 명령은 상태를 바꾸는 데 집중
@CommandHandler(RecordAttendanceCommand)
export class RecordAttendanceHandler {
  async execute(command: RecordAttendanceCommand): Promise<void> {
    // 비즈니스 로직에만 집중
  }
}

// 조회는 성능 최적화에 집중
@QueryHandler(GetPayslipQuery)
export class GetPayslipHandler {
  async execute(query: GetPayslipQuery): Promise<Payslip> {
    // 빠른 조회에만 집중
  }
}

이렇게 나누면 각각 최적화하기 수월합니다.

2. 도메인별 책임 분리

각 도메인이 자기 일만 한다면:

다른 도메인 정보가 필요하면 Service를 통해 요청하는 방식을 사용합니다.


실제 모듈 구조 예시

핵심 도메인과 조합 도메인 분리

핵심 도메인 (서로 몰라야 함)
├── schedule/    # 근무 스케줄만 관리
├── attendance/  # 출퇴근만 관리
├── leave/       # 휴가만 관리
└── payroll/     # 급여만 관리

조합 도메인 (핵심 도메인들을 조합)
├── schedule-attendance/ # 스케줄 + 출퇴근
├── attendance-payroll/  # 출퇴근 + 급여
└── leave-payroll/       # 휴가 + 급여

핵심은 핵심 도메인끼리는 절대 서로를 import하지 않는다는 것이다. 조합이 필요하면 별도 모듈에서 처리한다.

조합 모듈의 핵심 원칙

// ✅ 조합 모듈은 필요한 핵심 도메인만 import
@Module({
  imports: [AttendanceModule, PayrollModule],
  providers: [AttendancePayrollHandler],
})
export class AttendancePayrollModule {}

// ❌ 이런 건 절대 안 됨 - 불필요한 의존성
@Module({
  imports: [AttendanceModule, PayrollModule, ScheduleModule, LeaveModule]
})

핵심: 조합 모듈에서도 최소한의 의존성만 가져간다. 필요하면 Service를 통해 간접 접근.


CQRS 패턴의 장점과 적용 방법

복잡한 비즈니스 로직 정리 방법

// 급여 계산 같은 복잡한 로직을 Handler로 분리해보면
@CommandHandler(CalculatePayrollCommand)
export class CalculatePayrollHandler {
  constructor(
    private readonly attendanceService: AttendanceService,
    private readonly leaveService: LeaveService,
    private readonly scheduleService: ScheduleService
  ) {}

  async execute(command: CalculatePayrollCommand) {
    // 여러 도메인을 조율하는 역할만 담당
    const attendance = await this.attendanceService.getMonthlyData(
      command.employeeId
    );
    const leaves = await this.leaveService.getApprovedLeaves(
      command.employeeId
    );
    const schedule = await this.scheduleService.getWorkingDays(command.month);

    // 실제 급여 계산은 급여 도메인에서
    return this.payrollService.calculate({ attendance, leaves, schedule });
  }
}

이렇게 하면 Command Handler가 오케스트레이터 역할을 하며, 각 도메인의 세부 사항은 알 필요가 없습니다.

Domain Event로 사이드 이펙트 분리

// 급여 계산 후 여러 가지 일이 일어나야 한다
@EventsHandler(PayrollCalculatedEvent)
export class SendPayslipNotification {
  async handle(event: PayrollCalculatedEvent) {
    // 급여명세서 발송
    await this.notificationService.sendPayslip(event.employeeId, event.payslip);
  }
}

@EventsHandler(PayrollCalculatedEvent)
export class UpdatePayrollHistory {
  async handle(event: PayrollCalculatedEvent) {
    // 급여 이력 저장
    await this.historyService.savePayrollRecord(event.payrollData);
  }
}

핵심: 급여 계산 후 일어나야 하는 모든 일들을 Event Handler로 분리합니다. 급여 도메인은 자신의 일만 하고, 나머지는 Event로 위임합니다.


진짜 중요한 건 Aggregate 경계

Aggregate는 트랜잭션 경계다

// Order Aggregate - 주문과 주문항목은 함께 변경되어야 함
class Order {
  private constructor(
    private readonly id: OrderId,
    private readonly customerId: CustomerId,
    private items: OrderItem[]
  ) {}

  // 비즈니스 규칙: 주문 항목 추가는 재고를 확인해야 함
  addItem(productId: string, quantity: number) {
    // 도메인 로직
    if (this.status === "COMPLETED") {
      throw new Error("완료된 주문은 수정할 수 없습니다");
    }
    this.items.push(new OrderItem(productId, quantity));
  }
}

핵심: Aggregate 안의 데이터는 반드시 함께 변경되어야 한다. Order와 OrderItem을 따로 수정하면 안 된다.


진짜 복잡한 시나리오 - 여러 도메인이 얽힌 경우

주문 생성은 정말 복잡하다

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler {
  async execute(command: CreateOrderCommand) {
    // 1. 고객 검증 (Customer 도메인)
    const customer = await this.customerService.validateForOrder(
      command.customerId
    );

    // 2. 상품 및 재고 검증 (Product + Inventory 도메인)
    await this.productService.validateProducts(command.items);
    await this.inventoryService.checkAvailability(command.items);

    // 3. 할인 적용 (Promotion 도메인)
    const discount = await this.promotionService.calculateDiscount(
      customer,
      command.items
    );

    // 4. 실제 주문 생성 (Order 도메인)
    const order = await this.orderService.create(command, discount);

    // 5. 사이드 이펙트는 Event로
    await this.eventBus.publish(new OrderCreatedEvent(order));

    return order;
  }
}

진짜 포인트: Handler가 여러 도메인을 조율하지만, 각 도메인의 내부 로직은 건드리지 않는다.


일관성 전략 - 언제 강하게, 언제 느슨하게

Aggregate 내부는 강한 일관성

// Order + OrderItem은 반드시 함께 생성/수정
async createOrder(command: CreateOrderCommand) {
  return this.prisma.$transaction(async (tx) => {
    const order = await tx.order.create(...);
    await tx.orderItem.createMany(...); // 같은 트랜잭션
    return order;
  });
}

Aggregate 간에는 최종 일관성

// 주문 생성 후 재고 차감은 별도 트랜잭션
@EventsHandler(OrderCreatedEvent)
export class UpdateInventoryHandler {
  async handle(event: OrderCreatedEvent) {
    // 이게 실패해도 주문은 롤백되지 않음
    await this.inventoryService.decreaseStock(event.items);
  }
}

핵심: 도메인 경계를 넘나드는 일관성은 Event로 처리한다. 즉시 일관성이 필요 없는 건 비동기로.


테스트 전략 - 모듈별 독립 테스트의 위력

각 도메인은 독립적으로 테스트 가능

// Product 도메인 테스트 - 다른 도메인 몰라도 됨
describe('ProductService', () => {
  it('상품 검증 로직만 테스트', () => {
    const product = new Product(...);
    expect(product.isAvailable()).toBe(true);
  });
});

// Command Handler 테스트 - Service들을 모킹
describe('CreateOrderHandler', () => {
  it('여러 도메인 서비스 조율 테스트', async () => {
    // Given: 모든 도메인 서비스 모킹
    customerService.validate.mockResolvedValue(true);
    inventoryService.check.mockResolvedValue(true);

    // When: Handler 실행
    await handler.execute(command);

    // Then: 서비스 호출 순서와 파라미터 검증
    expect(customerService.validate).toHaveBeenCalledWith(customerId);
    expect(inventoryService.check).toHaveBeenCalledWith(items);
  });
});

핵심: 의존성이 명확하니까 테스트도 깔끔하다. 각 레이어별로 독립적인 테스트가 가능하다.


모니터링 - 도메인별로 분리해서 보기

각 도메인의 성능을 독립적으로 추적

// 주문 도메인의 성능 메트릭
@Injectable()
export class OrderMetrics {
  private orderCreationTime = new Histogram({
    name: "order_creation_duration_seconds",
    help: "Order creation duration",
  });

  recordOrderCreation(duration: number) {
    this.orderCreationTime.observe(duration);
  }
}

// Command Handler에서 도메인별 성능 추적
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler {
  async execute(command: CreateOrderCommand) {
    const timer = this.metrics.startTimer();

    try {
      // 비즈니스 로직
      const order = await this.processOrder(command);

      // 성공 메트릭
      this.metrics.recordSuccess("order_creation");
      return order;
    } catch (error) {
      // 실패 메트릭 (어느 도메인에서 실패했는지)
      this.metrics.recordFailure("order_creation", error.domain);
      throw error;
    } finally {
      timer();
    }
  }
}

핵심: 각 도메인의 성능과 에러를 독립적으로 추적할 수 있다. 문제가 생기면 어느 도메인인지 바로 알 수 있다.


성능 최적화 - Read와 Write 완전 분리의 위력

CQRS로 조회 성능 극대화

// Write용 - 정규화된 테이블
class Order {
  id: string;
  customerId: string;
  status: string;
}

class OrderItem {
  orderId: string;
  productId: string;
  quantity: number;
}

// Read용 - 비정규화된 뷰
interface OrderListView {
  orderId: string;
  customerName: string;
  productNames: string[];
  totalAmount: number;
  status: string;
}

// 조회는 완전히 별도 테이블/뷰 사용
@QueryHandler(GetOrderListQuery)
export class GetOrderListHandler {
  async execute(query: GetOrderListQuery) {
    // 조회 전용 뷰에서 빠르게 조회
    return this.orderViewRepo.findOptimized(query.filters);
  }
}

핵심: 쓰기용 데이터 구조와 읽기용 데이터 구조를 완전히 분리한다. Event로 Read Model을 업데이트.


피해야 할 함정들

이런 건 절대 하지 마라

// ❌ 도메인 간 직접 Repository 접근
@Injectable()
export class OrderService {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly customerRepo: CustomerRepository // 금지!
  ) {}
}

// ✅ Service를 통한 간접 접근
@Injectable()
export class OrderService {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly customerService: CustomerService // OK
  ) {}
}

주의사항들


결론 - 내가 깨달은 것들

이 패턴들을 2년간 적용해보니 확실히 달라졌다:

좋아진 점들

아직 어려운 점들

핵심 정리

  1. 도메인 경계를 명확히 - 이게 제일 중요함
  2. 의존성 방향 철저히 관리 - 역류하면 지옥
  3. Event로 느슨한 결합 - 도메인 간 직접 호출 금지
  4. 테스트 가능한 구조 - 각 레이어별 독립 테스트

진짜 팁: 새 기능 추가할 때 기존 코드 수정이 많이 필요하면 뭔가 잘못됐다. 새 모듈/Handler 추가만으로 해결되어야 함.

이 방식 한 번 써보면 예전으로 못 돌아간다. 특히 대규모 팀에서 협업할 때 진가가 드러난다.


Share this post on:

Previous Post
좀 더 현명하게 Vibe Coding 하기
Next Post
Prisma JSON 필드에 타입 붙이기