contents
CQRS(Command Query Responsibility Segregation)에 대해 알아보겠습니다.
CQRS는 시스템에서 상태를 변경하는 부분(쓰기)과 데이터를 조회하는 부분(읽기)을 분리하는 아키텍처 패턴입니다. 간단히 말해, 정보를 업데이트할 때 사용하는 모델과 정보를 읽을 때 사용하는 모델을 다르게 가져가는 것입니다.
처음에는 불필요하게 복잡하게 들릴 수 있지만, 정교한 애플리케이션에서 발생하는 중요한 문제들을 해결해 줍니다.
CQRS가 해결하려는 핵심 문제
대부분의 애플리케이션은 생성(Create), 읽기(Read), 업데이트(Update), 삭제(Delete) 즉, CRUD 작업을 위해 단일 통합 데이터 모델로 시작합니다.
전통적인 CRUD 모델:
전자상거래 시스템의 Product(상품) 객체를 상상해 보세요.
-
업데이트: 데이터베이스에서
Product를 불러와 가격을 변경하고(product.setPrice(19.99)) 다시 저장합니다. -
화면 표시: 똑같은
Product객체를 불러와 웹페이지에 이름, 가격, 재고 수량을 보여줍니다.
문제점:
애플리케이션이 성장함에 따라 이 단일 모델은 두 가지 상반된 방향으로 압력을 받게 됩니다.
-
쓰기 측면 (업데이트): 데이터 무결성을 보장하기 위해 모델에는 복잡한 비즈니스 규칙, 유효성 검사, 로직이 필요합니다. (예: "상품 가격은 음수가 될 수 없다", "고객의 장바구니에 담긴 상품은 단종시킬 수 없다.") 이 측면은 일관성에 최적화됩니다.
-
읽기 측면 (화면 표시): 빠르고 효율적인 조회를 위해 모델이 최적화되어야 합니다. 종종 여러 테이블의 데이터를 조인해서 보여줘야 합니다. (예: 상품의 카테고리명, 공급업체 정보, 최근 리뷰를 함께 표시) 이 측면은 성능과 편의성에 최적화됩니다.
하나의 모델로 이 두 가지 역할을 완벽하게 수행하려고 하면, 결국 어느 쪽도 제대로 해내지 못하는 복잡하고 지저분한 모델이 만들어집니다.
CQRS 솔루션: 깔끔한 책임 분리 ⚖️
CQRS는 간단하지만 강력한 해결책을 제안합니다: 읽기와 쓰기에 같은 모델을 사용하는 것을 멈추는 것입니다. 대신 두 개의 분리된 모델을 만듭니다.
-
명령 모델 (Command Model / 쓰기 모델): 애플리케이션의 상태를 변경하는 모든 요청을 처리합니다.
-
조회 모델 (Query Model / 읽기 모델): 상태 변경 없이 데이터를 가져오는 모든 요청을 처리합니다.
각 핵심 구성 요소를 자세히 살펴보겠습니다.
CQRS의 핵심 구성 요소
1. 명령 (Commands)
명령(Command) 은 시스템의 상태 변경 의도를 나타내는 메시지입니다. '무엇을 하라'는 지시죠.
-
작업 기반 (Task-Based): 명령은 수행할 비즈니스 작업에 기반하여 이름이 지어집니다. (예:
DeactivateProductCommand,AddProductToCartCommand,UpdateShippingAddressCommand) 항상 명령형 동사로 표현됩니다. -
데이터 운반: 작업을 수행하는 데 필요한 데이터를 담고 있습니다. (예:
AddProductToCartCommand는productId와quantity를 포함) -
데이터를 반환하지 않음: 명령 핸들러는 보통 데이터를 반환하지 않습니다. 작업 실행이 목적이며, 성공/실패 여부 정도만 알릴 뿐 업데이트된 객체를 돌려주지 않습니다.
예시 (의사 코드):
// 명령 객체
class RateHotelCommand {
constructor(hotelId, customerId, rating) {
this.hotelId = hotelId;
this.customerId = customerId;
this.rating = rating;
}
}
// 명령 핸들러
class RateHotelHandler {
handle(command) {
// 1. 호텔의 '쓰기 모델'을 로드
let hotel = hotelWriteRepository.findById(command.hotelId);
// 2. 비즈니스 로직 실행
hotel.addRating(command.customerId, command.rating);
// 3. 변경 사항 저장
hotelWriteRepository.save(hotel);
}
}
2. 쓰기 모델 (도메인/명령 모델)
이 모델은 명령을 처리하는 책임을 집니다. 비즈니스 규칙의 수호자 역할을 하죠.
-
풍부한 로직: 시스템의 무결성을 보호하기 위한 모든 유효성 검사와 비즈니스 로직을 포함합니다.
-
일관성에 최적화: 상태 변경을 트랜잭션으로 처리하고 시스템이 항상 유효한 상태를 유지하도록 설계됩니다.
-
표시용이 아님: 이 모델은 화면에 데이터를 표시하는 데 절대 사용되지 않습니다. 구조가 복잡하고 정규화되어 있으며, 실제 비즈니스 도메인을 반영합니다.
3. 조회 (Queries)
조회(Query) 는 데이터 요청을 나타내는 메시지입니다. 질문을 던지는 것과 같습니다.
-
질문: 특정 UI 화면을 위해 데이터를 가져오도록 설계됩니다.
-
데이터 반환: 조회 핸들러의 유일한 목적은 데이터를 가져와 반환하는 것입니다.
-
부수 효과 없음 (No Side Effects): 조회는 시스템의 상태를 절대 변경해서는 안 됩니다.
예시 (의사 코드):
// 조회 객체
class GetHotelReviewsQuery {
constructor(hotelId) {
this.hotelId = hotelId;
}
}
// 조회 핸들러
class GetHotelReviewsHandler {
handle(query) {
// 1. 특화된 '읽기 모델'을 직접 조회
let reviews = hotelReadRepository.findReviewsByHotelId(query.hotelId);
// 2. 데이터 전송 객체(DTO)를 반환
return reviews;
}
}
4. 읽기 모델 (조회/프로젝션 모델)
이 모델은 조회를 효율적으로 처리하기 위해 특별히 만들어집니다.
-
읽기에 최적화: 데이터가 UI에 필요한 형태로 정확하게 맞춰져 있습니다. 여러 테이블의 데이터가 미리 조인되어 하나의 평평한 구조로 만들어지는 비정규화(denormalized) 된 경우가 많습니다.
-
단순한 데이터 구조: 읽기 모델은 보통 비즈니스 로직이 없는 단순한 데이터 전송 객체(DTO)입니다.
-
다수의 모델: 애플리케이션의 각기 다른 화면이나 구성 요소를 위해 동일 데이터에 대한 여러 개의 다른 읽기 모델을 가질 수 있습니다. (예:
ProductSearchResult모델,ProductDetails모델,DashboardSummary모델)
두 모델은 어떻게 동기화될까요?
이것이 가장 중요한 부분입니다. 쓰기용 DB와 읽기용 DB가 다르다면, 읽기용 DB는 어떻게 업데이트될까요?
쓰기 모델은 명령을 성공적으로 처리한 후, 이벤트(event) 를 발행합니다 (예: ProductPriceChanged, NewProductAdded). 시스템의 다른 부분들은 이 이벤트를 구독하여 처리할 수 있습니다.
일반적인 흐름은 다음과 같습니다:
-
사용자 행동이 **명령(Command)**을 발생시킵니다.
-
명령 핸들러는 쓰기 모델을 사용하여 변경 사항을 검증하고 실행합니다.
-
쓰기 모델이 "쓰기 DB"에 저장됩니다.
-
변경 사항을 설명하는 이벤트(Event) 가 발행됩니다.
-
이벤트 핸들러가 이 이벤트를 수신하여 "읽기 DB"에 있는 관련 읽기 모델들을 업데이트합니다.
이 과정은 중요한 트레이드오프를 낳습니다: 최종적 일관성 (Eventual Consistency). 쓰기 작업이 완료된 후 읽기 모델이 업데이트되기까지 아주 짧은 지연(보통 밀리초 단위)이 발생합니다. 대부분의 애플리케이션에서는 이 정도 지연은 전혀 문제가 되지 않습니다.
CQRS의 장점 📈
-
확장성: 읽기 DB와 쓰기 DB를 독립적으로 확장할 수 있습니다. 애플리케이션에 쓰기보다 읽기가 100배 더 많다면(매우 흔한 경우), 쓰기 측은 그대로 둔 채 읽기 인프라만 확장할 수 있습니다(읽기 전용 복제본 추가, 다른 종류의 DB 사용 등).
-
성능:
-
읽기 속도가 매우 빠릅니다. 데이터가 조회에 최적화된 형태로 미리 가공되어 있어 조회 시점에 복잡한 조인이 필요 없습니다.
-
쓰기 또한 효율적입니다. 쓰기 모델이 조회 성능에 대한 걱정 없이 오직 상태 변경에만 집중하기 때문입니다.
-
-
유연성 및 단순성: 각 모델은 단일 책임을 가집니다. 쓰기 모델은 비즈니스 로직을 순수하게 표현하고, 읽기 모델은 단순하고 교체 가능한 데이터 컨테이너 역할을 합니다. 이는 각 모델의 로직을 더 쉽게 이해하고 유지보수하게 만듭니다.
-
최적화된 데이터 저장소: 각 작업에 가장 적합한 DB 기술을 사용할 수 있습니다. 예를 들어, 일관성을 위해 쓰기 측에는 관계형 DB(PostgreSQL 등)를, 강력하고 유연한 조회를 위해 읽기 측에는 문서 DB(Elasticsearch, MongoDB 등)를 사용할 수 있습니다.
단점 및 주의할 점 ⚠️
CQRS는 만병통치약이 아닙니다. 그 자체로 복잡성을 야기합니다.
-
복잡성 증가: 단순한 CRUD보다 더 복잡한 패턴입니다. 관리해야 할 구성 요소, 분리된 모델, 데이터 동기화 메커니즘이 더 많습니다.
-
최종적 일관성: 많은 경우에 허용되지만, 어떤 경우에는 문제가 될 수 있습니다. 내가 방금 쓴 내용을 즉시 100% 일관성 있게 읽어야 한다면, 이를 위한 별도의 처리 방법이 필요합니다.
-
개발 오버헤드: 다른 방식의 사고가 필요하며, 간단한 애플리케이션에는 과도한 설계(오버 엔지니어링)일 수 있습니다. 기본적인 CRUD 애플리케이션에는 CQRS를 사용하지 마세요.
CQRS는 언제 사용해야 할까?
애플리케이션이 다음 특징 중 하나 이상을 가질 때 CQRS 사용을 고려해 보세요.
-
복잡한 비즈니스 도메인: 데이터를 변경하는 규칙이 데이터를 표시하는 방식과 매우 다를 때.
-
고성능 요구사항: 읽기 또는 쓰기 처리량이 매우 높아 별도의 최적화와 확장이 필요할 때.
-
협업 환경: 여러 사용자가 동시에 동일한 데이터로 작업하며, 시스템이 복잡한 작업 기반의 동작을 관리해야 할 때.
이벤트 소싱(Event Sourcing)과의 관계
CQRS는 이벤트 소싱(ES) 과 자주 함께 언급되지만, 둘은 별개의 패턴입니다. 다만, 서로 아주 잘 어울릴 뿐입니다.
-
CQRS: 읽기와 쓰기를 분리합니다.
-
이벤트 소싱: 객체의 _현재 상태_를 저장하는 대신, 그 객체에 일어난 _이벤트들의 전체 기록_을 저장합니다. 현재 상태는 이벤트를 처음부터 다시 재생하여 계산해냅니다.
서로를 보완하는 방식: 이벤트 소싱의 이벤트 로그는 CQRS 아키텍처에서 읽기 모델을 업데이트하는 완벽한 메커니즘이 됩니다. 명령이 처리되면 새로운 이벤트가 생성되고, 이 이벤트들은 이벤트 저장소(쓰기 측)에 저장되는 동시에 읽기 모델(프로젝션)을 업데이트하기 위해 발행됩니다.
요약하자면, CQRS는 확장성 있고, 고성능이며, 유지보수하기 좋은 애플리케이션을 구축하기 위한 강력한 패턴입니다. 하지만 그 복잡성 때문에 정말로 필요한 문제에 신중하게 적용해야 합니다.
references