CQRS(명령 쿼리 책임 분리)는 데이터 저장소에 대한 읽기 및 쓰기 작업을 별도의 데이터 모델로 분리하는 디자인 패턴입니다. 이렇게 하면 각 모델을 독립적으로 최적화할 수 있으며 애플리케이션의 성능, 확장성 및 보안을 향상시킬 수 있습니다.
컨텍스트 및 문제점
기존 아키텍처에서 단일 데이터 모델은 읽기 및 쓰기 작업 모두에 사용되는 경우가 많습니다. 이 방법은 간단하며 기본 CRUD 작업에 적합합니다(그림 1참조).
기존 CRUD 아키텍처를 보여 주는
그림 1. 기존 CRUD 아키텍처입니다.
그러나 애플리케이션이 증가함에 따라 단일 데이터 모델에서 읽기 및 쓰기 작업을 최적화하는 것이 점점 더 어려워집니다. 읽기 및 쓰기 작업에는 성능 및 크기 조정 요구 사항이 서로 다른 경우가 많습니다. 기존 CRUD 아키텍처는 이 비대칭을 고려하지 않습니다. 다음과 같은 몇 가지 문제가 발생합니다.
데이터 불일치: 데이터의 읽기 및 쓰기 표현은 종종 다릅니다. 업데이트 중에 필요한 일부 필드는 읽기 중에 필요하지 않을 수 있습니다.
잠금 경합: 동일한 데이터 집합에서 병렬 작업으로 인해 잠금 경합이 발생할 수 있습니다.
성능 문제: 기존의 접근 방식은 데이터 저장소 및 데이터 액세스 계층의 로드 및 정보를 검색하는 데 필요한 쿼리의 복잡성으로 인해 성능에 부정적인 영향을 미칠 수 있습니다.
보안 문제: 엔터티에 읽기 및 쓰기 작업이 적용되는 경우 보안 관리가 어려워집니다. 이 중첩은 의도하지 않은 컨텍스트에서 데이터를 노출할 수 있습니다.
이러한 책임을 결합하면 너무 많은 작업을 시도하는 지나치게 복잡한 모델이 발생할 수 있습니다.
해결 방법
CQRS 패턴을 사용하여 쓰기 작업(명령)을 읽기 작업(쿼리)과 구분합니다. 명령은 데이터 업데이트를 담당합니다. 쿼리는 데이터 검색을 담당합니다.
명령을 이해합니다. 명령은 하위 수준 데이터 업데이트가 아닌 특정 비즈니스 작업을 나타내야 합니다. 예를 들어 호텔 예약 앱에서 "ReservationStatus를 예약됨으로 설정" 대신 "호텔 객실 예약"을 사용합니다. 이 방법은 사용자 작업의 의도를 더 잘 반영하고 비즈니스 프로세스와 명령을 정렬합니다. 명령이 성공적으로 수행되도록 하려면 사용자 상호 작용 흐름, 서버 쪽 논리를 구체화하고 비동기 처리를 고려해야 할 수 있습니다.
구체화 영역 | 추천 |
---|---|
클라이언트 쪽 유효성 검사 | 명백한 오류를 방지하기 위해 명령을 보내기 전에 특정 조건의 유효성을 검사합니다. 예를 들어 사용할 수 있는 회의실이 없는 경우 "책" 단추를 사용하지 않도록 설정하고 예약이 불가능한 이유를 설명하는 명확한 사용자 친화적인 메시지를 UI에 제공합니다. 이 설정은 불필요한 서버 요청을 줄이고 사용자에게 즉각적인 피드백을 제공하여 환경을 향상시킵니다. |
서버 쪽 논리 | 에지 사례 및 실패를 정상적으로 처리하도록 비즈니스 논리를 개선합니다. 예를 들어 경합 상태(사용 가능한 마지막 회의실을 예약하려는 여러 사용자)를 해결하려면 대기자 목록에 사용자를 추가하거나 대체 옵션을 제안하는 것이 좋습니다. |
비동기 처리 | 명령을 동기적으로 처리하는 대신 큐에 배치하여 비동기적으로 |
쿼리를 이해합니다. 쿼리는 데이터를 변경하지 않습니다. 대신 도메인 논리 없이 필요한 데이터를 편리한 형식으로 제공하는 DDO(데이터 전송 개체)를 반환합니다. 이러한 명확한 문제 분리는 시스템의 설계 및 구현을 간소화합니다.
읽기 및 쓰기 모델 분리 이해
읽기 모델을 쓰기 모델과 분리하면 데이터 쓰기 및 읽기에 대한 고유한 문제를 해결하여 시스템 디자인 및 구현이 간소화됩니다. 이렇게 분리하면 명확성, 확장성 및 성능이 향상되지만 몇 가지 단점이 있습니다. 예를 들어 O/RM 프레임워크와 같은 스캐폴딩 도구는 데이터베이스 스키마에서 CQRS 코드를 자동으로 생성할 수 없으므로 간격을 메우기 위한 사용자 지정 논리가 요구됩니다.
다음 섹션에서는 CQRS에서 읽기 및 쓰기 모델 분리를 구현하는 두 가지 주요 방법을 살펴봅니다. 각 접근 방식에는 동기화 및 일관성 관리와 같은 고유한 이점과 과제가 제공됩니다.
단일 데이터 저장소에서 모델 분리
이 방법은 읽기 및 쓰기 모델 모두 단일 기본 데이터베이스를 공유하지만 작업에 대한 고유한 논리를 유지하는 CQRS의 기본 수준을 나타냅니다. 이 전략은 별도의 문제를 정의하여 일반적인 사용 사례에 대한 확장성 및 성능의 이점을 제공하면서 단순성을 향상시킵니다. 기본 CQRS 아키텍처를 사용하면 공유 데이터 저장소를 사용하는 동안 읽기 모델에서 쓰기 모델을 구분할 수 있습니다(그림 2참조).
기본 CQRS 아키텍처를 보여 주는
그림 2. 단일 데이터 저장소를 사용하는 기본 CQRS 아키텍처입니다.
이 방법은 쓰기 및 읽기 문제를 처리하기 위한 고유한 모델을 정의하여 명확성, 성능 및 확장성을 향상시킵니다.
쓰기 모델: 데이터를 업데이트하거나 유지하는 명령을 처리하도록 설계되었습니다. 여기에는 유효성 검사, 도메인 논리가 포함되며 트랜잭션 무결성 및 비즈니스 프로세스에 최적화하여 데이터 일관성을 보장합니다.
읽기 모델: 데이터 검색을 위한 쿼리를 제공하도록 설계되었습니다. 프레젠테이션 계층에 최적화된 DTO(데이터 전송 개체) 또는 프로젝션을 생성하는 데 중점을 둡니다. 도메인 논리를 방지하여 쿼리 성능 및 응답성을 향상시킵니다.
별도의 데이터 저장소에서 모델의 물리적 분리
고급 CQRS 구현은 읽기 및 쓰기 모델에 고유한 데이터 저장소를 사용합니다. 읽기 및 쓰기 데이터 저장소를 분리하면 부하에 맞게 각각 크기를 조정할 수 있습니다. 또한 각 데이터 저장소에 대해 다른 스토리지 기술을 사용할 수 있습니다. 읽기 데이터 저장소에 문서 데이터베이스를 사용하고 쓰기 데이터 저장소에 관계형 데이터베이스를 사용할 수 있습니다(그림 3참조).
별도의 읽기 및 쓰기 데이터 저장소가 있는 CQRS 아키텍처를 보여 주는
그림 3. 별도의 읽기 및 쓰기 데이터 저장소가 있는 CQRS 아키텍처입니다.
별도의 데이터 저장소 동기화: 별도의 저장소를 사용하는 경우 둘 다 동기화 상태를 유지해야 합니다. 일반적인 패턴은 읽기 모델이 데이터를 새로 고치는 데 사용하는 데이터베이스를 업데이트할 때마다 쓰기 모델이 이벤트를 게시하도록 하는 것입니다. 이벤트 사용에 대한 자세한 내용은 이벤트 기반 아키텍처 스타일참조하세요. 그러나 일반적으로 메시지 브로커와 데이터베이스를 단일 분산 트랜잭션에 등록할 수는 없습니다. 따라서 데이터베이스를 업데이트하고 이벤트를 게시할 때 일관성을 보장하는 데 문제가 있을 수 있습니다. 자세한 내용은 멱등 메시지 처리참조하세요.
읽기 데이터 저장소: 읽기 데이터 저장소는 쿼리에 최적화된 자체 데이터 스키마를 사용할 수 있습니다. 예를 들어 복잡한 조인 또는 O/RM 매핑을 방지하기 위해 데이터의 구체화된 뷰 저장할 수 있습니다. 읽기 저장소는 쓰기 저장소의 읽기 전용 복제본이거나 다른 구조를 가질 수 있습니다. 여러 읽기 전용 복제본을 배포하면 특히 분산 시나리오에서 대기 시간을 줄이고 가용성을 높여 성능을 향상시킬 수 있습니다.
CQRS의 이점
독립적인 크기 조정 CQRS를 사용하면 읽기 및 쓰기 모델을 독립적으로 확장할 수 있으므로 잠금 경합을 최소화하고 로드 중인 시스템 성능을 향상시킬 수 있습니다.
최적화된 데이터 스키마. 읽기 작업은 쿼리에 최적화된 스키마를 사용할 수 있습니다. 쓰기 작업은 업데이트에 최적화된 스키마를 사용합니다.
보안. 읽기 및 쓰기를 구분하여 적절한 도메인 엔터티 또는 작업만 데이터에 대한 쓰기 작업을 수행할 수 있는 권한이 있는지 확인할 수 있습니다.
관심사의 분리. 읽기 및 쓰기 책임을 분할하면 더 깨끗하고 유지 관리하기 쉬운 모델이 생성됩니다. 쓰기 쪽은 일반적으로 복잡한 비즈니스 논리를 처리하지만 읽기 쪽은 단순하게 유지되고 쿼리 효율성에 초점을 맞출 수 있습니다.
단순한 쿼리 구체화된 뷰를 읽기 데이터베이스에 저장하는 경우 애플리케이션은 쿼리할 때 복잡한 조인을 방지할 수 있습니다.
구현 문제 및 고려 사항
이 패턴을 구현하는 몇 가지 과제는 다음과 같습니다.
복잡성
. CQRS의 핵심 개념은 간단하지만, 특히 이벤트 소싱 패턴결합할 때 애플리케이션 디자인에 상당한 복잡성을 도입할 수 있습니다. 메시징 챌린지는. 메시징은 CQRS에 대한 요구 사항은 아니지만 명령을 처리하고 업데이트 이벤트를 게시하는 데 사용하는 경우가 많습니다. 메시징이 관련된 경우 시스템은 메시지 오류, 중복 및 재시도와 같은 잠재적인 문제를 고려해야 합니다. 다양한 우선 순위가 있는 명령을 처리하는 전략은 우선 순위 큐 지침을 참조하세요.
결과적 일관성 읽기 및 쓰기 데이터베이스가 분리되면 읽기 데이터에 최신 변경 내용이 즉시 반영되지 않아 부실 데이터가 발생할 수 있습니다. 쓰기 모델 저장소의 변경 내용으로 읽기 모델 저장소가 up-to유지되도록 하는 것은 어려울 수 있습니다. 또한 사용자가 부실 데이터에 대해 동작하는 시나리오를 검색하고 처리하려면 신중하게 고려해야 합니다.
CQRS 패턴을 사용하는 경우
CQRS 패턴은 데이터 수정(명령)과 데이터 쿼리(읽기) 간에 명확한 구분이 필요한 시나리오에서 유용합니다. 다음과 같은 상황에서 CQRS를 사용하는 것이 좋습니다.
공동 작업 도메인: 여러 사용자가 동시에 동일한 데이터에 액세스하고 수정하는 환경에서 CQRS는 병합 충돌을 줄이는 데 도움이 됩니다. 명령에는 충돌을 방지하기에 충분한 세분성이 포함될 수 있으며, 시스템은 명령 논리 내에서 발생하는 모든 문제를 해결할 수 있습니다.
작업 기반 사용자 인터페이스: 일련의 단계 또는 복잡한 도메인 모델을 사용하여 복잡한 프로세스를 통해 사용자를 안내하는 애플리케이션은 CQRS의 이점을 활용합니다.
쓰기 모델에는 비즈니스 논리, 입력 유효성 검사 및 비즈니스 유효성 검사가 포함된 전체 명령 처리 스택이 있습니다. 쓰기 모델은 도메인 기반 디자인 용어의 집계 알고 있는 데이터 변경에 대한 단일 단위로 연결된 개체 집합을 처리할 수 있습니다. 또한 쓰기 모델은 이러한 개체가 항상 일관된 상태인지 확인할 수 있습니다.
읽기 모델에는 비즈니스 논리 또는 유효성 검사 스택이 없습니다. 뷰 모델에서 사용할 DTO를 반환합니다. 결과적으로 읽기 모델과 쓰기 모델의 일관성이 유지됩니다.
성능 튜닝: 특히 읽기 수가 쓰기 수보다 큰 경우 데이터 읽기 성능과 별도로 데이터 읽기 성능을 미세 조정해야 하는 시스템은 CQRS의 이점을 활용합니다. 읽기 모델은 큰 쿼리 볼륨을 처리하도록 가로로 확장되며, 쓰기 모델은 더 적은 인스턴스에서 실행되어 병합 충돌을 최소화하고 일관성을 유지합니다.
개발 문제 분리: CQRS를 사용하면 팀이 독립적으로 작업할 수 있습니다. 한 팀은 쓰기 모델에서 복잡한 비즈니스 논리를 구현하는 데 중점을 두고 다른 팀은 읽기 모델 및 사용자 인터페이스 구성 요소를 개발합니다.
진화하는 시스템: CQRS는 시간이 지남에 따라 진화하는 시스템을 지원합니다. 기존 기능에 영향을 주지 않고 새 모델 버전, 비즈니스 규칙의 빈번한 변경 또는 기타 수정 사항을 수용합니다.
시스템 통합: 다른 하위 시스템과 통합되는 시스템, 특히 이벤트 소싱을 사용하는 시스템은 하위 시스템이 일시적으로 실패하더라도 계속 사용할 수 있습니다. CQRS는 오류를 격리하여 단일 구성 요소가 전체 시스템에 영향을 주지 않도록 합니다.
CQRS를 사용하지 않는 경우
다음과 같은 상황에서는 CQRS를 사용하지 않도록 합니다.
도메인 또는 비즈니스 규칙이 간단합니다.
간단한 CRUD 스타일 사용자 인터페이스 및 데이터 액세스 작업이 충분합니다.
워크로드 디자인
설계자는 워크로드 디자인에서 CQRS 패턴을 사용하여 azure Well-Architected Framework
핵심 요소 | 이 패턴으로 핵심 목표를 지원하는 방법 |
---|---|
성능 효율성은 크기 조정, 데이터, 코드의 최적화를 통해 워크로드가 수요를 효율적으로 충족하는 데 도움이 됩니다. | 높은 읽기-쓰기 워크로드에서 읽기 및 쓰기 작업을 분리하면 각 작업의 특정 목적에 대한 대상 성능 및 크기 조정 최적화가 가능합니다. - PE:05 크기 조정 및 분할 - PE:08 데이터 성능 |
디자인 결정과 마찬가지로 이 패턴을 통해 도입 가능한 다른 핵심 요소의 목표에 관한 절충을 고려합니다.
이벤트 소싱 및 CQRS 결합
CQRS의 일부 구현에서는 시스템 상태를 시간순 일련의 이벤트로 저장하는 이벤트 소싱 패턴통합합니다. 각 이벤트는 지정된 시간에 데이터에 대한 변경 내용을 캡처합니다. 현재 상태를 확인하기 위해 시스템은 이러한 이벤트를 순서대로 재생합니다. 이 조합에서 다음을 수행합니다.
이벤트 저장소는 쓰기 모델 단일 소스입니다.
읽기 모델 일반적으로 비정규화된 형식으로 이러한 이벤트에서 구체화된 뷰를 생성합니다. 이러한 뷰는 쿼리 및 표시 요구 사항에 맞게 구조를 조정하여 데이터 검색을 최적화합니다.
이벤트 소싱 및 CQRS 결합의 이점
쓰기 모델을 업데이트하는 동일한 이벤트는 읽기 모델에 대한 입력으로 사용될 수 있습니다. 그런 다음 읽기 모델은 현재 상태의 실시간 스냅샷을 작성할 수 있습니다. 이러한 스냅샷은 데이터의 효율적이고 미리 계산된 뷰를 제공하여 쿼리를 최적화합니다.
시스템은 현재 상태를 직접 저장하는 대신 이벤트 스트림을 쓰기 저장소로 사용합니다. 이 방법은 집계에 대한 업데이트 충돌을 줄이고 성능 및 확장성을 향상시킵니다. 시스템은 이러한 이벤트를 비동기적으로 처리하여 읽기 저장소에 대한 구체화된 뷰를 빌드하거나 업데이트할 수 있습니다.
이벤트 저장소는 단일 진리 원본 역할을 하므로 구체화된 뷰를 쉽게 다시 생성하거나 기록 이벤트를 재생하여 읽기 모델의 변경 내용에 적응할 수 있습니다. 기본적으로 구체화된 뷰는 빠르고 효율적인 쿼리에 최적화된 지속형 읽기 전용 캐시로 작동합니다.
이벤트 소싱 및 CQRS를 결합할 때 고려 사항
CQRS 패턴을 이벤트 소싱 패턴결합하기 전에 다음 고려 사항을 평가합니다.
최종 일관성: 쓰기 및 읽기 저장소는 별개이므로 읽기 저장소에 대한 업데이트가 이벤트 생성보다 지연되어 최종 일관성이 발생할 수 있습니다.
복잡성 증가: CQRS와 이벤트 소싱을 결합하려면 다른 디자인 접근 방식이 필요하므로 성공적인 구현을 더 어렵게 만들 수 있습니다. 이벤트를 생성, 처리 및 처리하고 읽기 모델에 대한 뷰를 어셈블하거나 업데이트하는 코드를 작성해야 합니다. 그러나 이벤트 소싱은 도메인 모델링을 간소화하고 모든 데이터 변경 내용의 기록 및 의도를 유지하여 새 보기를 쉽게 다시 작성하거나 만들 수 있습니다.
뷰 생성 성능: 읽기 모델에 대해 구체화된 뷰를 생성하면 상당한 시간과 리소스를 사용할 수 있습니다. 특정 엔터티 또는 컬렉션에 대한 이벤트를 재생하고 처리하여 데이터를 프로젝션하는 데도 동일하게 적용됩니다. 이 효과는 모든 관련 이벤트를 검사해야 하므로 오랜 기간 동안 값을 분석하거나 합계를 계산하는 경우 증가합니다. 정기적으로 데이터의 스냅샷을 구현합니다. 예를 들어 집계된 합계(특정 작업이 발생한 횟수) 또는 엔터티의 현재 상태에 대한 정기적인 스냅샷을 저장합니다. 스냅샷은 전체 이벤트 기록을 반복적으로 처리할 필요성을 줄여 성능을 향상시킵니다.
CQRS 패턴의 예제
다음 코드는 읽기 모델과 쓰기 모델에 다른 정의를 사용하는 CQRS 구현의 예제 중 일부를 보여 줍니다. 모델 인터페이스는 기본 데이터 저장소의 기능을 지정하지 않고 진화할 수 있으며 분리되기 있기 때문에 독립적으로 세밀하게 조정할 수 있습니다.
다음 코드는 읽기 모델 정의를 보여줍니다.
// Query interface
namespace ReadModel
{
public interface ProductsDao
{
ProductDisplay FindById(int productId);
ICollection<ProductDisplay> FindByName(string name);
ICollection<ProductInventory> FindOutOfStockProducts();
ICollection<ProductDisplay> FindRelatedProducts(int productId);
}
public class ProductDisplay
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal UnitPrice { get; set; }
public bool IsOutOfStock { get; set; }
public double UserRating { get; set; }
}
public class ProductInventory
{
public int Id { get; set; }
public string Name { get; set; }
public int CurrentStock { get; set; }
}
}
시스템에서 사용자가 제품을 평가하는 것을 허용합니다. 애플리케이션 코드는 다음 코드에 제시된 RateProduct
명령을 사용하여 제품을 평가합니다.
public interface ICommand
{
Guid Id { get; }
}
public class RateProduct : ICommand
{
public RateProduct()
{
this.Id = Guid.NewGuid();
}
public Guid Id { get; set; }
public int ProductId { get; set; }
public int Rating { get; set; }
public int UserId {get; set; }
}
시스템이 ProductsCommandHandler
클래스를 사용하여 애플리케이션이 전송한 명령을 처리합니다. 일반적으로 클라이언트는 큐와 같은 메시징 시스템을 통해 명령을 도메인에 보냅니다. 명령 처리기는 이러한 명령을 수락하고 도메인 인터페이스의 메서드를 호출합니다. 요청이 충돌할 가능성이 줄어들도록 세분화된 각 명령이 디자인됩니다. 다음 코드는 ProductsCommandHandler
클래스의 개요를 보여 줍니다.
public class ProductsCommandHandler :
ICommandHandler<AddNewProduct>,
ICommandHandler<RateProduct>,
ICommandHandler<AddToInventory>,
ICommandHandler<ConfirmItemShipped>,
ICommandHandler<UpdateStockFromInventoryRecount>
{
private readonly IRepository<Product> repository;
public ProductsCommandHandler (IRepository<Product> repository)
{
this.repository = repository;
}
void Handle (AddNewProduct command)
{
...
}
void Handle (RateProduct command)
{
var product = repository.Find(command.ProductId);
if (product != null)
{
product.RateProduct(command.UserId, command.Rating);
repository.Save(product);
}
}
void Handle (AddToInventory command)
{
...
}
void Handle (ConfirmItemsShipped command)
{
...
}
void Handle (UpdateStockFromInventoryRecount command)
{
...
}
}
다음 단계
이 패턴을 구현할 때 유용한 패턴 및 지침은 다음과 같습니다.
- 수평, 수직 및 기능별 데이터 분할 데이터를 개별적으로 관리 및 액세스할 수 있는 파티션으로 분할하여 확장성을 개선하고 경합을 줄이며 성능을 최적화하는 모범 사례를 설명합니다.