프로덕트 개발이 진행됨에 따라 도메인의 책임도 많아집니다. 도메인의 복잡성이 높아질수록 유지보수와 기능 개발 효율이 떨어질 수 있습니다. 이전 게시글(Outside-in development)에서 보았듯이 개발자에게 중요한 것은 ‘정말로 고객이 필요로 하는 비즈니스 가치에 집중하고 있는가?’ 입니다. CQRS는 간단히 설명해서 데이터 저장소에 대한 읽기와 쓰기 작업을 분리하는 패턴입니다. 읽기와 쓰기의 분리가 도메인의 복잡성을 관리하는데 어떻게 도움을 주는지 알아보겠습니다.
CQRS는 Command and Query Responsibility Segregation의 약자로, 애플리케이션의 읽기(Query) 작업과 쓰기(Command) 작업을 분리하는 패턴입니다. 이 패턴의 주요 목적은 두 모델을 독립적으로 최적화하고 복잡성을 관리하는 것입니다. 우리가 흔히 알고 있는 전통적인 CRUD 모델에서는 하나의 데이터 모델을 사용해 모든 작업을 처리했지만, CQRS에서는 읽기와 쓰기를 위한 별도의 모델을 사용합니다. 한 개의 데이터 저장소에 Query와 Command를 분리할 수도 있지만, 데이터 저장소를 각 작업 전용으로 분리함으로써 개별적인 스케일 관리를 통한 확장성을 더 높일 수 있습니다.
CQRS는 읽기(Query)와 쓰기(Command) 모델을 분리함으로써 각 모델이 자신의 역할에 집중할 수 있게 합니다. 이를 통해 개발자는 복잡한 비즈니스 로직과 도메인 모델에 더 집중할 수 있습니다. Command 모델에서는 비즈니스 규칙과 유효성 검사에, Query 모델에서는 효율적인 데이터 표현에 집중할 수 있습니다. 이는 도메인 주도 설계(DDD)와 시너지 효과를 내며, 비즈니스 로직의 순수성을 유지하는 데 도움을 줍니다.
예를 들어 커머스 시스템에서 주문 처리(Command)와 주문 조회(Query)를 분리하여, 주문 처리 로직에 복잡한 재고 확인, 결제 처리 등의 비즈니스 로직을 집중시키고, 주문 조회는 단순히 데이터를 효율적으로 표현하는 데 집중할 수 있습니다.
CQRS를 통해 읽기와 쓰기 작업을 독립적으로 확장할 수 있습니다. 특히 읽기 작업이 쓰기 작업보다 훨씬 많이 발생하는 시스템에서 Query 모델을 독립적으로 스케일 아웃할 수 있어 전체적인 시스템 성능이 향상됩니다. 또한 각 모델에 적합한 데이터 저장소를 선택할 수 있어, 높은 쓰기 처리량과 낮은 읽기 지연 시간을 달성할 수 있습니다.
예를 들어 SNS에서 포스트 작성(Command)은 관계형 데이터베이스를, 포스트 조회(Query)는 NoSQL 데이터베이스 또는 캐시를 사용하여 각각의 성능을 최적화할 수 있습니다.
Command와 Query의 관심사가 명확히 분리되어 있어, 각 모델의 변경이나 최적화가 다른 모델에 영향을 미치지 않습니다. 이는 장애 처리와 기능 개선 시 유지보수성을 크게 높여줍니다. 최근 거의 모든 서비스들이 중단 없는 서비스를 지향하기 때문에, 특정 기능에 문제가 생겼다고 해서 모든 기능이 동작하지 않는 단일 장애점(SPOF, Single Point of Failure) 문제를 예방할 수 있습니다.
Command와 Query 모델을 명시적으로 분리함으로써 시스템 전반에 코드 수준의 복잡성을 증가시킵니다. 이는 개발 프로세스를 더 복잡하게 만들고, 학습 곡선을 높일 수 있습니다. 따라서 팀 전체가 CQRS 패턴을 충분히 이해하고 있어야 하며, 이에 대한 학습과 준비가 필요합니다.
Command와 Query 모델 간 데이터 동기화 지연으로 인해 일시적인 데이터 불일치가 발생할 수 있습니다. 이러한 문제를 관리하기 위해 이벤트 소싱이나 메시지 큐 등의 기술을 활용할 수 있습니다. 시스템의 요구사항에 따라 즉시 일관성이 필요한 부분과 최종 일관성이 필요한 부분을 구분하여 구현함으로써, 성능, 확장성, 그리고 데이터 일관성 사이의 트레이드 오프를 관리할 수 있습니다.
CQRS는 모든 시스템에 적합한 패턴은 아닙니다. 특히 도메인이 단순하거나 CRUD 스타일의 작업이 대부분인 시스템에서는 CQRS가 오버엔지니어링이 될 수 있습니다. CQRS는 복잡한 도메인 로직과 높은 확장성이 요구되는 환경, 특히나 쓰기보다 읽기 작업이 많이 발생하는 시스템에 가장 적합합니다.
Java, Spring, Spring Data JPA를 활용한 상품 관리 시스템 예제 코드를 통해 전통적인 CRUD 구조와 CQRS를 적용한 코드를 순서대로 보여드리겠습니다. 패턴의 형태를 표현하는 것이 목적이므로 코드의 품질에 시적 허용이 포함된 부분이 있을 수 있습니다.
// 상품 엔티티
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private String description;
private BigDecimal price;
private String sellerId;
// ...
}
// 상품 레포지토리
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}
// 상품 서비스
@Service
@Transactional
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional(readOnly = true)
public Product product(Long id) {
return productRepository.findById(id).orElseThrow(() -> new RuntimeException("Product not found"));
}
@Transactional(readOnly = true)
public List<Product> products() {
return productRepository.findAll();
}
public Product create(Product product) {
return productRepository.save(product);
}
public Product update(Long id, Product productDetails) {
Product savedProduct = product(id);
Product updatedProduct = Product.builder()
.id(savedProduct.getId())
.name(productDetails.getName())
.description(productDetails.getDescription())
.price(productDetails.getPrice())
.build();
return productRepository.save(updatedProduct);
}
public void delete(Long id) {
productRepository.deleteById(id);
}
}
// 상품 컨트롤러
@RestController
@RequestMapping("/products")
public class ProductController {
@Autowired
private ProductService productService;
@PostMapping
public Product create(@RequestBody Product product) {
return productService.create(product);
}
@GetMapping("/{id}")
public Product product(@PathVariable Long id) {
return productService.product(id);
}
@GetMapping
public List<Product> products() {
return productService.products();
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product productDetails) {
return productService.update(id, productDetails);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
productService.delete(id);
}
}
// Command 모델
public class CreateProductCommand {
private String name;
private String description;
private BigDecimal price;
private String sellerId;
// ...
}
public class UpdateProductCommand {
private Long id;
private String name;
private String description;
private BigDecimal price;
// ...
}
// Query 모델
public class ProductResponse {
private Long id;
private String name;
private String description;
private BigDecimal price;
private String sellerId;
// ...
}
// Command 핸들러
@Service
@Transactional
public class ProductCommandHandler {
@Autowired
private ProductRepository productRepository;
public Long handleCreateProduct(CreateProductCommand command) {
Product product = Product.builder()
.name(command.getName())
.description(command.getDescription())
.price(command.getPrice())
.sellerId(command.getSellerId())
.build();
Product savedProduct = productRepository.save(product);
return savedProduct.getId();
}
public void handleUpdateProduct(UpdateProductCommand command) {
Product product = productRepository.findById(command.getId())
.orElseThrow(() -> new RuntimeException("Product not found"));
Product updatedProduct = Product.builder()
.id(product.getId())
.name(command.getName())
.description(command.getDescription())
.price(command.getPrice())
.sellerId(product.getSellerId())
.build();
productRepository.save(updatedProduct);
}
}
// Query 서비스
@Service
@Transactional(readOnly = true)
public class ProductQueryService {
@Autowired
private ProductRepository productRepository;
public ProductResponse product(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found"));
return convertToDTO(product);
}
public List<ProductResponse> products() {
return productRepository.findAll().stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
private ProductResponse convertToDTO(Product product) {
return ProductResponse.builder()
.id(product.getId())
.name(product.getName())
.description(product.getDescription())
.price(product.getPrice())
.sellerId(product.getSellerId())
.build();
}
}
// 컨트롤러
@RestController
@RequestMapping("/products")
public class ProductController {
@Autowired
private ProductCommandHandler commandHandler;
@Autowired
private ProductQueryService queryService;
@PostMapping
public ResponseEntity<Long> createProduct(@RequestBody CreateProductCommand command) {
Long productId = commandHandler.handleCreateProduct(command);
return ResponseEntity.ok(productId);
}
@PutMapping("/{id}")
public ResponseEntity<Void> updateProduct(@PathVariable Long id, @RequestBody UpdateProductCommand command) {
command.setId(id);
commandHandler.handleUpdateProduct(command);
return ResponseEntity.ok().build();
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> product(@PathVariable Long id) {
ProductResponse productResponse = queryService.product(id);
return ResponseEntity.ok(product);
}
@GetMapping
public ResponseEntity<List<ProductResponse>> products() {
List<ProductResponse> productResponses = queryService.products();
return ResponseEntity.ok(products);
}
}
CQRS 구현 시 주요 고려사항 중 하나는 데이터 일관성 관리입니다. 이것을 어떻게 효과적으로 할 수 있을까요? CQRS는 이벤트 소싱(Event Sourcing)과 상호보완적인 관계를 가지고 있어, 두 패턴이 함께 등장하는 경우가 많습니다. 비록 실제 문제 해결에 적용한 경험은 없지만, 이론을 통해 이벤트 소싱의 개념과 장단점, 그리고 CQRS와의 관계에 대해 알아보도록 하겠습니다.
CQRS를 제안한 Greg Young은 CQRS가 이벤트 소싱을 위한 발판이라고 소개했습니다.
이벤트 소싱을 간단히 소개하면 애플리케이션의 모든 상태 변화를 이벤트 형태로 저장하는 방식입니다. 이벤트 소싱에 대한 마틴 파울러의 글에서는 이벤트 소싱의 핵심 아이디어는 “애플리케이션 상태의 모든 변경을 이벤트 객체로 캡처하고, 이 이벤트 객체들을 애플리케이션 상태와 동일한 순서로 저장하는 것”이라고 정의하고 있습니다.
이벤트 소싱과 CQRS는 각각 별개의 요구사항에 의해 사용할 수 있는 패턴입니다. 비유하자면 햄버거와 감자튀김이라고 할 수 있겠습니다. 서로 별개이지만, 함께 사용하면 시너지 효과를 낼 수 있습니다.
CQRS는 읽기와 쓰기 작업을 분리하는 것이 목적입니다. 이 때 쓰기 모델에서 생성된 이벤트를 이벤트 소싱 패턴을 통해 저장한다면 모든 변경상태의 추적을 통해 감사 데이터로 활용하거나 디버깅에 사용할 수 있습니다.
이벤트 소싱은 시스템의 상태 변경을 이벤트로 저장하여 히스토리를 관리하고자 하는 것이 목적입니다. 이 때 기존 로직에 비해 모든 이벤트를 저장한 후 읽기 위해서는 많은 리소스가 필요합니다. 이 때 CQRS의 읽기 모델 분리 후 개별 스케일링을 통한 확장성 및 성능 향상을 기대할 수 있습니다.
이벤트 소싱은 시스템의 모든 중요한 변화를 이벤트 형태로 기록하는 방식입니다. 예를 들어, 사용자가 주문을 생성하면 "주문 생성(Order Created)" 이벤트가, 주문 상태가 변경되면 "주문 승인(Order Approved)" 이벤트가 생성됩니다. 이러한 이벤트들은 시간 순서대로 append-only log 형태로 저장되며, audit log 의 성질을 가집니다. 또한 각 이벤트는 고유한 순서 번호나 타임스탬프를 가지며, 한번 저장된 이벤트는 불변성을 통해 시스템의 무결성을 보장합니다.
이러한 이벤트 스트림은 시스템의 전체 역사를 나타내며, 현재 상태는 이 저장된 이벤트들을 순차적으로 적용하여 재구성됩니다. 필요에 따라 시스템은 이벤트 스트림을 처음부터 (또는 최근의 스냅샷부터) 처리하여 특정 시점의 상태를 정확히 재현할 수 있습니다. 이러한 특성을 통해 디버깅, 감사, 시간 여행 기능 등을 할 수 있으며, 시스템의 모든 변경사항을 추적하고 이해하는 데 큰 도움을 줍니다.
모든 변경사항이 이벤트로 저장되어 시스템의 전체 이력을 추적할 수 있습니다. 이는 금융, 의료 등 규제가 엄격한 산업에서 특히 유용하며, 시스템의 모든 상태 변화를 정확히 추적하고 설명할 수 있습니다.
특정 시점의 시스템 상태를 정확히 재현할 수 있습니다. 이는 과거의 특정 시점으로 돌아가 그 당시의 시스템 상태를 검토할 수 있음을 의미합니다. 이 기능은 디버깅, 과거 데이터 분석, 가정 시나리오 테스트 등에 매우 유용합니다. 이러한 동작은 우리가 git을 통해 코드의 형상을 관리하고, 그 당시의 코드로 돌아가 디버깅을 하는 것과 비슷합니다.
이벤트는 비즈니스 용어로 표현되어 도메인 중심 설계를 쉽게 만듭니다. 이벤트는 "주문 생성", "결제 완료" 등과 같이 비즈니스 언어로 표현되므로, 개발자와 도메인 전문가 간의 의사소통이 더욱 명확해집니다. 이는 도메인 주도 설계(DDD) 원칙과 잘 부합합니다.
CQRS 패턴과 결합하여 읽기 모델을 별도로 최적화할 수 있습니다. 또한, 이벤트 기반 아키텍처와 자연스럽게 통합되어 시스템의 확장성을 높일 수 있습니다. 이는 마이크로서비스 아키텍처에서 특히 유용할 수 있습니다.
서비스가 발전함에 따라 주문 이벤트에 새로운 필드가 추가될 수 있습니다. 이 때 버전별 관리를 통해 이전 버전의 이벤트도 처리할 수 있도록 설계해야 합니다.
많은 이벤트를 처리해야 할 경우 스냅샷 등의 최적화 기법이 필요합니다. 이벤트가 누적됨에 따라 상태 재구성에 시간이 많이 소요될 수 있으므로, 주기적으로 상태 스냅샷을 저장하여 재구성 시간을 단축시킬 수 있습니다. 또는 CQRS 모델 도입을 통한 확장성 있는 설계를 고려할 필요가 있습니다.
이벤트 재생 시 외부 시스템과의 상호작용 처리 방법을 고려해야 합니다. 이벤트를 재생할 때 외부 시스템에 중복 요청을 보내지 않도록 주의해야 합니다. 이를 위해 멱등성을 보장하는 설계가 필요합니다.
이벤트 소싱의 개념을 Java를 사용한 간단한 예제 코드를 통해 살펴보겠습니다. 이 예제는 주문 시스템의 일부를 구현한 것으로, 실제 프로덕션 환경의 복잡성을 모두 반영하지는 않았지만 이벤트 소싱의 개념을 가볍게 이해하는데 도움을 주는 것을 목적으로 합니다.
// 이벤트 인터페이스
public interface Event {
UUID getEventId();
LocalDateTime getCreatedAt();
}
// 주문 생성 이벤트
public class OrderCreatedEvent implements Event {
private final UUID eventId;
private final Date createdAt;
private final UUID orderId;
private final String customerName;
// ...
}
// 이벤트 스토어
public class EventStore {
private final List<Event> events = new ArrayList<>();
public void store(Event event) {
events.add(event);
}
public List<Event> getEvents() {
return new ArrayList<>(events);
}
}
// 주문 애그리게이트
public class Order {
private UUID id;
private String customerName;
private OrderStatus status;
public void apply(OrderCreatedEvent event) {
this.id = event.getOrderId();
this.customerName = event.getCustomerName();
this.status = OrderStatus.CREATED;
}
// ...
}
// 주문 서비스
public class OrderService {
private final EventStore eventStore;
public OrderService(EventStore eventStore) {
this.eventStore = eventStore;
}
public void createOrder(String customerName) {
UUID orderId = UUID.randomUUID();
OrderCreatedEvent event = new OrderCreatedEvent(orderId, customerName);
eventStore.store(event);
}
public Order getOrder(UUID orderId) {
Order order = new Order();
for (Event event : eventStore.getEvents()) {
if (event instanceof OrderCreatedEvent) {
OrderCreatedEvent orderCreatedEvent = (OrderCreatedEvent) event;
if (orderCreatedEvent.getOrderId().equals(orderId)) {
order.apply(orderCreatedEvent);
}
}
// ...
}
return order;
}
}
CQRS와 이벤트 소싱은 복잡한 도메인 관리와 유지보수성, 확장성 향상에 유용한 도구입니다. CQRS를 통해 명령과 조회 모델을 분리하여 각각을 최적화할 수 있으며, 이벤트 소싱은 시스템의 모든 상태 변화를 추적하고 재현할 수 있게 해줍니다. 그러나 이들의 도입에는 초기 학습 비용과 데이터 일관성 관리, 이벤트 스키마 변경 등의 기술적 과제가 따릅니다.
따라서 이러한 패턴들을 적용할 때는 시스템의 요구사항과 도메인의 복잡성을 통해 트레이드 오프를 고려해야 합니다. 궁극적으로 기술은 비즈니스 가치 창출을 위한 수단임을 명심하며, CQRS와 이벤트 소싱을 적절히 활용하여 사용자에게 더 나은 가치를 제공할 수 있는 시스템을 구축하는 것이 중요합니다.