brunch

You can make anything
by writing

C.S.Lewis

by 에디의 기술블로그 Jul 10. 2020

웹서비스 백엔드 애플리케이션 아키텍처(2)-CQRS

CQRS 패턴으로, 복잡한 도메인 모델 극복하기 

CQRS 패턴은 Command and Query Responsibility Segregation (CQRS) pattern 의 약자이다. 명령을 처리하는 책임과 조회를 처리하는 책임을 분리하는 것이 CQRS의 핵심이다. 이 글에서는, 최근 필자가 프로젝트에서의 경험을 바탕으로 CQRS에 대해서 간략하게 소개하겠다. 


해당 글은, "웹서비스 백엔드 애플리케이션 아키텍처"라는 카테고리의 두 번째 글이다. 작년에 첫번 째 글을 작성한 이후로 1년 만에 작성하는 두번째 글이다. 다음 세번째 포스팅은 캐싱이라는 주제로 작성할 예정인데, 아마도 내년 이후가 될 것이다...


목차

웹서비스 백엔드 애플리케이션 아키텍처(1) - 클린아키텍처

웹서비스 백엔드 애플리케이션 아키텍처(2) - CQRS (현재 글)

웹서비스 백엔드 애플리케이션 아키텍처(3) - 캐싱(미정)



글을 시작하기 전에

한달 만에 작성하는 기술 블로그 포스팅이다. 최근에 글 쓸 시간이 거의 없어서 사실상 기술 블로그를 방치하고 있었다. 연차 휴가로 집에서 쉬면서 가벼운 마음으로 글을 시작하겠다.

(글을 쓰다 보니깐 무거운 마음으로 바뀌었다...)


최근에 필자가 아키텍트로 참여한 프로젝트에서 CQRS 패턴을 도입해서 시스템을 구축하였다. 사실, 처음부터 CQRS 를 생각하고 설계한 아키텍처는 아니었다. 도메인 요구사항, 개발조직 여건 등 여러가지 상황을 고민하면서 시스템을 설계하였는데, 자연스럽게 CQRS 패턴과 유사한 아키텍처로 시스템을 구축하게 되었다이 글은 실무 사례 경험을 바탕으로 작성한 글이지만, 회사 비즈니스, 소스코드 등 회사와 관련된 모든 내용은 (회사의 허락을 받지 않은 상황에서) 전혀 공개할 수 없다. 해당 글의 샘플 사례는 필자가 실무에서 경험해본적이 없는 "호텔 예약 시스템" 이라는 다소 생소한 주제를 선택하였다. 이 글은 Microsoft 의 자료를 적극적으로 참고하였고, (필자의 개인적인 생각이 많이 포함되었지만)기본적인 핵심 개념은 Microsoft 에서 소개하는 내용과 유사하다. 링크를 참고하길 바란다.  

https://docs.microsoft.com/ko-kr/azure/architecture/patterns/cqrs


해당 글을 재밌게 읽기 위해서 미리 알고 있으면 좋은 기술은 아래와 같다.  


- 자바 & 스프링부트

- JPA

- DDD

- NoSQL, MongoDB  


글을 작성하기 시작한지 얼마 안되었는데, 글 내용이 벌써부터 너무 재미 없다.

글을 쓰는것이 너무 귀찮지만... 차분한 마음으로 글을 작성해보겠다...  


복잡한 도메인 모델

간단한 호텔 예약 시스템을 구축해보자.  


호텔 예약 시스템

필자가 대충 생각한 스키마는 아래와 같다. 아주 허접하다.  


(참고로, 해당 스키마 그대로 진행하지 않았다.) 


Domain Model, Entity, Aggregate

호텔 예약 시스템의 도메인 모델은 아래와 같다.


- 호텔 정보 (객실, 호텔 관리)

- 회원

- 예약

- 결제


도메인 모델에 맞게 클래스 설계를 해보자. 

CustomerId, RoomId 와 같은 클래스는 ID를 이용한 애그리거트 참조를 위해 설계하였다. 해당 내용은 조금 이따가 다시 설명하겠다. 전체 도메인 모델을 애그리거트로 분리하면 아래와 같다. 


객실 애그리거트의 루트는 Room 모델이다. Room에서는 Hotel, RoomType 를 객체 참조한다.  

Room 엔티티

Room 도메인 모델에서는 Hotel, RoomType 를 객체 참조하기 때문에, 아래와 같이 Hotel, RoomType 데이터를 아주 쉽게 가져올 수 있다. 

Hotel, RoomType 엔티티는 아래와 같다. 

Hotel 엔티티
RoomType 엔티티

고객 애그리거트의 루트인 Customer 엔티티 모델은 아래와 같다.

예약 애그리거트의 Reservation 엔티티는 아래와 같다. 

참고로, 객실 애그리거트의 Room 모델에서는 Hotel, RoomType 를 필드 기반의 객체를 참조했었다. 하지만, Reservation 에서 Room, Customer에 대한 참조는 ID 참조 방식으로 매핑한다. 그래서, 필자는 RoomId, CustomerId 라는 클래스를 별도로 정의하였다. 

RoomId


CustomerId

필자의 샘플 사례는 JPA 를 사용하는데 (위와 같이 ID를 이용한 참조 방식보다는) 필드를 이용해서 직접 객체를 참조하면 훨씬 편리할 것이다. 아래와 같이 구현할 수는 있다.(결론적으로는 추천하지 않음)

아주 쉽게 객체를 참조해서 데이터를 조회할 수 있다. 

하지만, 애그리거트 사이의 필드 객체 참조는 아래와 같은 문제를 발생시킨다. 


- 편한 탐색 오용

- 성능 이슈

- 확장이 어려움


필자의 샘플 코드에서는 애그리거트 사이에서는 필드 객체 참조를 하지 않고, ID 를 이용한 참조 방식으로 구현하였다. 좀더 자세한 내용은 최범균님의 "DDD Start! 도메인 주도 설계 구현과 핵심 개념 익히기" 87page ~ 91page 를 참고하길 바란다. 


필자는 JPA, DDD 초보이다. 잘못된 내용이 있다면 꼭 알려주길 바란다. 



복잡한 도메인 모델

사용자에게 예약 정보를 제공하는 기능이 있다고 가정해보자. 예약 정보에는 Reservation 외에도 Room, Hotel, Customer 등의 데이터를 조인해서 사용자에게 제공해야 한다. 즉, 예약 정보에는 고객의 이름 정보가 포함되며, 어떤 객실인지에 대한 정보도 포함한다. Reservation 테이블에는 모든 정보를 저장하지 않는다. Room, Hotel, Customer 등을 연관 매핑해서 데이터를 조인해서 가져오거나, 또는 Reservation의 RoomId, CustomerId 를 사용해서 Room, Customer 데이터를 별도로 가져오는 방식을 사용해야 한다. 필자의 글에서는 Reservation, Room, Customer 의 데이터를 ReservationDTO에 매핑해서 사용자에게 제공한다. 

최종적 데이터를 ReservationDTO 에 포함해서 사용자에게 제공하는데, 해당 시스템은 데이터를 저장하는 저장소로 RDBMS(MySQL)를 사용한다. Entity 사이의 연관관계는 복잡하게 연결되어 있다. 


사용자에게 예약 정보를 제공하기 위해서, 거의 모든 Entity 를 조회해야 하는 상황이다. 


ReservationDTO 는 아래와 같다. 

아래와 같이 RestAPI 를 제공할 것이다.


해당 샘플은 초보 개발자를 위해서, 아주 쉽고 간략하게 작성한 코드이다.

(사실, 필자의 개발실력이 이정도 수준밖에 되지 않는다...) 


실무에서는 훨씬 더 복잡한 도메인 모델을 설계하게 될 것이다. 


복잡한 도메인 모델에서의 읽기, 쓰기


복잡한 도메인 모델에서는 아래와 같은 문제가 발생한다. 


- 작업의 일부로 필요하지 않더라도 올바르게 업데이트해야 하는 추가 열이나 속성과 같이 데이터의 읽기 및 쓰기 표현 간에 불일치가 있는 경우가 많다.

- 동일한 데이터 집합에서 작업을 병렬로 수행할 때 데이터 경합이 발생할 수 있다.

- 기존의 접근 방식은 데이터 저장소 및 데이터 액세스 계층에 대한 로드 및 정보를 검색하는 데 필요한 쿼리의 복잡성으로 인해 성능에 부정적인 영향을 미칠 수 있다.

- 각 엔티티는 읽기 및 쓰기 작업의 대상이 되므로 잘못된 컨텍스트에서 데이터를 노출할 수 있으므로 보안 및 사용 권한 관리가 복잡해질 수 있다. 


MSDN 을 참고하였다.

https://docs.microsoft.com/ko-kr/azure/architecture/patterns/cqrs


도메인 모델은 지속적으로 복잡해질 것이다.

기획자의 요구사항은 끊임 없이 변경될 것이며, 도메인 모델은 점점 복잡해질 것이다. 복잡한 도메인 모델을 조회하기 위해서는 수많은 객체 참조를 발생시킨다. 유지보수가 어렵고, 성능에도 악영향을 끼칠 것이다. CQRS는 위와 같은 상황에서 적용이 가능하다. 


CQRS의 핵심 개념은 읽기, 명령 로직을 서로 다른 모델로 구분하는 것이다. 

이제 본격적으로 CQRS 에 대해서 알아보자. 


명령(Command), 읽기(조회,Query) 분리

CQRS 에 대해서 본격적으로 설명한다. 사실은, 기본 개념은 아주 심플하다.


명령(Command)과 읽기(조회, Query) 를 분리하면 된다. 


기본 개념은 아주 심플하지만, 실제로 시스템을 구축하다보면 많은 고민을 하게 될 것이다.


[AS-IS] 기존 방식 : 명령, 읽기 기능에 동일한 데이터 모델을 사용

CQRS 를 적용하지 않은 시스템에서는, 명령(Command)과 읽기(조회)를 위한 데이터 모델을 같이 사용한다. RDBMS에 데이터가 업데이트되고해당 RDBMS에서 데이터를 조회해서 사용하면 된다. 

복잡하지 않은 시스템에서는 해당 아키텍처가 특별히 문제가 되지 않는다. 하지만, 도메인 모델이 복잡해지는 경우에는 문제가 발생한다. 


[TO-BE] CQRS 패턴 : 명령(Command), 읽기(Query)기능에 서로 다른 데이터 모델을 사용, 분리

명령(Command)과 읽기(조회,Query)를 위한 데이터 모델을 분리해보자. 아래와 같이 RDMBS 에 별도의 읽기 모델을 구축하였다. 그리고, 읽기(조회,Query)를 위한 DTO 를 별도로 설계한다. 필요한 데이터만 조회할 수 있도록 DTO 객체를 잘 설계해야한다. 

경우에 따라서는, 별도의 읽기(조회,Query) 저장소를 구축할 수 있다. 아래와 같이 명령(Command) 에서 사용하는 저장소와, 읽기(조회,Query)를 위한 저장소를 별도로 구축할 수 있다. 


CQRS 는 정답이 없다고 생각한다. 요구사항 및 팀 상황에 맞게 아키텍처를 구축하길 바란다. 읽기(조회,Query) 모델을 위해서 사용하는 저장소는 MongoDB 를 사용하였다. (항상 정답은 아니지만)CQRS 패턴에서의 읽기(조회,Query) 모델은 NoSQL 기반의 저장소가 적합한 경우가 많다.  


읽기(조회,Query) 모델을 위한 NoSQL

읽기(조회,Query)를 위한 별도의 NoSQL 저장소인 MongoDB 를 사용하기 위해서, 필자의 샘플 코드에서는 Spring Data MongoDB 를 사용한다. @Document 어노테이션을 선언한다. 

Repository 를 정의한다. 

업데이트 요청이 있을 때, MySQL 의 데이터를 취합해서 MongoDB에 문서(Document)기반으로 저장한다. 

몽고DB 에 저장된 데이터는 아래와 같을 것이다. 

명령(Command)모델의 데이터를 읽기(조회,Query)모델로 업데이트 요청을 보내는 방법으로는, 일반적으로 메시지 이벤트 방식을 주로 사용한다. 만약, 업데이트 요청이 많은 경우에는 메시지 큐잉 시스템을 별도로 구축하는 것이 좋다. 업데이트 요청이 많지 않다면 굳이 메시지 큐잉을 적용하지 않아도 된다. 필자의 샘플 코드에서는 메시징 큐잉을 적용하진 않았고, 스프링 이벤트 리스너로 이벤트를 전송한다. 아래 샘플 코드는 고객 정보 변경 이벤트가 발생하면, 예약 정보를 업데이트 하는 이벤트 리스너를 구현한 것이다. 


필자의 샘플 코드는 빠른 시간에 급하게 작성한 코드이며, 눈으로 보고 이해하면 된다. 코드가 그지같아서 이해가 안되어도 크게 신경쓰지 않아도 된다. 


각자 시스템 상황에 맞게 알아서 잘 구현하길 바란다.


읽기(조회,Query) 모델을 위한, 신규 API 서버 구축

읽기(조회,Query) 모델을 위한 새로운 API 서버를 구축한다. 읽기(조회,Query)를 위한 애플리케이션은 명령(Command) 애플리케이션과 다른 프로그래밍 언어로 구축해도 된다. 읽기(조회,Query) 모델 데이터가 저장된 MongoDB 에 접속해서 데이터를 꺼내서 사용할수 있다면, 어떤 언어를 사용해도 전혀 상관없읽기(조회,Query) 조회를 위한 애플리케이션으로 스프링 부트 기반으로 구축하였다. 상세한 샘플 코드는 생략한다. 


CQRS를 사용하면 좋은 점

CQRS 패턴의 장점에 대해서 알아보자. 필자의 개인적인 생각이 추가되었으니 혹시 이상한 내용이 있다면 꼭 제보해주기를 바란다. 


심플한 읽기 모델, 최적화된 데이터 스키마

복잡했던 명령(업데이트,Command) 도메인 모델에 비해서, 읽기(조회,Query) 모델은 매우 심플하고 간결하다. 읽기 모델에서는 쿼리에 최적화된 스키마를 사용할 수 있다. 명령 모델에서는 업데이트에 최적화 된 JPA 기술을 사용하였다. 명령 모델에는 모든 데이터가 전부 저장되어 있지만, 읽기 모델에서는 반드시 필요한 데이터만 저장한다. 해당 내용은, "구체화 된 뷰 패턴" 과 관련이 있다. 참고자료를 반드시 읽어보길 바란다. 

https://docs.microsoft.com/ko-kr/azure/architecture/patterns/materialized-view


개발/운영에 유연한 NoSQL

필자는 읽기(조회,Query)를 제공하는 저장소로 NoSQL 기반의 MongoDB를 선택하였다. 명령을 위한 저장소는 관계형 데이터베이스인 RDMBS가 적합하였지만, 읽기(조회,Query)를 위한 데이터베이스는 NoSQL 이 적합하다고 판단하였다. 결과적으로 NoSQL 을 도입한 것은 매우 유연한 개발을 가능하게 하였다. NoSQL 의 "스키마리스"한 장점은 개발 시 데이터의 필드를 추가하고 삭제하는것이 매우 유연하였으며, 명령과 읽기(조회,Query) 사이의 양측에서 개발할 때 서로 의존성이 높지 않게 개발을 진행할 수 있었다. 


읽기(조회,Query) 성능 개선

RDBMS 에 비해서 상대적으로 NoSQL 에 읽기(조회,Query)는 성능이 좋을 수 있다. 복잡한 Join 을 사용해서 RDBMS 에 SQL 을 실행하는 것보다는, NoSQL 에 저장된 문서화된 컬렉션을 조회하는 것이 성능이 좋을 가능성이 높다. 


물론, 항상 그렇지는 않다. NoSQL이라고 항상 성능이 좋은 것은 아니다. 


독립적으로 확장 가능한 데이터베이스

별도의 데이터베이스를 사용한다면, 독립적으로 데이터베이스를 확장할 수 있다. 읽기(조회,Query) 트래픽이 매우 높다면, NoSQL 인프라를 높은 트래픽을 수용할 수 있도록 확장해서 구축할 수 있다. 읽기(조회,Query) 저장소를 확장하는 것이, 명령(Command)을 위한 저장소에 영향을 주지 않는다. 


마이크로서비스 아키텍처의 가장 매력적인 장점이다. 



하지만, 

필자가 최근 프로젝트에서 CQRS 패턴의 시스템을 구축하면서 몇가지 어려운 점을 깨닫게 되었다. 



생각처럼 쉽지 않은 CQRS

시스템 아키텍처에는 항상 정답이 없다. 상황에 맞게 시스템을 구축하는 지혜와 노력이 필요하다. 


CQRS 패턴이 아무리 좋다고해도... 항상 정답이 될 수는 없다.



필자가 생각하는 CQRS 를 도입할 때 발생하는 수많은 과제 중, 

가장 어려운 주제는 아래와 같이 4가지 이다. 


- 최종 데이터 일관성

- 트랜잭션 범위 정의

- 시스템 복잡도 증가

- 메시징 패턴



최종 데이터 일관성

CQRS 를 도입하게 되었을 때 가장 이슈가 되는 내용이다. 명령(Command)에 의해서 저장된 데이터가 읽기(조회,Query) 모델의 데이터와 일치하지 않는다면, 수많은 혼란을 겪에 될 것이다. 필자가 최근에 CQRS 프로젝트를 진행하면서 "최종 데이터 일관성" 문제로 인해서 일부 서비스 장애를 경험하였다. 해결을 하기 위해서 이런저런 고민을 하고 있지만, 해결하기 쉽지 않은 이슈이다. 해당 내용에 대해서는 상세한 설명을 생략하겠다. 회사 비즈니스 관련 내용이라서 자세한 설명은 생략하겠다. 솔직히, 필자도 완벽한 정답을 모르겠다. 


트랜잭션 범위

역시 매우 어려운 주제이다. 해당 내용에 관련해서도 상세한 설명은 생략하겠다. 명령 모델의 데이터를 업데이트 할 때, 읽기(조회,Query) 모델에도 동일하게 데이터를 동기화 해야 한다. 해당 과정을 전체 트랜잭션으로 묶게 된다면, 시스템은 너무 복잡해질 것이다. 명령 트랜잭션과 읽기(조회,Query) 트랜잭션을 따로 분리하였는데, 사실 해당 이슈를 해결하는 명확한 방법을 아직 찾지 못했다. 필자가 아직 내공이 많이 부족한 것 같다. 


시스템 복잡도 증가

명령과 읽기(조회,Query)를 분리한다는 CQRS 의 기본 개념은 매우 심플하며, CQRS는 복잡한 도메인 모델을 극복할 수 있는 매우 좋은 아키텍처 패턴이다. 하지만, CQRS 를 도입하기 위해서는 스템이 복잡해질 수 있다. RDBMS 와 NoSQL 별도의 분리된 저장소를 사용해야 하며, 읽기(조회,Query)를 위한 별도의 애플리케이션을 구축해야할 수도 있다. 


[필자의 의견] CQRS 뿐만 아니라, 마이크로서비스 아키텍처는 시스템 복잡도를 증가시킬 수 있다. MSA 기반의 시스템 아키텍처 설계 시에 시스템 복잡도를 줄일 수 있는 방법에 대해서 많은 고민을 해야한다.  


메시징 패턴  

해당 글에서는 별도의 메시징 큐잉 시스템을 도입하지는 않았다. 필자의 샘플 코드에서는 스프링 이벤트 리스너로 심플하게 구현하였다. 하지만, 명령에 대한 업데이트 데이터를 읽기(조회,Query)모델에 전달해야 하는 트래픽이 너무 높다면, 필수적으로 메시징 큐잉 개념을 적용해야하며, 별도의 메시징 시스템을 구축해야할 수 있다. 메시징 시스템을 제대로 구축하지 않아서, 메시지 전송 시에 병목이 발생한다면, 데이터 업데이트 지연으로 인해서 최종 데이터 일관성 문제가 발생할 것이다. 추가로, 메시징 시스템이 메시지를 잘 전송했는지에 대한 신뢰성을 항상 검증해야 한다. 

(참고로, 유실되어도 상관없는 메시지라면 심각하게 고민하지 않아도 된다.)


이 외에도 다수의 어려운 점이 발생한다. 

필자가 생각할 때 가장 중요하고 해결해야 하는 과제에 대해서만 설명하였는데, 

사실 회사 실무에서는 더 복잡하고 어려운 일이 많이 발생한다. 


좋은 소프트웨어를 개발한다는 것은 참 쉽지가 않은 일이다... 



[참고자료]

CQRS 패턴을 사용해야 하는 경우

어려운 점이 존재하지만, 그럼에도 불구하고 CQRS 패턴을 사용하는 것은 좋은 선택이 될 수 있다. 


https://docs.microsoft.com/ko-kr/azure/architecture/patterns/cqrs#when-to-use-this-pattern

- 많은 사용자가 동일한 데이터에 병렬로 액세스하는 공동 작업 도메인입니다. CQRS를 사용하면 도메인 수준에서 병합 충돌을 최소화하기에 충분한 세분성으로 명령을 정의할 수 있으며, 발생하는 충돌은 명령으로 병합할 수 있습니다.

- 여러 단계를 거치거나 복잡한 도메인 모델을 사용하는 복잡한 프로세스를 통해 사용자를 안내하는 작업 기반 사용자 인터페이스. 쓰기 모델에는 비즈니스 논리, 입력 유효성 검사 및 비즈니스 유효성 검사가 있는 전체 명령 처리 스택이 있습니다. write 모델은 연결된 개체 집합을 데이터 변경(DDD 용어의 집계)에 대한 단일 단위로 처리하고 이러한 개체가 항상 일관된 상태인지 확인할 수 있습니다. 읽기 모델에는 비즈니스 논리 또는 유효성 검사 스택이 없으며 뷰 모델에서 사용할 DTO만 반환합니다. 결과적으로 읽기 모델과 쓰기 모델의 일관성이 유지됩니다.

- 데이터 읽기의 성능이 데이터 쓰기 성능과 별도로 미세 조정되어야 하는 시나리오(특히 읽기 수가 쓰기 수보다 훨씬 큰 경우). 이 시나리오에서는 읽기 모델을 확장할 수 있지만 몇 가지 인스턴스에서만 쓰기 모델을 실행할 수 있습니다. 소수의 쓰기 모델 인스턴스는 병합 충돌 발생을 최소화하는 데도 기여합니다.

- 개발자 중 한 팀은 쓰기 모델에 포함되는 복잡한 도메인 모델에 집중하고 또 한 팀은 읽기 모델과 사용자 인터페이스에 집중할 수 있는 시나리오.

- 시스템이 시간이 지나면서 진화할 것으로 예상되어 여러 버전의 모델을 포함할 수 있거나 비즈니스 규칙이 정기적으로 변하는 시나리오



도메인, 비즈니스 로직이 간단한 경우에는 CQRS 를 굳이 도입할 필요가 없다. 



글 마무리

이 글에서는 CQRS 의 기본 개념 및 샘플 사례에 대해서 소개하였다. 최근 필자의 실무 프로젝트에서 도입한 CQRS 는 몇가지 어려운 점은 있었지만, 개인적으로는 괜찮은 선택이었다고 생각하며, 다른 프로젝트에서도 CQRS 를 도입하는 것에 대해서 용기를 얻게 되었다. 


이 글에서는, CQRS를 쉽게 설명하기 위해서 "호텔 예약 시스템" 이라는 주제로 코드를 작성하였는데, 과연 이 글을 읽는 개발자들이 얼마나 공감할 수 있을지는 모르겠다. 아마, 글이 너무 지루하고 재미 없어서 대부분 중간에 읽는 것을 포기했을 것이다. 혹시, 이 글을 끝까지 정독해서 읽은 개발자가 있다면 매우 감사하게 생각하겠다. 필자는, 이 글을 작성하면서 스스로 아직 많이 부족하다는 사실을 깊게 깨닫게 되었다. 이 글을 읽은 모든 개발자들이 조금이라도 도움이 되었길 바라며, 부족한 글을 마치겠다. 



필자의 허접한 샘플 코드는 가볍게 참고만 하길 바라며... 

https://github.com/sieunkr/cqrs


매거진의 이전글 Spring Boot Redis Pub/Sub
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari