[Spring] CQRS

배창민·2025년 10월 30일
post-thumbnail

CQRS

CQRS(Command Query Responsibility Segregation)는 쓰기(Command)와 읽기(Query)의 책임을 분리해 각각을 독립적으로 최적화·확장하는 아키텍처 스타일이다. 대규모 트래픽에서 읽기 사이드의 확장과 캐싱, 읽기 전용 모델 운영이 특히 유리하다.


1. 구조 개요

1-1. Command Side(쓰기)

  • Application Layer

    • Controller: 명령 요청 수신
    • Service: 유스케이스 조립, 트랜잭션, 외부 연동 조율
    • DTO: 입력 전송 객체
  • Domain Layer

    • Aggregate: 일관성 경계(여러 엔티티를 하나의 변경 단위로)
    • Entity/Value: 도메인 상태와 규칙
    • Repository 인터페이스: 저장소 추상화
    • Domain Service: 규칙·계산·상태변경 로직
  • Infrastructure Layer

    • Repository 구현체(JPA 등)
    • 외부 API 연동 서비스

1-2. Query Side(읽기)

  • Controller: 조회 요청 수신
  • Service: 조회 로직, 정렬/페이징
  • DAO/Mapper: SQL 최적화(MyBatis 등)
  • DTO: 화면/응답 전용 읽기 모델

2. Service 역할 구분

2-1. Application Service

  • 유스케이스 흐름 조립(트랜잭션·보안·로깅)
  • 여러 도메인 서비스/저장소/외부 API를 조율
@Service
public class OrderApplicationService {

    private final OrderDomainService orderDomainService;
    private final PaymentService paymentService;

    public OrderApplicationService(OrderDomainService orderDomainService, PaymentService paymentService) {
        this.orderDomainService = orderDomainService;
        this.paymentService = paymentService;
    }

    @Transactional
    public void createOrder(CreateOrderDTO dto) {
        Order order = orderDomainService.createOrder(dto);
        PaymentResponse pay = paymentService.processPayment(order);
        orderDomainService.updateOrderPaymentStatus(order, pay);
        orderDomainService.saveOrder(order);
    }
}

2-2. Domain Service

  • 순수 비즈니스 규칙, 상태 변경 로직
  • 트랜잭션 경계 관리하지 않음(호출자는 Application)
@Service
public class OrderDomainService {

    private final OrderRepository orderRepository;

    public OrderDomainService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order createOrder(CreateOrderDTO dto) {
        Order order = new Order(dto.getCustomerId(), dto.getItems());
        order.applyDiscount(dto.getDiscountCode());
        return order;
    }

    public void updateOrderPaymentStatus(Order order, PaymentResponse res) {
        if (res.isSuccessful()) order.markAsPaid();
        else order.markAsPaymentFailed();
    }

    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

2-3. Infrastructure Service

  • 외부 시스템 연동(결제, 메시징, 외부 마이크로서비스 등)
  • 세부 구현을 캡슐화하여 상위 레이어 의존 제거

2-4. 언제 단순화할 수 있나

  • 로직이 매우 단순한 CRUD이고 확장 가능성이 낮을 때
  • Application Service에서 Repository 직접 호출도 가능
  • 단, 로직이 복잡해지면 즉시 Domain Service로 분리
@Service
public class UserService {
    private final UserRepository repo;
    public UserService(UserRepository repo) { this.repo = repo; }

    public User getUserById(Long id) {
        return repo.findById(id).orElseThrow(() -> new UserNotFoundException(id));
    }

    public void createUser(CreateUserDTO dto) {
        repo.save(new User(dto.getName(), dto.getEmail()));
    }
}

3. Repository 위치와 의존

// Domain Layer
public interface OrderRepository {
    Order findById(Long id);
    Order save(Order order);
}

// Infrastructure Layer(JPA 구현)
@Repository
public interface JpaOrderRepository extends OrderRepository, JpaRepository<Order, Long> { }
  • 도메인 레이어: 저장소 기술과 무관한 추상화(인터페이스)
  • 인프라 레이어: 실제 구현(JPA, MyBatis, Mongo 등)
  • 장점: 기술 교체 시 도메인 코드 변경 최소화, 응집도↑

4. 심플 패키지 구조 예시

com.ohgiraffers.cqrs.product
 ├─ command
 │    ├─ controller/ProductCommandController.java
 │    ├─ dto
 │    │    ├─ request/ProductCreateRequest.java
 │    │    └─ request/ProductUpdateRequest.java
 │    │    └─ response/ProductCommandResponse.java
 │    ├─ service/ProductCommandService.java
 │    ├─ domain/aggregate/Product.java
 │    └─ repository/ProductRepository.java   // extends JpaRepository<...>
 └─ query
      ├─ controller/ProductQueryController.java
      ├─ dto
      │    ├─ request/ProductSearchRequest.java   // 선택
      │    └─ response/ProductListResponse.java
      │    └─ response/ProductDetailResponse.java
      ├─ service/ProductQueryService.java
      └─ mapper/ProductMapper.java               // MyBatis 등 조회 특화

5. MySQL 스키마와 데이터

계정/DB 생성

CREATE USER 'swcamp'@'%' IDENTIFIED BY 'swcamp';
CREATE DATABASE cqrs;
GRANT ALL PRIVILEGES ON cqrs.* TO 'swcamp'@'%';

테이블

CREATE TABLE IF NOT EXISTS tbl_category
(
    category_code BIGINT AUTO_INCREMENT COMMENT '카테고리코드',
    category_name VARCHAR(50) NOT NULL COMMENT '카테고리명',
    CONSTRAINT pk_category_code PRIMARY KEY (category_code)
) ENGINE=INNODB COMMENT '상품카테고리';

CREATE TABLE IF NOT EXISTS tbl_product
(
    product_code        BIGINT AUTO_INCREMENT COMMENT '상품코드',
    product_name        VARCHAR(100) NOT NULL COMMENT '상품명',
    product_price       VARCHAR(100) NOT NULL COMMENT '상품가격',
    product_description VARCHAR(1000) NOT NULL COMMENT '상품설명',
    category_code       BIGINT COMMENT '카테고리코드',
    product_image_url   VARCHAR(100) NOT NULL COMMENT '상품이미지경로',
    product_stock       BIGINT NOT NULL COMMENT '상품재고',
    created_at          DATETIME NOT NULL DEFAULT now() COMMENT '생성일시',
    modified_at         DATETIME NOT NULL DEFAULT now() COMMENT '수정일시',
    status              VARCHAR(10) NOT NULL DEFAULT 'USABLE' COMMENT '상태',
    CONSTRAINT pk_product_code PRIMARY KEY (product_code),
    CONSTRAINT fk_category_code FOREIGN KEY (category_code) REFERENCES tbl_category (category_code)
) ENGINE=INNODB COMMENT '상품';

샘플 데이터(일부)

INSERT INTO tbl_category (category_name) VALUES ('식사'), ('디저트'), ('음료');

INSERT INTO tbl_product
(product_name, product_price, product_description, category_code, product_image_url, product_stock)
VALUES
('열무김치라떼', 4500, '열무로 만든 김치 라떼', 3, 'http://localhost:8080/productimgs/06a0060a.PNG', 10),
('우럭스무디', 5000, '우럭으로 만든 스무디', 3, 'http://localhost:8080/productimgs/fcb3e0c8.PNG', 15),
('앙버터김치찜', 13000, '가장 먹을만한 김치찜', 1, 'http://localhost:8080/productimgs/7580adcf.PNG', 19);

예제 요청 바디

{
  "productName": "새상품",
  "productPrice": 10000,
  "productDescription": "상품설명",
  "categoryCode": 1,
  "productStock": 10
}

6. 적용 팁

  • 읽기 트래픽이 월등히 크면 Query Side를 별도 DB/캐시로 분리
  • 읽기 전용 DTO로 N+1 회피(fetch join, 전용 SQL/뷰, 캐시)
  • Command는 일관성, Query는 성능에 초점
  • 초기엔 단순 구조로 시작 → 복잡도/트래픽에 따라 점진적 분리
  • 도메인 규칙이 쌓이면 Application Service의 로직을 Domain Service로 이동

7. 체크리스트

  • Aggregate 경계 정의가 명확한가
  • 도메인 규칙이 Application Service에 새어 나오지 않는가
  • 조회 모델이 화면 요구에 최적화되어 있는가
  • 트랜잭션 경계는 Application(Service)에서 일관되게 다루는가
  • 저장소 기술(JPA/MyBatis 등)과 도메인 의존이 분리됐는가
profile
개발자 희망자

0개의 댓글