Command Query Responsibility Segregation

CQRS 는 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴이. 전통적인 CRUD 모델에서 벗어나 데이터를 변경하는 작업과 데이터를 읽는 작업을 완전히 분리한다.

  • Command: 시스템의 상태를 변경하지만 값을 반환하지 않음
  • Query: 값을 반환하지만 시스템의 상태를 변경하지 않음

Command Query Responsibility Segregation is suited to complex domains, the kind that also benefit from Domain-Driven Design.

Traditional:

// 전통적인 CRUD - 하나의 모델로 모든 작업 처리
@Entity
public class Order {
    private String orderId;
    private String customerId;
    private List<OrderItem> items;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    // CRUD 메서드들이 같은 모델을 사용
}

@Repository
public class OrderRepository {
    public void save(Order order) { /* 생성/수정 */ }
    public Order findById(String id) { /* 조회 */ }
    public void delete(String id) { /* 삭제 */ }
    public List<Order> findByCustomerId(String customerId) { /* 조회 */ }
}

CQRS:

// Command Side - 쓰기 모델
public class CreateOrderCommand {
    private final String customerId;
    private final List<OrderItemDto> items;
    private final String shippingAddress;
    
    // 비즈니스 로직에 필요한 최소한의 데이터만 포함
}

public class OrderAggregate {
    private String orderId;
    private String customerId;
    private List<OrderItem> items;
    private OrderStatus status;
    
    public void createOrder(CreateOrderCommand command) {
        // 비즈니스 로직 실행
        // 이벤트 발생
    }
}

// Query Side - 읽기 모델
public class OrderSummaryView {
    private String orderId;
    private String customerName;
    private String statusDisplay;
    private BigDecimal totalAmount;
    private String formattedDate;
    
    // 화면 표시에 최적화된 데이터 구조
}

public class OrderDetailView {
    private String orderId;
    private CustomerInfo customer;
    private List<OrderItemView> items;
    private ShippingInfo shipping;
    private PaymentInfo payment;
    
    // 상세 조회에 최적화된 데이터 구조
}

Command Side:

// Command
public class UpdateInventoryCommand {
    private final String productId;
    private final int quantity;
    private final String reason;
    private final String updatedBy;
}

// Command Handler
@Component
public class UpdateInventoryCommandHandler {
    private final InventoryRepository repository;
    private final EventPublisher eventPublisher;
    
    public void handle(UpdateInventoryCommand command) {
        // 1. 도메인 객체 로드
        Inventory inventory = repository.findById(command.getProductId());
        
        // 2. 비즈니스 로직 실행
        inventory.updateQuantity(command.getQuantity(), command.getReason());
        
        // 3. 변경사항 저장
        repository.save(inventory);
        
        // 4. 이벤트 발행
        eventPublisher.publish(new InventoryUpdatedEvent(
            command.getProductId(),
            command.getQuantity(),
            command.getReason()
        ));
    }
}

// Domain Model
public class Inventory {
    private String productId;
    private int currentQuantity;
    private int reservedQuantity;
    
    public void updateQuantity(int newQuantity, String reason) {
        validateQuantityUpdate(newQuantity, reason);
        this.currentQuantity = newQuantity;
        // 비즈니스 규칙 적용
    }
    
    private void validateQuantityUpdate(int quantity, String reason) {
        if (quantity < 0) {
            throw new InvalidQuantityException("Quantity cannot be negative");
        }
        // 추가 비즈니스 규칙 검증
    }
}

Query Side:

// Query
public class GetProductInventoryQuery {
    private final String productId;
    private final boolean includeReserved;
}

// Query Handler
@Component
public class GetProductInventoryQueryHandler {
    private final InventoryReadRepository readRepository;
    
    public InventoryView handle(GetProductInventoryQuery query) {
        return readRepository.findInventoryView(
            query.getProductId(),
            query.isIncludeReserved()
        );
    }
}

// Read Model
public class InventoryView {
    private String productId;
    private String productName;
    private int availableQuantity;
    private int reservedQuantity;
    private String lastUpdated;
    private String status;
    
    // 조회에 최적화된 구조
}

// Read Repository
@Repository
public class InventoryReadRepository {
    private final JdbcTemplate jdbcTemplate;
    
    public InventoryView findInventoryView(String productId, boolean includeReserved) {
        String sql = """
            SELECT 
                p.product_id,
                p.product_name,
                i.available_quantity,
                i.reserved_quantity,
                i.last_updated,
                i.status
            FROM products p
            JOIN inventory i ON p.product_id = i.product_id
            WHERE p.product_id = ?
        """;
        
        return jdbcTemplate.queryForObject(sql, new InventoryViewRowMapper(), productId);
    }
}

Command/Query Bus

Command Bus:

@Component
public class CommandBus {
    private final ApplicationContext applicationContext;
    private final Map<Class<?>, CommandHandler> handlers = new HashMap<>();
    
    @PostConstruct
    public void initializeHandlers() {
        applicationContext.getBeansOfType(CommandHandler.class)
            .values()
            .forEach(handler -> {
                Class<?> commandType = getCommandType(handler);
                handlers.put(commandType, handler);
            });
    }
    
    @SuppressWarnings("unchecked")
    public <T> void send(T command) {
        CommandHandler<T> handler = handlers.get(command.getClass());
        if (handler == null) {
            throw new IllegalArgumentException("No handler found for command: " + command.getClass());
        }
        handler.handle(command);
    }
}

public interface CommandHandler<T> {
    void handle(T command);
}

@Component
public class CreateOrderCommandHandler implements CommandHandler<CreateOrderCommand> {
    private final OrderRepository repository;
    private final EventPublisher eventPublisher;
    
    @Override
    @Transactional
    public void handle(CreateOrderCommand command) {
        Order order = Order.create(command);
        repository.save(order);
        
        eventPublisher.publish(new OrderCreatedEvent(order.getId(), order.getCustomerId()));
    }
}

Query Bus:

@Component
public class QueryBus {
    private final ApplicationContext applicationContext;
    private final Map<Class<?>, QueryHandler> handlers = new HashMap<>();
    
    @SuppressWarnings("unchecked")
    public <T, R> R send(T query) {
        QueryHandler<T, R> handler = handlers.get(query.getClass());
        if (handler == null) {
            throw new IllegalArgumentException("No handler found for query: " + query.getClass());
        }
        return handler.handle(query);
    }
}

public interface QueryHandler<T, R> {
    R handle(T query);
}

@Component
public class GetOrderQueryHandler implements QueryHandler<GetOrderQuery, OrderView> {
    private final OrderViewRepository repository;
    
    @Override
    public OrderView handle(GetOrderQuery query) {
        return repository.findById(query.getOrderId())
            .orElseThrow(() -> new OrderNotFoundException(query.getOrderId()));
    }
}