백엔드 프레임워크에서 CQRS 탐색: 도입할 때와 피해야 할 때
Olivia Novak
Dev Intern · Leapcell

소개
끊임없이 진화하는 백엔드 개발 환경에서 아키텍트와 엔지니어들은 확장성, 유지보수성 및 성능을 향상시키는 패턴과 관행을 끊임없이 모색합니다. 특히 복잡한 엔터프라이즈 시스템에서 상당한 주목을 받은 패턴 중 하나는 명령 쿼리 책임 분리(CQRS)입니다. 이 아키텍처 접근 방식은 기본적으로 데이터를 읽는 방식과 수정하는 방식 사이의 근본적인 차이를 인정합니다. 겉보기에는 간단해 보이지만 CQRS는 강력한 이점을 발휘할 수 있지만 상당한 복잡성을 야기하는 패러다임 전환을 도입합니다. CQRS를 언제, 왜 채택해야 하는지 이해하는 것뿐만 아니라 잠재적인 함정을 인식하는 것은 강력하고 효율적인 백엔드 시스템 구축에 중요합니다. 이 글에서는 백엔드 프레임워크 내에서 CQRS의 실질적인 적용을 탐구하며, 원칙, 구현 세부 정보 및 중요한 의사 결정 지점을 안내합니다.
기본 개념 살펴보기
"언제, 왜"로 본격적으로 들어가기 전에 CQRS를 둘러싼 핵심 개념에 대한 명확한 이해를 확립해 보겠습니다.
CQRS란 무엇인가?
CQRS 또는 명령 쿼리 책임 분리는 데이터를 읽는 작업(쿼리)과 데이터를 업데이트하는 작업(명령)을 분리하는 아키텍처 패턴입니다. 전통적인 CRUD(Create, Read, Update, Delete) 시스템에서는 읽기와 쓰기 모두에 동일한 데이터 모델과 종종 동일한 서비스 집합이 사용됩니다. CQRS는 이 종속성을 깨뜨려 각기 다른 최적화 및 독립적인 처리를 가능하게 합니다.
명령과 쿼리
- 명령: 시스템의 상태를 변경하려는 의도입니다. 명령은 명령형이며 명령형으로 명명됩니다(예:
CreateOrder
,UpdateProductPrice
,DeactivateUser
). 일반적으로 void 또는 간단한 승인(성공/실패 표시기 또는 생성된 엔터티의 ID)을 반환합니다. 명령은 클라이언트와 즉각적인 지속 작업 간의 결합을 해제하기 위해 종종 비동기적으로 처리됩니다. - 쿼리: 데이터를 요청하며 시스템의 상태를 수정하지 않습니다. 쿼리는 종종 선언형으로 표현됩니다(예:
GetProductDetails
,ListActiveUsers
,FindOrdersByCustomer
). 소비자를 위해 특별히 맞춤 설정된 DTO(Data Transfer Object) 형식으로 데이터를 반환하는 경우가 많습니다.
이벤트 소싱
CQRS에서 반드시 요구되는 것은 아니지만, 종종 CQRS와 함께 사용됩니다. 이벤트 소싱은 애플리케이션 상태의 모든 변경 사항을 불변 이벤트 시퀀스로 캡처하도록 보장합니다. 현재 상태를 저장하는 대신 이벤트 소싱 시스템은 언제든지 상태를 재구성하기 위해 재생할 수 있는 일련의 이벤트를 저장합니다. 이는 감사 추적을 제공하고, 강력한 분석을 가능하게 하며, 강력한 복구 메커니즘을 제공합니다.
심층 탐색: 원칙, 구현 및 실질적인 예
CQRS의 핵심 아이디어는 간단하지만 프로젝트의 요구 사항에 따라 구현이 크게 달라질 수 있습니다.
CQRS 작동 방식
일반적인 CQRS 시스템은 다음과 같습니다.
- 명령 측(쓰기 모델):
- 클라이언트로부터 명령을 받습니다.
- 명령은 비즈니스 로직을 포함하는 명령 핸들러에 의해 처리됩니다.
- 명령 핸들러는 쓰기 모델 데이터 저장소(종종 전통적인 데이터베이스 또는 이벤트 저장소)에서 집계(단일 단위로 취급되는 도메인 객체의 클러스터)를 로드합니다.
- 성공적인 비즈니스 규칙 실행 후 도메인 이벤트가 생성됩니다.
- 이러한 이벤트는 (예: 이벤트 저장소에) 지속되고 메시지 브로커에 게시됩니다.
- 쿼리 측(읽기 모델):
- 명령 측에서 게시한 이벤트를 구독합니다.
- 이벤트 핸들러는 이러한 이벤트를 처리하여 읽기 모델 데이터 저장소를 업데이트합니다.
- 읽기 모델은 쿼링을 위해 순수하게 최적화되며, 잠재적으로 다른 데이터 저장소를 사용합니다(예: 복잡한 조인을 위한 관계형 데이터베이스, 유연한 스키마를 위한 문서 데이터베이스, 전체 텍스트 검색을 위한 검색 엔진).
- 클라이언트는 읽기 모델에 직접 쿼리합니다.
예시
전자 상거래 플랫폼을 고려해 봅시다.
CQRS 없이(전통적인 접근 방식):
# 모델 class Product(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) price = db.Column(db.Float) stock = db.Column(db.Integer) # 서비스 계층 def update_product_stock(product_id, quantity_change): product = Product.query.get(product_id) if product: product.stock += quantity_change db.session.commit() return True return False def get_product_details(product_id): product = Product.query.get(product_id) if product: return {'id': product.id, 'name': product.name, 'price': product.price, 'stock': product.stock} return None # 쓰기와 읽기 모두 동일한 Product 모델과 데이터베이스 스키마를 사용합니다.
CQRS 사용(단순화됨):
1. 명령 및 쿼리 정의:
# 명령 class UpdateProductStockCommand: def __init__(self, product_id: int, quantity_change: int): self.product_id = product_id self.quantity_change = quantity_change # 쿼리 class GetProductDetailsQuery: def __init__(self, product_id: int): self.product_id = product_id class ProductDetailsDto: # 읽기 모델에 최적화됨 def __init__(self, id: int, name: str, current_price: float, available_stock: int): self.id = id self.name = name self.current_price = current_price self.available_stock = available_stock
2. 명령 측(쓰기 모델):
# 핵심 비즈니스 로직 및 상태 변경을 나타냅니다. class ProductAggregate: def __init__(self, product_id, stock): self.id = product_id self.stock = stock self.events = [] def apply_stock_change(self, quantity_change): if self.stock + quantity_change < 0: raise ValueError("재고 부족") self.stock += quantity_change self.events.append(ProductStockUpdatedEvent(self.id, quantity_change, self.stock)) # 명령 핸들러 class UpdateProductStockCommandHandler: def __init__(self, event_store, product_repository): self.event_store = event_store # 데이터베이스 또는 전용 이벤트 저장소일 수 있습니다. self.product_repository = product_repository # 집계를 로드하기 위한 것입니다. def handle(self, command: UpdateProductStockCommand): # 이벤트 스트림 또는 스냅샷에서 집계 로드 # 단순화를 위해 여기서는 간단한 저장소를 가정합니다. product = self.product_repository.get_product_aggregate(command.product_id) if not product: raise ValueError("상품을 찾을 수 없습니다.") product.apply_stock_change(command.quantity_change) self.event_store.save_events(product.events) # 이벤트 지속 # 메시지 브로커에 이벤트 게시(예: Kafka, RabbitMQ) print(f"Product {command.product_id}에 대한 ProductStockUpdatedEvent가 게시되었습니다.") # 이벤트 class ProductStockUpdatedEvent: def __init__(self, product_id, quantity_change, new_stock): self.product_id = product_id self.quantity_change = quantity_change self.new_stock = new_stock
3. 쿼리 측(읽기 모델):
# 제품 세부 정보를 위한 별도의 비정규화된 읽기 모델 # 다른 데이터베이스 또는 읽기에 최적화된 다른 테이블일 수 있습니다. class ProductReadModel(db.Model): # 예: SQLAlchemy를 다시 사용하지만 개념적으로는 다릅니다. id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) current_price = db.Column(db.Float) available_stock = db.Column(db.Integer) # 이벤트 핸들러 (명령 측의 이벤트를 수신) class ProductStockUpdatedEventHandler: def handle(self, event: ProductStockUpdatedEvent): # 이벤트를 기반으로 읽기 모델 업데이트 product_dto = ProductReadModel.query.get(event.product_id) if product_dto: product_dto.available_stock = event.new_stock db.session.commit() print(f"Product {event.product_id}에 대한 읽기 모델이 업데이트되었습니다. 새 재고: {event.new_stock}") else: # 제품이 아직 읽기 모델에 존재하지 않는 경우 처리(예: 초기 생성 이벤트) pass # 쿼리 핸들러 class GetProductDetailsQueryHandler: def handle(self, query: GetProductDetailsQuery) -> ProductDetailsDto: product_dto = ProductReadModel.query.get(query.product_id) if product_dto: return ProductDetailsDto( id=product_dto.id, name=product_dto.name, current_price=product_dto.current_price, available_stock=product_dto.available_stock ) return None
# 가상 사용 흐름 # --- 명령이 들어옴 --- command_handler = UpdateProductStockCommandHandler(event_store, product_repository) command_handler.handle(UpdateProductStockCommand(product_id=123, quantity_change=-5)) # --- 이벤트가 비동기적으로 처리됨 --- event_handler = ProductStockUpdatedEventHandler() # event_handler.handle(event_from_message_broker) # 이것은 메시지 큐를 통해 발생합니다. # --- 쿼리가 들어옴 --- query_handler = GetProductDetailsQueryHandler() product_details = query_handler.handle(GetProductDetailsQuery(product_id=123)) if product_details: print(f"상품명: {product_details.name}, 사용 가능한 재고: {product_details.available_stock}")
이 예시는 단순화되었지만 분리를 보여줍니다. 명령 측은 비즈니스 로직과 상태 변경에 중점을 두고, 쿼리 측은 데이터를 효율적으로 제공하는 데 중점을 둡니다.
CQRS 도입 시기
CQRS는 만병통치약이 아니며 특정 시나리오에서 빛을 발합니다.
- 높은 성능 요구 사항(읽기 또는 쓰기): 읽기 및 쓰기 작업량이 크게 다르거나 별도의 확장 전략이 필요한 경우. 예를 들어, 초당 수백만 번의 읽기와 수천 번의 쓰기만 있는 경우 캐싱, 비정규화 또는 특수 데이터베이스를 사용하여 읽기 모델을 처리량에 맞게 최적화할 수 있습니다.
- 복잡한 도메인 및 비즈니스 로직(DDD 컨텍스트): 도메인 모델이 풍부하고 복잡한 경우, 특히 도메인 주도 설계(DDD) 컨텍스트에서. CQRS는 명령을 집계 루트와 정렬하고 이벤트 기반 아키텍처를 활성화하여 DDD와 자연스럽게 보완됩니다.
- 확장성 및 가용성 요구 사항: 읽기 측과 쓰기 측을 독립적으로 확장해야 하는 경우. 쓰기 모델의 일관성에 영향을 주지 않고 읽기 모델 서비스의 여러 인스턴스를 배포할 수 있습니다. 이는 가용성도 향상시킵니다.
- 보고 및 분석: 보고 요구 사항이 정규화된 쓰기 모델에서 생성하기 어렵거나 비효율적인 최적화되고 종종 비정규화된 데이터 보기를 필요로 하는 경우. 읽기 모델은 분석 쿼리를 위해 특별히 설계될 수 있습니다.
- 이벤트 소싱 이점: 불변 감사 로그, 상태 재구성을 위한 이벤트 다시 재생 기능 또는 강력한 타임 트래블 디버깅 기능이 필요한 경우. 이벤트 소싱을 사용한 CQRS는 이러한 기능을 즉시 제공합니다.
- 최종 일관성 시스템: 어느 정도의 최종 일관성이 허용되거나 바람직한 경우. 읽기 모델은 비동기적으로 업데이트되므로 쿼리가 명령 실행 후 잠시 동안 약간 오래된 데이터를 반환할 수 있습니다.
CQRS를 피해야 할 때
CQRS를 채택해야 하는 강력한 이유가 있는 것처럼, 피해야 하는 마찬가지로 강력한 이유도 있습니다.
- 단순 CRUD 애플리케이션: 간단한 데이터 모델과 기본 생성, 읽기, 업데이트, 삭제 작업이 있는 애플리케이션의 경우 CQRS는 불필요한 복잡성을 야기합니다. 전통적인 계층형 아키텍처가 일반적으로 충분하며 개발 및 유지 관리가 훨씬 쉽습니다.
- 엄격한 일관성 요구 사항: 애플리케이션이 쓰기 후 즉시(즉, 사용자가 변경 직후에 변경 사항을 보아야 함) 읽기 일관성을 절대적으로 요구하는 경우, CQRS의 최종 일관성 모델이 문제가 될 수 있습니다. 이를 완화하는 기술(예: 명령 직후 쓰기 모델에서 읽기 제공)이 존재하지만, 더 많은 복잡성을 야기합니다.
- 소규모 팀 또는 제한된 리소스: CQRS는 더 높은 수준의 아키텍처 이해, 더 많은 인프라(메시지 브로커, 잠재적으로 여러 데이터베이스) 및 증가된 운영 오버헤드를 요구합니다. 소규모 팀의 경우 이점은 추가 부담을 훨씬 능가하지 못하는 경우가 많습니다.
- 가파른 학습 곡선: CQRS를 채택하는 것은 종종 이벤트 기반 아키텍처, 메시지 큐 및 잠재적으로 이벤트 소싱을 수용하는 것을 의미합니다. 이는 이러한 개념에 익숙하지 않은 개발자에게 상당한 학습 곡선을 도입합니다.
- 증가된 복잡성 및 상용구: 읽기 및 쓰기 모델을 분리하면 종종 더 많은 코드, 더 많은 데이터 동기화 로직 및 시스템의 더 많은 이동 부분이 발생합니다. 비동기적인 특성과 분산된 구성 요소로 인해 디버깅이 더 어려워질 수도 있습니다.
- 명확한 문제 부족: 단지 "멋진" 패턴이기 때문에 CQRS를 채택하지 마십시오. CQRS가 해결하도록 설계된 특정 문제(예: 읽기/쓰기 경합, 확장 병목 현상, 복잡한 도메인 로직)에 직면하지 않는다면, 더 간단한 솔루션이 더 낫습니다.
결론
CQRS는 복잡한 백엔드 시스템, 특히 높은 성능 요구 사항, 복잡한 비즈니스 로직 및 읽기/쓰기 작업의 독립적인 확장 필요성이 있는 시스템에 상당한 이점을 가져다줄 수 있는 강력한 아키텍처 패턴입니다. 그러나 채택에는 복잡성이 상당히 증가하므로 프로젝트의 특정 요구 사항, 팀 전문 지식 및 리소스 가용성을 신중하게 고려해야 합니다. 명확한 장점과 단점을 이해함으로써 정보에 입각한 의사 결정을 내릴 수 있으며, CQRS가 백엔드 프레임워크에서 불필요한 부담이 아닌 전략적 자산이 되도록 보장할 수 있습니다.