Microservice Pattern: Chap.7 마이크로서비스 쿼리 구현(CQRS)

bluehope·2024년 10월 24일
post-thumbnail

마이크로서비스에서는 개별 서비스 단위로 DB를 관리하기 때문에 여러 서비스에 분산되어 있는 데이터를 조회하는 것이 간단하지 않다. 일반적으로 마이크로서비스 아키텍처에서는 다음 두가지 패턴으로 쿼리를 구현한다.

  • API 조합(composition) 패턴: 서비스 클라이언트가 데이터를 가진 여러 서비스를 직접 호출하여 그 결과를 조합하는 패턴으로, 구현이 단순함
  • CQRS(커맨드 쿼리 책임 분산) 패턴: 쿼리만 지원하는 하나 이상의 뷰 전용 DB를 유지하는 패턴으로, API조합보다 다양한 기능을 제공할 수 있으나 구현은 더 어려움

API 조합 패턴 응용 쿼리

findOrder() 쿼리

findOrder() 는 기본키를 활용하여 주문 정보를 조회하는 메소드로, orderId를 인자로 입력받아 주문 내역이 포함된 OrderDetails 객체를 반환한다. 이 메소드는 주로 사용자의 단말에 표시하기 위한 view를 구현한 쪽에서 호출하게 되며, OrderDetails는 주문의 다양한 현재 상태 정보로 음식점, 주문한 음식의 조리 상태, 배달원 위치, 결제 상태 등의 정보를 제공해 준다.

모놀리식 어플리케이션은 단일 DB에 해당 정보가 관리되고 있으므로 정보를 확보하여 제공하는 것은 SELECT 쿼리를 orderId를 키로 join 하여 찾아오면 가능하다. 그러나 마이크로서비스는 각각의 정보가 서로 다른 서비스에 위치하고 있으므로 쿼리를 SELECT 쿼리로 찾아오는 것이 어렵다. 따라서 findOrder()를 처리하려면 각각의 서비스에 요청을 반복해야 하는 문제가 발생한다.

  • 주문 서비스: 주문 기본 정보
  • 주방 서비스: 음식점 관점의 주문상태, 픽업 준비까지 예상 소요 시간
  • 배달 서비스: 주문 배달 상태, 배달 예상 정보, 현재 배달원 위치
  • 회계 서비스: 주문 지불 상태
    The findOrder() operation is invoked by a FTGO frontend module and returns the details of an Order.

API 조합 패턴

API 조합 패턴은 데이터를 가진 서비스를 호출한 후 그 반환 결과를 조합해서 응답하는 패턴이다. 이를 위해서는 API 조합기 및 프로바이더가 새로 추가된다.

  • API조합기: 프로바이더(provider) 서비스를 쿼리하여 데이터를 조회
  • 프로바이더 서비스: 최종 결과로 반환할 데이터의 일부를 가지고 있는 서비스

API 조합기는 A,B,C 세 프로바이더 서비스에서 데이터를 조회한 후 그 결과를 조합하는 역할을 수행하며, 웹 애플리케이션에서 웹 페이지 데이터를 렌더링하는 클라이언트이거나, 쿼리 작업을 API 엔드포인트로 표출한 API 게이트웨이나 프론트엔드를 위한 백엔드 패턴의 변형일 수 있다.

The API composition pattern consists of an API composer and two or more provider services. The API composer implements a query by querying the providers and combining the results.

패턴:API 조합
여러 서비스에 있는 데이터를 API를 통해 조회하고 그 결과를 조합하여 쿼리를 구현한다.

API 조합 패턴으로 findOrder() 쿼리 구현

findOrder()는 기본키로 조회하는 패턴으로 orderId를 기반으로 각 서비스에서 필요한 정보를 가져올 수 있는 형태이다. 4개의 서비스에 분산되어 있는 주문 관련 검색 결과를 동일한 키를 이용하여 조회하여 응답할 수 있다. API 조합기는 쿼리를 REST 끝점으로 표출한 서비스로, HTTP 대신 gRPC 같은 다른 IPC 프로토콜을 사용하여도 동일하게 동작할 수 있다. 예제에서의 REST 끝점 GET / order/{orderId}가 구현된 주문 검색 조합기는 orderId로 네 서비스를 각각 호출한 후 수신한 응답을 조인한다.

Implementing findOrder() using the API composition pattern

API 조합 설계의 검토 사항

API 조합 패턴에는 두 가지 설계 이슈를 검토해야 한다. 첫째는 '어느 컴포넌트를 쿼리 작업의 API 조합기로 선정할 것인가?'이고, 두번째는 '어떻게 해야 효율적으로 취합 로직을 작성할 것인가?'의 문제를 검토해야 한다.

어느 컴포넌트를 쿼리 작업의 API 조합기로 선정할 것인가?

여기에는 3가지 방안이 있다. 서비스 클라이언트, API게이트웨이, 그리고 별도의 API조합기를 사용하는 방법이다.

클라이언트

주문 상태 뷰를 구현한 웹 애플리케이션 같은 클라이언트가 동일한 네트워크에서 실행 중이라면 가장 효율적으로 주문 내역을 조회할 수 있지만, 클라이언트가 방화벽 외부에 있고 서비스가 위치한 네트워크가 느리다면 여러번 호출을 반복하는데 비용이 클 수 있다.

Implementing API composition in a client. The client queries the provider services to retrieve the data.

API게이트웨이

쿼리 작업이 애플리케이션의 외부 API에서 호출한 쿼리에 대한 응답을 만드는 형태에 적합한 방식으로, 다른 서비스로 요청을 보내는 대신 API 게이트웨이에 API 조합 로직을 구현하는 형태이다. 이 방법은 모바일 기기 등 방화벽 외부에서 접근하는 클라이언트가 API 호출 한 번으로 여러 서비스의 데이터를 조회할 수 있기 때문에 효율적일 수 있다.

Implementing API composition in the API
gateway. The API queries the provider services to retrieve the data, combines the results, and returns a response to the client.

Stand alone 조합 서비스

유사한 조회를 내부적으로 여러 서비스가 사용하거나, 취합 로직이 복잡하여 API게이트웨이 수준에서 구성이 어려운데 외부에서 접근이 필요한 경우 등에도 효율적인 방법일 수 있다.

Implement a query operation used by multiple clients and services as a standalone service.

리액티브 프로그래밍 모델 사용

분산 시스템에서의 지연 시간은 최소화는 어려운 문제이다. 쿼리 작업의 반응 시간을 최소화하기 위해 API 조합기는 프로바이더 서비스를 병렬 호출할 수 있어야 한다. 주문 검색 애그리거트는 호출 대상인 네 서비스가 서로 의존 관계가 없기 때문에 동시 호출하는데 문제가 없지만, 다양한 비즈니스 로직에는 A 서비스 프로바이더 서비스를 호출 이전에 반드시 B서비스 프로바이더의 결과를 먼저 가져와야 할 수 있어, 서비스 프로바이더 서비스를 순차 호출해야 할 수도 있다.

API 조합 패턴의 고려 사항

API조합패턴은 마이크로서비스 아키텍처에서 쿼리 작업의 구현을 용이하게 해 주지만 부수적으로 발생되는 단점들에 대하여 고려가 필요하다.

  • 오버헤드 증가: 여러 서비스를 여러번 호출하고, 여러 DB를 쿼리하는 것은 모놀리식에서 단일 DB를 쿼리하는 것 대비 오버헤드가 발생하는 것은 불가피하다.
  • 가용성 저하: 하나의 쿼리 작업에 여러 서비스가 개입되는 구조라 장애 지점이 확대되어 가용성에 리스크가 커지게 된다. 이를 위해 프로바이더 서비스가 불능인 경우를 고려하면 다음 두 전략을 사용할 수 있다.
    • API조합기가 이전 캐시 데이터 반환
    • API조합기가 미완성된 데이터를 반환
  • 데이터 일관성 결여: Saga 등으로 결과적 일관성을 확보하기 떄문에, 여러 서비스를 대상으로 조회하면 일관되지 않은 데이터를 받을 수 있다. 예를 들어 주문 서비스가 조회한 주문 상태는 CANCELLED지만, 주방 서비스가 조회한 이 주문의 티켓은 아직 취소되지 않은 상태일 수 있다.

이러한 문제들은 API조합기에서 대응을 하려면 많은 개발이 필요하게 될 수 있지만, 단순한 기능들을 제공하는데는 API 조합 패턴이 유용할 수 있다.

CQRS 패턴

CQRS는 Command Query Responsibility Segregation(명령 조회 책임 분리)의 약어로 여러 서비스에 분산되어 있는 데이터를 복제하여 하나의 읽기전용 뷰를 만들어 해당 뷰에서 쿼리를 수행하는 형태를 의미한다.

패턴: 명령 조회 책임 분리(CQRS)
여러 서비스에 있는 데이터를 가져오는 쿼리는 이벤트를 이용하여 해당 서비스의 데이터를 복제한 읽기 전용 뷰를 유지한다.

CQRS 필요성

findOrderHistory()라는 메소드를 고려해보자. 이 메소드는 consumerIdOrderHistoryFilter라는 매개변수(인자)를 받아 소비자의 주문 이력을 조회하는 메소드이다. OrderHistoryFilter는 어느 시점 이후 주문까지 반환할지(필수 정보), 어떤 주문의 상태를 반환활지(옵션), 음식점명 및 메뉴 항목 검색 키워드(옵션) 등의 정보를 가지고 있을 수 있다. 이 쿼리는 OrderHistory 객체 목록을 최근 순서로 반환하고, 이는 주문 내역을 표시하는 뷰(주문 ID, 주문 상태, 주문 금액 등의 정보를 표시)에 의해서 호출 된다.

이 메소드를 처리하려면 개별 프로바이더 서비스에서 consumerIdOrderHistoryFilter를 이용하여 적합한 응답을 생성할 수 있어야 한다. 그러나 모든 서비스가 OrderHistoryFilter에 적합한 항목을 보관하지 않고 있기 때문에 처리가 불가능한 옵션이 발생된다. 만약 모놀리식 구조였다면 DBMS에 join 쿼리문으로 해결할 수 있음에도 어려운 문제가 발생하게 된다.

예를 들어 findOrderHistory()OrderHistoryFilter에는 메뉴 항목과 매치할 keywords 속성이 있는데, 메뉴 항목을 저장하는 서비스는 주문 서비스, 주방 서비스 2개 뿐이고, 나머지 배달 서비스, 회계 서비스는 메뉴 항목을 저장할 필요가 없기 때문에, keywords 항목을 지정하여 검색하는 것이 어렵다. 마찬가지로 주방 서비스, 배달 서비스도 역시 orderCreationDate 속성은 관리할 필요가 없는 정보이므로 정렬하기 어려운 문제가 발생한다.

만약 앞에서 살펴본 API조합기를 사용한다면, 인-메모리 조인이나, API조합기가 순차적으로 필요한 키워드를 탐색하면서 프로바이더를 호출하는 형태를 사용할 수 있다.

  • API 조합기로 데이터를 인 메모리 조인: 이 방식은 데이터의 규모가 적을때 효율적이며, 많은 데이터를 처리해야 하는 경우 그 효율성이 급격히 떨어지게 된다.
  • API 조합기가 순차 조회: 이 방식은 API 조합기가 필요한 데이터를 확보하기 위해 순차적으로 필요한 서비스를 조회하고, 확보된 조회키를 이용하여 다른 서비스를 조회하는 방식이다. 예를 들어 customerId로 주문 서비스, 주방 서비스에서 데이터를 조회하고, 조회 된 정보를 활용하여 다른 서비스를 조회할 키인 OrderId를 확보하고, 확보된 OrderId 정보를 이용하여 다른 서비스에 있는 데이터를 요청하는 방식이다. 그러나 이 방식은 각각의 서비스가 대량 조회 API를 제공하거나, 혹은 단건으로 조회해야 하는 비효율성이 발생할 수 있다.

단일 서비스 쿼리로는 어려운 경우 - findAvailableRestaurants()

하나의 서비스에 국한된 쿼리도 구현하는데 있어, 데이터를 가진 서비스에 쿼리를 구현하는 것이 부적절한 경우와, 서비스 DB가 효율적인 쿼리를 지원하지 않는 경우에는 어려울 수 있다. 예를 들어 findAvailableRestaurants() 쿼리 작업은 주어진 시점에, 주어진 위치로 배달 가능한 음식점을 검색하는 메소드다. 이 쿼리의 핵심은 배달 주소 기준, 특정 거리 내에 있는 음식점을 위치 기준으로 찾는 기능으로, 음식점을 표시하는 UI 모듈에 의해 주로 호출되는 주문 프로세스에 있어서 매우 중요한 부분이다. findAvailableRestaurants() 쿼리는 지리 정보를 기반으로 하기 때문에 일반적인 DBMS에서는 지원이 어려울 수 있다. 예를 들어 MongoDB, Postgress, MySQL의 Geospatial Extensions를 이용한다면 비교적 쉽게 구현이 가능할 수 있으나, 만약 사용 중인 DBMS가 해당 기능을 지원하지 않을 경우에는 음식점 데이터를 지리 공간 쿼리에 맞게 설계해야 하며, 음식점 데이터의 복제본을 새로운 지리공간 인덱싱을 지원하는 다른 DB에 유지하는 방법도 있다. 그러나 이 경우, 복제본에 최신 데이터를 현행화하는 문제를 해결해야 한다.

관심사 분리

데이터를 가진 서비스에 쿼리를 구현하는 것이 합리적인지 고려할 필요가 있다. 예를 들어 findAvailableRestaurants()는 음식점 서비스(RestaurantService)에 있는 데이터를 조회하는 쿼리 작업으로, 음식점명, 주소, 요리, 메뉴, 오픈 시간 등 다양한 속성을 조회하는 기능이다. 그러나 음식점 서비스는 음식점주에게 음식점의 프로필, 메뉴 항목, 영업 시간 등의 정보를 관리할 수 있게 해 주는 서비스로 데이터의 관리 측면에서는 합리적인 접근일 수 있다. 그러나 음식점 서비스 개발 팀의 주 목표는 음식점 주인이 자기가 운영하는 음식점을 잘 관리할 수 있게 해주는 서비스를 개발하는 일이므로, 성능이 매우 중요한 대용량 데이터를 조회하는 쿼리를 구현하는 일은 아닐 수 있다. 또한 이 팀의 개발자가 findAvailableRestaurants()의 개발까지 담당하는 것이 합리적인지 검토가 필요하다. 음식점주를 대상으로 하는 서비스가 직접 고객의 오더에 영향을 줄 수 있는 형태가 합리적이지 않을 수 있기 때문이다. 따라서 어떤 경우에는 findAvailableRestaurants()의 쿼리는 다른 팀(해당 기능이 필요한 주문서비스 개발 부서 등)이 구현하고 음식점 서비스는 검색할 음식점 데이터만 제공하는 것이 유리할 수 있다.

CQRS 개요

마이크로서비스에서는 쿼리를 구현할 때 앞에서 살펴본 세가지의 난관을 해소해야 한다. 이 문제를 해소하는데 CQRS 패턴이 도움이 될 수 있다.

  • API를 조합하여 여러 서비스에 흩어진 데이터를 조회하려면 값비싸고 비효울적인 인-메모리 조인을 해야한다.
  • 데이터를 가진 서비스는 필요한 쿼리를 효율적으로 지원하지 않는 DB에 또는 그런 형태로 데이터를 저장한다.
  • 관심사를 분리할 필요가 있다는 것은 데이터를 가진 서비스가 쿼리 작업을 구현할 장소로 적합하지 않다는 의미이다.

CQRS는 커맨드와 쿼리를 서로 분리한다.

CQRS(커맨드 쿼리 책임 분리)는 관심사의 분리/구분에 관한 패턴이다. 영속적인 데이터 모델과 그것을 사용하는 모듈을 커맨드와 쿼리, 두 편으로 분리하여, 조회 기능과 생성/수정/삭제 기능을 분리하여 별도로 구현하고, 데이터 동기화는 커맨드 쪽에서 발행한 이벤트를 조회 쪽에서 구독하여 업데이트 하는 형태로 진행된다.

  • 생성/수정/삭제(CUD): HTTP POST, PUT, DELETE 등에 해당하는 기능은 커맨드 모듈 및 데이터 모델에 구현
  • 조회(R): HTTP GET 에 해당하는 기능은 쿼리 쪽 모듈 및 데이터 모델에 구현

On the left is the non-CQRS version of the service, and on the right is the CQRS version. CQRS restructures a service into command-side and query-side modules, which have separate databases.

CQRS 서비스에서 커맨드 쪽 도메인 모델은 CRUD 작업을 처리하고 자체 DB에 매핑되며, 커맨드 쪽은 데이터가 바뀔 때 마다 (이벤트 소싱 프레임워크 활용 등) 도메인 이벤트를 발행한다. 쿼리 모델은 이벤트 핸들러가 도메인 이벤트를 구독하면서 커맨드에서 발생한 이벤트를 기반으로 쿼리 모델의 DB를 업데이트한다.

CQRS와 쿼리 전용 서비스

CQRS는 서비스 내부에 적용하는 것 외에, 쿼리 서비스를 정의하는 것도 가능하다. 쿼리 서비스에는 커맨드 작업이 전혀 없는 오직 쿼리 작업만으로 구성된 API나, 혹은 하나 이상의 다른 서비스가 발행한 이벤트를 구독하여 항상 최신 상태로 유지되는 DB를 쿼리하는 로직이 구현되어 있다. 어떤 서비스에도 속하지 않는 이런 뷰는 별도의 stand alone(단독구축) 형태로 구축하는 것이 합리적이다. findOrderHistory()를 구현하기 위한 OrderHistoryService(주문이력서비스)를 아래 그림처럼 별도 서비스로 구현이 가능하다. 주문 이력 서비스는 여러 서비스가 발행한 이벤트를 구독하면서, 주문 이력 뷰 DB를 업데이트 하는 이벤트 핸들러를 가지고 있다.

The design of Order History Service, which is a query-side service. It implements the findOrderHistory() query operation by querying a database, which it maintains by subscribing to events published by multiple other services.

추가로 쿼리 서비스는 한 서비스가 가진 데이터를 복제한 뷰를 구현하는 수단으로도 유용하여, findAvailableRestaurants() 쿼리 작업을 가용 음식점 서비스라는 별도의 서비스로 구현할 수도 있다.

여러 면에서 CQRS는 RDBMS를 기록 시스템으로 활용하면서 텍스트 검색 엔진 (Elsatic Search)를 이용하여 텍스트 검색 쿼리를 처리하는 대중적인 접근 방식을 이벤트를 기반으로 일반화한 것이라고 볼 수 있다. 다만 CQRS는 텍스트 검색 엔진 뿐 아니라 훨씬 다양한 종류의 DB를 활용할 수 있다는 차이점이 있고, CQRS 쿼리 쪽 뷰는 이벤트를 구독해서 거의 실시간으로 업데이트할 수 있다.

CQRS의 장점

  • 마이크로서비스 아키텍처에서 효율적인 쿼리가 가능하다. API 조합 패턴으로 쿼리하면 거대한 데이터를 인-메모리 조인해야 하는 작업을 미리 뷰를 만들어 두어 효율적이 될 수 있다.
  • 다양한 쿼리를 효율적으로 구현할 수 있다. 단일 영속화 데이터 모델로는 다양한 요구상의 쿼리르 지원하기 어려우나, CQRS 패턴을 이용하면 각 쿼리가 효울적으로 구현된 하나 이상의 뷰를 정의하여 단일 데이터 저장소의 한계를 극복할 수 있다.
  • 이벤트 소싱 애플리케이션에서 쿼리가 가능하다. CQRS는 이벤트 소싱의 한계 (이벤트 저장소는 기본키 쿼리만 지원)을 극복할 수 있도록 지원한다. CQRS 패턴은 하나 이상의 애그리거트 뷰를 정의하고 이벤트 소싱 기반의 애그리거트가 발행한 이벤트 스트림을 구독해서 항상 최신 상태를 유지할 수 있다. 따라서 이벤트 소싱 애그리거트는 대부분 CQRS를 사용한다.
  • 관심사가 더 분리된다. 도메인 모델과 영속화 데이터 모델은 커맨드와 쿼리를 모두 처리하지 않고, 서비스의 커맨드 쪽, 쿼리 쪽에 각각 알맞은 코드 모듈과 DB 스키마를 별도로 정의하기 때문에, 관심사가 분리되어 커맨드/쿼리 양쪽 모두 관리하기 용이해진다. 더욱이 데이터 소유 서비스가 아닌 별도의 서비스에서 뷰를 만들어 서비스를 할 수 있따.

CQRS의 단점

  • 아키텍처가 복잡하다. 개발자는 뷰를 조회/수정하는 쿼리를 작성하고, 별도의 데이터 저장소를 관리해야하므로 운영 복잡도도 증가된다.
  • 복제 시차를 신경써야 한다. 커맨드/쿼리 양쪽 뷰사이의 시차(lag)이 필연적으로 발생되므로 시차를 잘 처리해야 한다. 클라이언트 애플리케이션이 애그리거트를 업데이트한 즉시 뷰를 쿼리하면 이전 버전의 애그리거트를 바라볼 수도 있으므로 시차에 대한 해소가 필요하다. 랙을 해결할 수 있는 한 가지 방법은 커맨드/쿼리 양쪽 API가 클라이언트에 버전 정보를 전달해서 해당 데이터가 업데이트가 된 애그리거트에서 가져온 건지 확인하는 방법이 있다. 이 경우 클라이언트는 최신 데이터를 받을 때 까지 쿼리 쪽 뷰를 계속 폴링하는 형태로 동작할 수 있다.

결론적으로, 가능하면 API를 조합을 우선 고려하고, 요구사항이 복잡해지는 경우에 CQRS를 고려하는 것이 합리적이다.

CQRS 뷰 설계

CQRS 뷰 모듈에는 View DB와 이벤트 핸들러, 쿼리 API 모듈 그리고 데이터 접근모듈이다. 이러한 CQRS 뷰 모듈을 개발할 때에는 몇 가지 중요한 설계 사항에 대한 결정이 필요하다.

  • DB를 선정하고 스키마 설계
  • 데이터 접근 모듈을 설계할 때 멱등한/동시 업데이트 등 다양한 문제를 고려
  • 기존 애플리케이션에 새 뷰를 구현하거나 기존 스키마를 바꿀 경우, 뷰를 효율적으로 (재)빌드할 수 있는 수단을 강구
  • 뷰 클라이언트에서 복제 시차를 어떻게 처리할지 결정
    The design of a CQRS view module. Event handlers update the view database, which is queried by the Query API module.

뷰 DB 선택

NoSQL은 대부분 트랜잭션 기능이 제한적이고 범용적인 쿼리 능력은 없지만, 유연한 데이터 모델, 우수한 성능/확장성 등으로 인해 확대되고 있다. NoSQL DB는 CQRS 뷰와 잘 맞을 수 있으며, NoSQL DB의 데이터 모델과 우수한 성능이 CQRS 뷰에 유리할 수 있다. 또한 CQRS 뷰는 단순 트랜잭션만 사용하고 고정된 쿼리만 실행하므로 NoSQL DB의 제약 사항에도 영향을 적게 받을 수도 있다. 반면에 기존 관계형 DB를 사용해서 CQRS 뷰를 구현하는 것이 유리한 부분도 많다. 최신 하드웨어에서 실행되는 최신 RDBMS는 예전보다 성능이 뛰어나고, 아무래도 대부분의 IT 종사자들은 SQL DB가 더 익숙하다는 장점이 있으며, SQL DB는 확장판을 설치해서 비관계형 기능 (ex: 지리 공간 데이터형 쿼리)를 추가할 수도 있다.

~가 필요하면~를 사용한다예시
JSON 객체를 PK로 검색문서형 스토어(MongoDB, DynamoDB 등)
키-값 스토어(Redis 등)
고객별 MongoDB 문서로 주문 이력 관리
쿼리 기반의 JSON 객체 검색문서형 스토어MongoDB, DynamoDB로 고객 뷰 구현
텍스트 쿼리텍스트 검색 엔진(ElasticSearch 등)주문별 일래스틱서치 문서로 주문 텍스트 검색 구현
그래프 쿼리그래프DB(Neo4j 등)고객, 주문, 기타 데이터의 그래프로 부정 탐지 구현
전통적인 SQL 리포팅/BI관계형DB표준 비즈니스 리포트 및 분석

업데이트 작업 지원

뷰 데이터 모델에서는 쿼리뿐만 아니라 이벤트 핸들러가 실행할 업데이트 작업 역시 효율적으로 구현되어야 한다. 이벤트 핸들러는 대게 뷰 DB에 있는 레코드를 기본키로 찾아 수정/삭제하게 된다. 예를 들어 findOrderHistory() 쿼리의 CQRS 뷰를 설계한다고 할 때, 이 뷰는 서비스에서 이벤트를 수신 받아 그대로 해당 레코드에 업데이트 할 수 있다. 그러나 외래키를 이용해서 레코드를 수정/삭제해야 하는 경우도 발생한다. 만약 Delivery와 Order가 1:1 관계라면 Delivery.id와 Order.id는 같을 수 있지만, 1:N의 관계인 경우도 발생한다. 이런 경우 외래키 기반의 업데이트가 요구될 수 있다. 일부 DB 자료형은 외래키 기반의 업데이트 작업을 지원하나, NoSQL DB에서는 non PK 기반으로 데이터를 업데이트하기가 쉽지 않다. 따라서 애플리케이션이 업데이트할 레코드를 결정하려면 외래키에서 기본키로 매핑 가능한 데이터를 별도로 DB에 관리해야 한다. 예를 들어 PK 기반의 수정/삭제만 지원되는 DynamoDB를 사용한다면, 먼저 DynamoDB 보조 인덱스를 쿼리해서 수정/삭제할 항목의 기본키를 결정해야 할 수도 있다.

데이터 접근 모듈 설계

이벤트 핸들러와 쿼리 API 모듈은 DB에 직접 접근하지 않고, DAO 및 헬퍼 클래스로 구성된 데이터 접근 모듈을 사용하여 접근한다. DAO는 이벤트 핸들러가 호출한 업데이트 작업과 쿼리 모듈이 호출한 쿼리 작업을 실제로 수행하고, 고수준 코드에 쓰이는 자료형과 DB API간 매핑, 동시 업데이트 처리 및 업데이트 멱등성 보장 등의 역할을 담당해야 한다.

동시성 처리

동일한 DB 레코드에 대하여 동시에 접근하여 갱신을 시도할 수 있다. 뷰가 한 종류의 애그리거트가 발행한 이벤트를 구독한다면 특정 애그리거트 인스턴스가 발행한 이벤트는 순차적으로 처리되기 때문에, 한 애그리거트 인스턴스에 해당하는 레코드의 동시 업데이트가 발생하지 않아 동시성 이슈는 문제가 되지 않으. 그러나 뷰가 여러 종류의 애그리거트가 발행한 이벤트를 구독하는 경우, 여러 이벤트 핸들러가 동일한 레코드를 업데이트하려고 시도할 수 있게 된다. 예를 들어 동일한 주문을 대상으로 Order* 이벤트 핸들러와 Delivery* 이벤트 핸들러가 동일한 시간에 호출되어 해당 주문의 DB 레코드를 업데이트를 동시에 시도하게 된다면, DAO는 동시 업데이트로 서로가 서로의 데이터를 덮어쓰지 않아야 하며, 낙관적 잠금, 비관적 잠금 등 잠금(lock) 방법 등을 구현해야 한다.

멱등한 이벤트 핸들러

메시징의 특성상 일반적으로 이벤트는 여러번 전달될 수 있으며, 그에 따라서 이벤트 핸들러는 같은 이벤트를 여러번 처리해야 할 수도 있다. 동일한 이벤트를 여러번 처리한다 하더라도 결과에 차이가 발생하지 않는다면 문제의 소지가 없으나, 일시적으로 동기화가 안된는 경우가 발생할 수 있어 제어 로직이 필요하다. 예를 들어 주문 이력의 뷰를 유지하는 이벤트 핸들러가 처리하는 도중 메시지처리에 오류가 생겨 PICKEDUP과 DELIVERED를 두번 전송되는 문제가 발생된다고 가정하면 아래 그림처럼 일시적인 상태 오류가 발생할 수 있다.

The DeliveryPickedUp and DeliveryDelivered events are delivered twice, which causes the order state in view to be temporarily out-of-date. _

따라서 이벤트 핸들러는 멱등성을 보장하기 위해서 중복 이벤트에 대한 처리 로직을 구현해야 한다. 만약 은행 잔고를 증가시키는 이벤트를 처리한다면 중복 처리에 대한 결과는 멱등할 수 없게 된다. 따라서 이벤트 핸들러는 자신이 뷰 데이터 저장소에서 처리한 이벤트 ID를 기록해 두었다가 중복 이벤트가 들어오면 솎아내야 한하며, 순차 처리를 위한 방법도 제공해야 한다. 이를 위해 이벤트 핸들러는 반드시 처리한 이벤트 ID를 기록해야 하며, 데이터 저장소에 원자적으로 업데이트 해야 한다. 뷰 데이터 저장소가 SQL DB일 경우, 이벤트 핸들러가 처리완료한 이벤트를 뷰 업데이트 트랜잭션 일부로 PROCESSED_EVENTS 테이블에 삽입할 수 있다. 그러나 NoSQL DB처럼 트랜잭션을 지원하지 않는 경우, 이벤트 핸들러는 자신이 업데이트하는 데이터 저장소 '레코드' (MongoDB의 도큐먼트, DynamoDB의 테이블 아이템)에 처리된 이벤트를 같이 저장해야 한다.

만약 이벤트 ID가 하나씩 증가하는 구조라면 주어진 애그리거트 인스턴스에서 전달받은 max(eventID)를 각 레코드에 저장하고, 레코드가 단일 애그리거트 인스턴스에 해당된다면 처리할 이벤트의 eventId를 비교하여 기 처리 완료한 이벤트를 걸러낼 수 있다. 여러 애그리거트의 이벤트가 조합된 결과를 나타내는 레코드는 [애그리거트 타입, 애그리거트 ID] -> max(eventId) 맵을 담고 있어야 한다.

{...
"Order3945484543123-48546546542": "0000815e0c6f18f-0242ac1110e0ee02e"
"Delivery3945484543123-48546546542": "0000815e0c6f18f-0242ac1110e0ee02e"
}

클라이언트 최종 뷰 확인

CQRS를 적용하면 커맨드 쪽 업데이트 후 클라이언트가 조회용 DB를 바라보면 아직 업데이트 되지 않은 결과를 조회할 수 있다. 메시징 인프라의 특성에 따라 최종 일관성이 맞춰지겠지만 일시적 불일치는 발생할 수 밖에 없다. 이 문제를 해소하기 위해서는 커맨드 쪽에서 클라이언트에 각 이벤트 ID에 맞춰 토큰을 발행하고, 클라이언트는 해당 토큰을 쿼리(조회 작업)에 인자로 전송하면 쿼리 쪽에서 바라보는 결과에 토큰과 불일치하면 에러를 반환하는 형태로 뷰 모듈에서도 기능 개발이 가능할 수 있다.

CQRS 뷰 추가 및 업데이트

CQRS에서 뷰를 새로 생성하거나, 업데이트하려면 기존의 이벤트를 메시지 브로커에서 모두 가져와서 필요한 정보를 포함한 뷰를 다시 등록하는 형태로 처리가 되어야 한다. 그러나 메시지 브로커에 그동안 발생된 모든 메시지를 보관하는 것이 불가능하기 때문에 메시지 브로커에서 재구성하는 것은 현실적으로 어려운 문제가 된다. 한 방안은 메시지 브로커에서 받은 메시지를 아카이빙 해 두는 방법등을 고려할 수 있다.
또한 시간이 흐를수록 이벤트가 누적되어 가기 때문에, 전체 이벤트를 처리하는 시간/리소스가 점점 증가하게 되는 문제가 있다. 따라서 2단계 증분 알고리즘 (two-step incremental algorithm)을 적용하여 재처리해야 하는 부분을 최소화 시키는 것이 필요하다. 1단계는 주기적으로 각 애그리거트의 인스턴스의 스냅샷을 만들어 두는 것이다. 2단계는 직전 스냅샷과 그 이후 발생한 이벤트를 처리하여 새로운 뷰를 생성하는 형태이다.

CQRS 뷰 구현: AWS DynamoDB 응용

AWS DynamoDB는 NoSQL DB로 완전 관리형으로 사용가능하여 널리 사용되고 있다. DynamoDB의 데이터 모델은 JSON 객체처럼 계층적인 이름-값 쌍이 포함된 테이블로 구성되어 있다. 본 예시에서 findOrderHistory()의 CQRS 뷰는 여러 서비스의 이벤트를 소비해야 하는 형태이므로 Stand alone(SA)의 주문 뷰 서비스로 구현하는 것으로 가정한다. 주문 이력 서비스는 여러 가지 모듈이 있을 수 있지만, 본 예제에서는 개발/테스트를 단순화하기 위해 모듈마다 책임을 나누어 구현한다.

  • OrderHistoryEventHandler: 여러 서비스가 발행한 이벤트를 구독하여 OrderHistoryDAO를 호출하여 뷰를 구축

  • OrderHistoryQuery API 모듈: 서비스 외부에서 조회하기 위한 REST Endpoint

  • OrderHistoryDataAccess: DynamoDB 접근에 필요한 헬퍼 클래스와 ftgo-order-history의 DyanmoDB 테이블을 조회/갱신하기 위한 메소드를 정의한 OrderHistoryDAO를 포함한 클래스

  • ftgo-order-history: 주문 애그리거트를 저장하는 DynamoDB 테이블

    The design of OrderHistoryService. OrderHistory- EventHandlers updates the database in response to events. The OrderHistoryQuery module implements the query operations by query- ing the database. These two modules use the OrderHistory- DataAccess module to access the database.

    OrderHistoryEventHandlers 모듈

OrderHistoryEventHandlers는 이벤트를 소비하여 DynamoDB의 테이블을 업데이트하는 이벤트 핸들러들로 구성된 모듈로, 이벤트로부터 전달받은 인수를 OrderHistoryDao 메소드에 전달하는 단순 역할을 수행한다.

public class OrderHistoryEventHandlers {
  private OrderHistoryDao orderHistoryDao;
  public OrderHistoryEventHandlers(OrderHistoryDao orderHistoryDao) {
    this.orderHistoryDao = orderHistoryDao;
  }
  public void handleOrderCreated(DomainEventEnvelope<OrderCreated> dee) {
    boolean result = orderHistoryDao.addOrder(makeOrder(dee.getAggregateId(), dee.getEvent()), makeSourceEvent(dee));
  }
  private Order makeOrder(String orderId, OrderCreatedEvent event) {
    return new Order(orderId,
            Long.toString(event.getOrderDetails().getConsumerId()),
            OrderState.APPROVAL_PENDING,
            event.getOrderDetails().getLineItems(),
            event.getOrderDetails().getOrderTotal(),
            event.getOrderDetails().getRestaurantId(),
            event.getRestaurantName());
  }
  public void handleDeliveryPickedUp(DomainEventEnvelope<DeliveryPickedUp> dee) {
    orderHistoryDao.notePickedUp(dee.getEvent().getOrderId(),
            makeSourceEvent(dee));
  }
}

이벤트 핸들러는 이벤트와 이벤트에 관한 메타데이터가 담겨 있는 하나의 DomainEventEnvelope형 매개변수를 받아 이벤트에 맞춰 메소드가 호출되면 그에 맞는 오더 생성, 픽업 이벤트 업데이트 등을 처리한다.
예를 들어 OrderCreated 이벤트가 발생하면 handleOrderCreated() 이벤트가 호출되고, 이 메서드는 다시 orderHistoryDao.addOrder()를 호출해서 DB에 Order를 생성하고, DeliveryPickedUp 이벤트가 발생하면 handleDeliveryPickedUp()이 호출되고, 다시 orderHistoryDao.notePickedUp()이 호출되어 저장되어 있는 Order의 상태를 갱신한다.
이벤트를 처리하는 두 메소드(handleOrderCreated(), handleDeliveryPickedUp())는 멱등성을 보장하기 위해 헬퍼 메서드인 makeSourceEvent()를 호출하며, makeSourceEvent()는 이벤트를 발생시킨 애그리거트 타입과 ID, 그리고 이벤트 ID가 포함된 SourceEvent를 생성한다.

DynamoDB 데이터 모델링 및 쿼리

DynamoDB는 테이블과 인덱스로 구성된다. 테이블은 아이템을 포함하고 있으며, 인덱스는 테이블 아이템에 접근하는 대체 수단으로 사용된다. RDBMS에서는 한 레코드가 아이템에 해당하지만, DynamoDB에서는 하나의 애그리거트를 저장할 수도 있다. 매우 유연하지만 DynamoDB도 다른 NoSQL DB처럼 데이터에 대한 접근을 위한 키 설계는 중요한 문제이며, 다음의 설계 이슈들 검토해야 한다.

  • ftgo-order-history 테이블 설계
  • findOrderHistory 쿼리 전용 인덱스 정의
  • findOrderHistory 쿼리 구현
  • 쿼리 결과에 대한 페이지 분리
  • 주문 업데이트
  • 중복 이벤트 감지

ftgo-order-history 테이블 설계

Order클래스의 각 필드는 각 아이템의 속성애 매핑이 가능하고, 개별 주문 항목인 lineItems는 타임 라인당 하나씩 리스트 형태로 하나의 속성에 매핑한다. DynamoDB는 기본키로 아이템을 삽입/수정/조회하므로 기본키는 OrderId를 선택한다.

Preliminary structure of the DynamoDB OrderHistory table

findOrderHistory쿼리 전용 인덱스

ftgo-order-history 테이블은 기본키로 접근이 가능하지만, findOrderHistory의 순차 정렬이 필요한 접근은 지원하지 않는다. DynamoDB는 두 스칼라 속성이 조합된 기본키가 있어야 해당 조회가 가능하고, 정렬 키가 있어야 순차 정렬을 진행할 수 있다.
findOrderHistory() 쿼리 작업은 consumerId가 파티션 키, orderCreationDate가 정렬키로 있어야 하지만, (consumerId, orderCreationDate)는 유일키(unique key)가 아니므로 기본키로 사용하는 것에 적합하지 않다. 따라서 DynamoDB에서 지원하는 보조인덱스(secondary index)를 활용하여 non-unique 키인 (consumerId, orderCreationDate) 를 활용하여 비 식별 인덱스(non-key index)를 생성하여 빠르게 접근이 가능하게 한다.

The design of the OrderHistory table and index

findOrderHistory 쿼리 구현

findOrderHistory에는 주문 조회 시작/종료 일 등의 검색 기준 filter를 지원하려면, DynamoDB query() 작업에서 정렬 키에 범위 제약을 걸 수 있는 조건 표현식을 이용하여 구현이 가능하다. 그 밖에 비식별 속성에 해당되는 검색기준은 Boolean 표현식인 Filter Expression을 이용하여 구현이 가능하다. 인덱스에 포함되어 있지 않지만, OrderHistoryDaoDynamoDb 에서 CANCELLED 상태인 주문을 검색하기 위해서는 orderStatus = :orderStatus의 필터 표현식으로 검색이 가능하다.
추가로 음식점명, 메뉴 항목 등 일반 텍스트에 해당하는 키워드 검색은 OrderHistoryDaoDynamoDb에서 음식점명과 메뉴 항목을 토큰화하여 해당 키워드들을 keywords라는 세트 값 속성(set-value attribute)에 저장하여 contains(keywords, :keyword1) OR contains(keywords, :keyword2) 의 형태로 contains() 함수를 쓴 필터 표현식으로 키워드가 포함된 주문 정보를 찾을 수 있다.

페이지화

쿼리 결과가 매우 많이 나오는 경우를 대비하기 위해 페이지화(pagination)이 필요하다. DynamoDB는 쿼리 결과의 최대 개수를 지정할 수 있으며, LastEvaluatedKey라는 속성으로 다음 페이지를 검색할 수 있도록 지원한다. 그러나 DynamoDB는 position 기반의 페이지화는 지원하지 않기 때문에, 클라이언트가 토큰을 받아 다시 제공하는 형태로 개발이 필요하다.

주문 업데이트

DynamoDB는 PutItem(), UpdateItem()을 통해 기본키로 아이템을 찾아 생성 또는 대체하는 작업을 지원하므로 OrderHistoryDaoDynamoDb가 삽입/수정할 수 있지만, 동일한 아이템을 동시 업데이트 할 경우 순차 처리 보장이 되지 않는다. 예를 들어 두 이벤트 핸들러가 동일한 아이템을 동시에 업데이트를 시도한다고 하면, DynamoDB에서 가져온 아이템을 메모리에서 변경후 PutItem()으로 업데이트를 시도한다. 따라서 두 이벤트 핸들러가 변경한 내역을 덮어쓰게 될 수 있으므로 낙관적 잠금 메커니즘을 적용하여 소실된 업데이트를 방지할 수 있다. 반면에 UpdateItem()은 개별 아이템 속성을 업데이트 하는 것으로, 이벤트 핸들러가 각각의 항목만 업데이트 하도록 하면 더 효과적으로 처리할 수 있다.

중복 이벤트 감지

메시지는 중복 전송이 발생될 수 있으므로 이벤트도 중복 발생이 될 수 있다. 이 경우 결과적 일관성을 유지할 수 있을 지라도, 일시적으로 일관성이 깨지는 현상이 발생되는 것을 최소화 하기 위하여 중복 이벤트를 감지하여 재처리 되는 것을 차단하는 것은 필요한 일이다. 따라서 OrderHistoryDaoDynamoDb는 아이템마다 업데이트를 일으킨 이벤트를 기록해서 중복 이벤트를 수신했는지 감지하고, UpdadteItem() 작업의 조건부 업데이트 메커니즘을 활용하여 중복 이벤트가 아닌 조건에만 아이템을 업데이트하도록 구현해야 한다.
OrderHistoryDaoDynamoDb의 DAO는 수신한 <애그리거트 타입><애그리거트 ID> 속성을 활용하여 각 애그리거트 인스턴스에서 전달받은 이벤트를 추적하여, <애그리거트 타입><애그리거트 ID>이 존재하고 그 값이 현재의 이벤트ID보다 같거나 작은 이벤트면 중복 이벤트로 판단하고 처리를 거를 수 있다. 아래와 같은 조건부 표현식을 사용하면 속성이 존재하지 않거나, eventID가 가장 마지막에 처리된 이벤트 ID보다 클 경우에만 업데이트한다.

  attribute_not_exists(<애그리거트 타입><애그리거트 ID>)
    또는 <애그리거트 타입><애그리거트 ID> < :이벤트 ID

OrderHistoryDaoDynamoDB 클래스

OrderHistoryDaoDaynamoDB 클래스는 ftgo-order-history테이블에 접근하는 메소드가 구현된 클래스로, OrderHistoryEventHandler가 쓰기 요청을, OrderHistoryQuery가 조회 요청을 호출하게 된다.

addOrder() 메소드

addOrder()order, sourceEvent 두 인자를 받아 ftgo-order-history 테이블에 Order를 추가하는 메소드이다. OrderHistoryEventHandlerOrderCreated 이벤트에서 Order 애그리거트를 확보하고, eventSource 는 이벤트를 발생시킨 애그리거트의 aggregateType, aggregateId, eventId 를 확보하여 addOrder()를 호출한다. eventSource는 조건부 업데이트를 수행(중복 메시지 필터링, 낙관적 잠금 등)하기 위해 사용되는 값이다. addOrder()는 최종적으로 중복 메시지를 걸러내는 기능을 포함한 메소드인 idempotentUpdate()를 호출하여 repository(DyanmoDB) 업데이트를 처리한다.

public class OrderHistoryDaoDynamoDb ... // 클래스 선언부 생략

  @Override
  public boolean addOrder(Order order, Optional<SourceEvent> eventSource) {
    UpdateItemSpec spec = new UpdateItemSpec()
            .withPrimaryKey("orderId", order.getOrderId()) // 업데이트할 Order 기본 키
            .withUpdateExpression("SET orderStatus = :orderStatus, " + // 속성을 업데이트 하는 표현 식
                    "creationDate = :creationDate, consumerId = :consumerId, lineItems =" +
                    " :lineItems, keywords = :keywords, restaurantId = :restaurantId, " +
                    " restaurantName = :restaurantName"
            )
            .withValueMap(new Maps() // 업데이트 표현식의 자리끼우개 값들
                    .add(":orderStatus", order.getStatus().toString())
                    .add(":consumerId", order.getConsumerId())
                    .add(":creationDate", order.getCreationDate().getMillis())
                    .add(":lineItems", mapLineItems(order.getLineItems()))
                    .add(":keywords", mapKeywords(order))
                    .add(":restaurantId", order.getRestaurantId())
                    .add(":restaurantName", order.getRestaurantName())
                    .map())
            .withReturnValues(ReturnValue.NONE);
    return idempotentUpdate(spec, eventSource); 
  }

notePickedUp() 메소드

notePickedUp() 메소드는 DiliveryPickedUp의 이벤트 핸들러가 호출하는 메소드로, OrderdeliveryStatusPICKED_UP으로 변경한다. 이 메소드는 return 값이 없다.

public class OrderHistoryDaoDynamoDb ... // 클래스 선언부 생략
@Override
public void notePickedUp(String orderId, Optional<SourceEvent> eventSource) {
 UpdateItemSpec spec = new UpdateItemSpec()
         .withPrimaryKey("orderId", orderId)
         .withUpdateExpression("SET #deliveryStatus = :deliveryStatus")
         .withNameMap(Collections.singletonMap("#deliveryStatus",
                 DELIVERY_STATUS_FIELD))
         .withValueMap(Collections.singletonMap(":deliveryStatus",
                 DeliveryStatus.PICKED_UP.toString()))
         .withReturnValues(ReturnValue.NONE);
 idempotentUpdate(spec, eventSource); 
}

idempotentUpdate() 메소드

idempotentUpdate() 메소드는 중복 업데이트를 방지하기 위한 조건부 표현식을 추가하여 중복이면 skip 하고 중복이 아니면 정상 업데이트를 처리하도록 한다. SourceEvent.addDuplicateDetection() 메소드는 이벤트의 중복성을 검증하는 기능을 수행하며, 앞에서 호출하였던 <aggregateType, aggregateId, eventId> 정보를 사용하여 기 처리된 메시지인지 확인하고 기존에 처리 되었으면 ConditionalCheckFailedException을 발생시켜 아무 일도 하지 않으며 예외가 발생되었음 응답(return false)하고, 아니라면 정상적인 업데이트를 처리하고 성공적으로 처리했음을 응답(return true) 한다.

public class OrderHistoryDaoDynamoDb ... // 클래스 선언부 생략

private boolean idempotentUpdate(UpdateItemSpec spec, Optional<SourceEvent>
        eventSource) {
  try {
    table.updateItem(eventSource.map(es -> es.addDuplicateDetection(spec))
          .orElse(spec)); //duplicateDetection에서 중복이 확인되면 예외 발생
    return true;
  } catch (ConditionalCheckFailedException e) {
    // Do nothing
   return false;
  }
}

findOrderHistory() 메소드

findOrderHistory() 메소드는 앞에서 살펴본 보조 인덱스 ftgo-order-history-by-consumer-id-and-creation-time을 이용하여 ftgo-order-history 테이블을 필터 조건과 consumerId를 기준으로 조회하여 생성일 기준으로 정렬하여 응답하기 위한 QuerySpec 객체를 생성하여 퀄한 결과를 OrderHistory 객체로 반환한다.

public class OrderHistoryDaoDynamoDb ... // 클래스 선언부 생략

 @Override
  public OrderHistory findOrderHistory(String consumerId, OrderHistoryFilter
          filter) {

    QuerySpec spec = new QuerySpec()
            .withScanIndexForward(false) // 정렬 순서 지정
            .withHashKey("consumerId", consumerId)
            .withRangeKeyCondition(new RangeKeyCondition("creationDate").gt // 필터에서 지정한 주문 일자 범위 지정
                    (filter.getSince().getMillis()));

    // 페이지화를 위한 토큰 확인, 토큰이 있으면 해당 토큰 기준으로 시작키 설정
    filter.getStartKeyToken().ifPresent(token -> spec.withExclusiveStartKey
            (toStartingPrimaryKey(token)));

    Map<String, Object> valuesMap = new HashMap<>();

    String filterExpression = Expressions.and( // 필터 표현식 생성 및  OrderHistoryFilter에서 가져온 맵으로 자리끼우개 값 세팅
            keywordFilterExpression(valuesMap, filter.getKeywords()),
            statusFilterExpression(valuesMap, filter.getStatus()));

    if (!valuesMap.isEmpty())
      spec.withValueMap(valuesMap);

    if (StringUtils.isNotBlank(filterExpression)) {
      spec.withFilterExpression(filterExpression);
    }

    System.out.print("filterExpression.toString()=" + filterExpression);

    filter.getPageSize().ifPresent(spec::withMaxResultSize); // 호출부가 페이기 크기를 지정했다면 응답 결과의 갯수 제한

    ItemCollection<QueryOutcome> result = index.query(spec);

    return new OrderHistory(StreamSupport.stream(result.spliterator(), false)
            .map(this::toOrder).collect(toList()), // 조회 결과 반환된 항목으로 Order 생성
            Optional.ofNullable(result.getLastLowLevelResult().getQueryResult
                    ().getLastEvaluatedKey()).map(this::toStartKeyToken)); // 페이지화를 위한 키 포함
  }

findOrderHistory()getLastEvaluatedKey()의 응답을 JSON 토큰으로 직렬화 하는 형태로 페이지네이션을 구현한다. 클라이언트가 OrderHistoryFilter에서 시작 토큰을 지정했다면 findOrderHistory()는 해당 토큰을 직렬화 하여 withExclusiveStartKey()를 사용하여 시작 키를 설정한다.

더 읽어 보기

CQRS(원문 링크)

14 July 2011 by Martin Folwer

CQRS는 Command Query Responsibility Segregation(명령 조회 책임 분리)의 약자입니다. 이는 Greg Young이 처음 설명한 패턴입니다. 핵심 개념은 정보를 업데이트하는 모델과 정보를 읽는 모델을 다르게 사용할 수 있다는 것입니다. 일부 상황에서 이러한 분리는 가치가 있을 수 있지만, 대부분의 시스템에서 CQRS는 위험한 복잡성을 추가한다는 점에 주의해야 합니다.

정보 시스템과 상호 작용하는 주류 접근 방식은 CRUD 데이터 저장소로 취급하는 것입니다. 이는 레코드 구조에 대한 정신적 모델을 가지고 있어, 새로운 레코드를 생성하고, 레코드를 읽고, 기존 레코드를 업데이트하며, 필요 없어지면 레코드를 삭제할 수 있다는 의미입니다. 가장 단순한 경우, 우리의 상호 작용은 모두 이러한 레코드를 저장하고 검색하는 것에 관한 것입니다.

요구 사항이 더 복잡해짐에 따라 우리는 점진적으로 이 모델에서 벗어납니다. 레코드 저장소와는 다른 방식으로 정보를 보고 싶을 수 있습니다. 아마도 여러 레코드를 하나로 축소하거나 다른 장소의 정보를 결합하여 가상 레코드를 형성할 수 있습니다. 업데이트 측면에서는 특정 데이터 조합만 저장을 허용하는 유효성 검사 규칙을 발견할 수 있고, 심지어 우리가 제공한 것과 다른 저장될 데이터를 추론할 수도 있습니다.

이런 일이 발생하면 우리는 정보의 여러 표현을 보기 시작합니다. 사용자가 정보와 상호 작용할 때 이 정보의 다양한 프레젠테이션을 사용하며, 각각은 서로 다른 표현입니다. 개발자들은 일반적으로 모델의 핵심 요소를 조작하는 데 사용하는 자체 개념 모델을 구축합니다. 도메인 모델을 사용하는 경우, 이는 보통 도메인의 개념적 표현입니다. 일반적으로 영구 저장소도 개념 모델과 최대한 가깝게 만듭니다.

이러한 다중 표현 계층 구조는 꽤 복잡해질 수 있지만, 사람들이 이를 수행할 때 여전히 모든 프레젠테이션 사이의 개념적 통합 지점 역할을 하는 단일 개념적 표현으로 해결합니다.
CQRS가 도입하는 변화는 이 개념적 모델을 업데이트와 표시를 위한 별도의 모델로 분할하는 것입니다. 이를 Command Query Separation의 어휘를 따라 각각 Command와 Query라고 합니다. 이유는 많은 문제, 특히 더 복잡한 도메인에서 명령과 쿼리에 대해 동일한 개념 모델을 가지면 둘 다 잘 수행하지 못하는 더 복잡한 모델로 이어진다는 것입니다.

별도의 모델이란 대개 다른 객체 모델을 의미하며, 아마도 다른 논리적 프로세스에서 실행되고 별도의 하드웨어에서 실행될 수 있습니다. 웹 예시로는 사용자가 쿼리 모델을 사용하여 렌더링된 웹 페이지를 보는 것입니다. 변경을 시작하면 그 변경은 처리를 위해 별도의 명령 모델로 라우팅되고, 결과적인 변경 사항은 업데이트된 상태를 렌더링하기 위해 쿼리 모델에 전달됩니다.

여기에는 상당한 변형의 여지가 있습니다. 메모리 내 모델이 같은 데이터베이스를 공유할 수 있으며, 이 경우 데이터베이스가 두 모델 간의 통신 역할을 합니다. 그러나 별도의 데이터베이스를 사용할 수도 있어, 실질적으로 쿼리 측의 데이터베이스를 실시간 보고 데이터베이스로 만듭니다. 이 경우 두 모델 또는 그들의 데이터베이스 간에 어떤 통신 메커니즘이 필요합니다.
두 모델이 반드시 별도의 객체 모델일 필요는 없습니다. 관계형 데이터베이스의 뷰와 비슷하게, 동일한 객체가 명령 측과 쿼리 측에 대해 다른 인터페이스를 가질 수 있습니다. 그러나 보통 CQRS에 대해 들을 때, 그것들은 명확히 별도의 모델입니다.

CQRS는 자연스럽게 다른 아키텍처 패턴들과 잘 맞습니다.

  • CRUD를 통해 상호 작용하는 단일 표현에서 벗어나면서, 우리는 쉽게 작업 기반 UI로 이동할 수 있습니다.
  • CQRS는 이벤트 기반 프로그래밍 모델과 잘 맞습니다. CQRS 시스템이 이벤트 협업을 통해 통신하는 별도의 서비스로 분할되는 것이 일반적입니다. 이를 통해 이러한 서비스들이 쉽게 이벤트 소싱을 활용할 수 있습니다.
  • 별도의 모델을 가짐으로써 이러한 모델들을 얼마나 일관되게 유지할지에 대한 질문이 제기되며, 이는 결과적 일관성을 사용할 가능성을 높입니다.
  • 많은 도메인에서 대부분의 로직은 업데이트할 때 필요하므로, 열성적 읽기 파생(Eager Read Derivation)을 사용하여 쿼리 측 모델을 단순화하는 것이 합리적일 수 있습니다.
  • 쓰기 모델이 모든 업데이트에 대해 이벤트를 생성하는 경우, 읽기 모델을 이벤트 포스터로 구조화하여 메모리 이미지가 되도록 할 수 있으며, 따라서 많은 데이터베이스 상호 작용을 피할 수 있습니다.
  • CQRS는 복잡한 도메인에 적합하며, 이는 도메인 주도 설계(Domain-Driven Design)의 이점을 얻을 수 있는 종류의 도메인입니다.

언제 사용해야 하는가

모든 패턴과 마찬가지로 CQRS는 일부 상황에서는 유용하지만 다른 상황에서는 그렇지 않습니다. 많은 시스템이 CRUD 정신 모델에 잘 맞기 때문에 그런 스타일로 구현되어야 합니다. CQRS는 모든 관련자에게 상당한 정신적 도약을 요구하므로, 그 이점이 그만한 가치가 있지 않다면 시도해서는 안 됩니다. CQRS를 성공적으로 사용한 사례를 본 적이 있지만, 지금까지 제가 마주친 대부분의 경우는 그리 좋지 않았으며, CQRS가 소프트웨어 시스템을 심각한 어려움에 빠뜨리는 중요한 요인으로 여겨졌습니다.

특히 CQRS는 시스템 전체가 아니라 시스템의 특정 부분(DDD 용어로 경계 컨텍스트(Bounded Context))에만 사용되어야 합니다. 이러한 사고 방식에서 각 경계 컨텍스트는 어떻게 모델링되어야 하는지에 대한 자체적인 결정이 필요합니다.
지금까지 두 가지 방향에서 이점을 보고 있습니다. 첫째, 일부 복잡한 도메인은 CQRS를 사용하여 더 쉽게 다룰 수 있을 수 있습니다. 하지만 CQRS에 적합한 이런 경우는 매우 소수라는 점을 강조해야 합니다. 대개는 명령 측과 쿼리 측 사이에 충분한 중복이 있어 모델을 공유하는 것이 더 쉽습니다. CQRS와 맞지 않는 도메인에 CQRS를 사용하면 복잡성이 증가하여 생산성이 감소하고 위험이 증가합니다.

다른 주요 이점은 고성능 애플리케이션을 처리하는 데 있습니다. CQRS를 사용하면 읽기와 쓰기의 부하를 분리하여 각각을 독립적으로 확장할 수 있습니다. 애플리케이션에서 읽기와 쓰기 사이에 큰 불균형이 있다면 이는 매우 유용합니다. 그렇지 않더라도 두 측면에 서로 다른 최적화 전략을 적용할 수 있습니다. 예를 들어, 읽기와 업데이트에 대해 서로 다른 데이터베이스 접근 기술을 사용하는 것입니다.

도메인이 CQRS에 적합하지 않지만 복잡성이나 성능 문제를 야기하는 까다로운 쿼리가 있다면, 여전히 보고 데이터베이스(Reporting Database)를 사용할 수 있다는 점을 기억하세요. CQRS는 모든 쿼리에 대해 별도의 모델을 사용합니다. 보고 데이터베이스를 사용하면 대부분의 쿼리에 대해 여전히 주 시스템을 사용하지만, 더 까다로운 쿼리는 보고 데이터베이스로 오프로드합니다.

이러한 이점에도 불구하고, CQRS 사용에 대해 매우 신중해야 합니다. 많은 정보 시스템은 읽는 방식과 동일한 방식으로 업데이트되는 정보 기반이라는 개념과 잘 맞습니다. 이러한 시스템에 CQRS를 추가하면 상당한 복잡성이 더해질 수 있습니다. 저는 확실히 CQRS가 생산성에 상당한 장애물이 되어 유능한 팀의 손에서도 프로젝트에 불필요한 양의 위험을 추가한 경우를 보았습니다. 따라서 CQRS는 도구 상자에 가지고 있으면 좋은 패턴이지만, 사용하기 어렵고 잘못 다루면 중요한 부분을 쉽게 잃을 수 있다는 점에 주의해야 합니다.

profile
다시 시작해보자!

0개의 댓글