상세 컨텐츠

본문 제목

CQRS 패턴, 코드에 순식간에 적용해보기

Log.Develop/Architecture&Design

by bluayer 2021. 2. 14. 17:55

본문

서론

평화롭게 프로젝트를 개발하고 있던 어느 날,

우연한 기회로 CQRS 패턴에 대해 이야기를 들을 일이 생겼다.

기존 구조는 R.C.Martin의 Clean Architecture를 따라서 작업을 해둔 상태였는데,

어떤 프로그래머 분이 우리 구조에 대해서 들으시더니

오, 그 구조면 CQRS도 고려해보는 건 어때요?

라는 말을 해주셨다.

 

그리고 나는 CQRS를 진행하던 사이드 프로젝트에 적용하기 위해 알아보는 여정을 가졌다.

(그리고 이 사이드 프로젝트는 곧 출시될 예정이다!

https://www.official.cookieparking.com에서 메일을 받아보실 수 있습니다)

 

그래서 우리 API 서버의 이전 구조는...

이전 구조는 클린 아키텍쳐를 '나름' 꼼꼼하게 따르려고 노력했었다.

 

참조한 클린 아키텍쳐 구조

 

그러니깐 정확하게 아래와 같은 구조로 되어 있었다.

우리 팀에서는 Prisma를 통해 entity를 관리했고,

UseCase 쪽에 DTO 인터페이스를 설정해서 Adapter와 Usecase 레이어 사이에서 데이터가 경계를 넘을 수 있게 했다.

(참고로 COOPA는 우리 서비스 이름인 cookieparking의 줄임말로 사용하고 있다!)

(또한 entity가 다른 폴더에 있긴 했지만, 우리는 저 폴더가 가장 내부에서 보호받는 폴더라고 생각하기로 내부적인 consensus를 맞췄다.)

 

prisma(entity)
    ㄴ schema.prisma : 스키마의 내용을 담고 있으며, 사실상 Entity에 대한 정보를 여기서 확인할 수 있다.
    ㄴ usecase에서 반복적으로 사용되는 핵심 비즈니스 로직과 핵심 비즈니스 데이터들을 클래스로 정의
src
    ㄴ app.ts : 라우터와 일부 DI 관련 코드만 존재.
    
    ㄴ external : 외부 기능에 가장 구체적으로 의존하는 기능들을 위한 모듈들
        ㄴ express : http 서버 엔진으로 express를 사용하는 구체적 사항들을 담당
	
    ㄴ adapter : 외부 기능과 내부의 도메인과의 연결을 위한 모듈들
        ㄴ router : 외부에서 들어오는 요청 데이터를 적절하게 변환하고, 적절한 처리 방식에 매핑하고, 응답 데이터를 적절하게 변환하는 역할을 담당
        ㄴ datagateway : 외부에서 데이터를 가져오고, 이를 Entity 형식 변환하는 역할을 담당
    
    ㄴ domain : COOPA의 순수한 비즈니스 로직들이 담기는 모듈들
    	ㄴ interfaces: datagateway에 대한 인터페이스들을 정의해놓은 곳. DTO에 대한 인터페이스들을 정의해놓음.
        ㄴ usecase : COOPA 서버가 제공하는 핵심적인 사용자 기능들을 정의하고 구현

 

사실 이정도만 해도 꽤 괜찮은 구조 아닌가? 라는 생각을 했다.

그렇지만 CQRS에 대해서 알아보면서 상당히 합리적인 내용이 많다고 생각했으며,

우리 서비스는 CRUD 중 특히 Read 요청이 매우 많을 것으로 생각되고 있었기 때문에

스스럼 없이 낮은 수준(low-level)의 CQRS를 적용하기로 마음 먹었다.

 

그래서 CQRS가 뭔데?

솔직히 아직도 딱 "뭐다! 이것이 CQRS다!!" 이렇게 정의 내리기는 어렵다.

그럼에도 불구하고 한 줄로 정의하자면,

 

CQRS 패턴이란,
우리가 보통 이야기하는 CRUD(Create, Read, Update, Delete)에서
CUD(Command)와 R(Query)을 구분하자는 이야기다.

구분하는 이유는,
우리가 Database로부터 데이터를 읽어오고 처리를 하게 되면
이미 그 사이에 데이터가 변경이 되었을 가능성이 높다.
CQRS는 이런 변경 가능성을 인정하고 어차피 Read와 CUD 사이에는 delay가 존재할 수 있음을 인정하는 것이다.
이를 통해서 R과 CUD를 구분함으로써 얻는 이점을 설명하는 것이 CQRS패턴이다.

 

CQRS 패턴을 통해 얻을 수 있는 이점은 여러 가지가 있다.

 

  • Read와 CUD 각각에 더 최적화된 Database 구성을 통해서 성능을 더 향상시킬 수 있다.
  • Read와 CUD에서 필요한 데이터 형식이 다를 수 있고, 특히 Read는 aggregation(집계 함수) 등의 부가적인 attribute들이 Entity에 필요하게 될 수 있다. R과 CUD를 분리함으로써 R로 인해 Entity의 구조가 변경되는 것을 막을 수 있다.
  • R과 CUD를 분리함으로써 과도하게 복잡한 모델을 덜 복합하게 만듦으로서 시스템 복잡도를 줄일 수 있다.

 

아래의 내용은 다음의 링크에서 가져왔다.

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

 

CQRS 패턴 - Azure Architecture Center

데이터를 업데이트 하는 작업에서 데이터를 읽는 작업을 구분 합니다.

docs.microsoft.com

 

그래서 CQRS를 사용하면 좋은 경우는 다음과 같다.

 

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

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

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

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

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

  • 특히 이벤트 소싱과 조합해 다른 시스템과 통합하는 경우. 이때 하위 시스템 하나의 일시적인 장애가 다른 시스템의 가용성에 영향을 주지 않아야 합니다.

 

CQRS가 권장 되지 않는 경우는 다음과 같다.

  • 도메인 또는 비즈니스 규칙은 간단 합니다.

  • 간단한 CRUD 스타일 사용자 인터페이스 및 데이터 액세스 작업으로 충분합니다.

 

CQRS는 알겠으니깐 어떻게 적용했는데?

아래의 링크에서 이야기한 CQRS의 3단계에서 가장 낮은 1단계를 적용해보았다.

www.popit.kr/cqrs-eventsourcing/

 

나만 모르고 있던 CQRS & EventSourcing | Popit

CQRS는 네이밍에서 알 수 있듯이 명령과 쿼리의 역할을 구분 한다는 것이다. 즉 커맨드 ( Create – Insert, Update, Delete : 데이터를 변경) 와 쿼리 ( Select – Read : 데이터를 조회)의 책임을 분리한다는

www.popit.kr

 

구조는 기존과 거의 똑같지만, read를 위한 분리를 진행하였다.

 

prisma(entity)
    ㄴ schema.prisma : 스키마의 내용을 담고 있으며, 사실상 Entity에 대한 정보를 여기서 확인할 수 있다.
    ㄴ usecase에서 반복적으로 사용되는 핵심 비즈니스 로직과 핵심 비즈니스 데이터들을 클래스로 정의
src
    ㄴ app.ts : 라우터와 일부 DI 관련 코드만 존재.
    
    ㄴ external : 외부 기능에 가장 구체적으로 의존하는 기능들을 위한 모듈들
        ㄴ express : http 서버 엔진으로 express를 사용하는 구체적 사항들을 담당
	
    ㄴ adapter : 외부 기능과 내부의 도메인과의 연결을 위한 모듈들
        ㄴ router : 외부에서 들어오는 요청 데이터를 적절하게 변환하고, 적절한 처리 방식에 매핑하고, 응답 데이터를 적절하게 변환하는 역할을 담당
        ㄴ repository : 외부에서 데이터를 가져오고, 이를 Entity 형식 변환하는 역할을 담당
        	ㄴ read : Read Repository에 대한 구현들.
    
    ㄴ domain : COOPA의 순수한 비즈니스 로직들이 담기는 모듈들
    	ㄴ interfaces: repository에 대한 인터페이스들을 정의해놓은 곳. DTO에 대한 인터페이스들을 정의해놓음.
        	ㄴ read : Read Repository에 대한 인터페이스들을 정의해놓은 곳.
        ㄴ usecase : COOPA 서버가 제공하는 핵심적인 사용자 기능들을 정의하고 구현

 

실제 프로젝트 디렉토리 구조는 이런 식으로 구성되어 있다.

 

Read와 구현을 분리해놓은 구조
Read와 CUD 인터페이스를 분리해놓은 구조

 

실제 Read Interface 내부의 코드

 

위처럼 CUD와 R을 분리했더니 이런 일이 발생하기 시작했다!

데이터베이스에서 Read만 해오는 Usecase들이 있는 것이었다!!

 

Read만 하는 Usecase 에시

세상에....

그럼 진짜로 각 Repository에 연결되어 있는 Database 성능을 개선하면 API 서버의 성능을 개선할 수도 있지 않겠는가?

특히 정규화가 잘되어 있어 원래 Read 시에 Join이 빈번한 시스템이라고 했을 때,

이걸 Document 기반의 DB(MongoDB와 같은 NoSQL 계열)로 바꾼다면?!

 

당연히 DB에서는 Document를 가져오기만 하면 될 것이고,

데이터를 꺼내오는 것은 더더욱 쉬울 것이다!!

 

만약 DB까지 서로 다른 걸 사용하게 되면 Polyglot(여러 종류의 DB를 함께 쓸 수 있는)한 구조도 가질 수 있게 될 것이다.

심지어 Polyglot한 구조가 어렵지 않고, 단순하게 연결되는 부분만 바꾸면 전체에 영향이 없게 되는 것이다!

 

결론

물론 Micro-Service-Architecture에서 CQRS는 성능 상의 이슈 및 책임의 분리를 위해 빈번하게 쓰이는 패턴이다.

하지만 코드 레벨에 적용해도 꽤 유의미한 결과를 얻을 수 있다.

 

높은 수준의 CQRS 패턴Event Sourcing과 함께,

Queue(AWS SQS, RabbitMQ, Kafka와 같은 Message Queue가 일반적이다)를 이용하여

비동기적으로 데이터를 쓰고 읽어오는 것이 일반적이다.

 

(실제로 MSA에서 CQRS 패턴과 함께 Event Sourcing을 적용한 내용을 Woowa Con에서 김영한 님이 발표한 적이 있다.

혹시 궁금하신 분들이 있다면 꼭 보시는 것을 추천한다.

그리고 이 동영상은 꼭 40분 내내 풀 집중해서 볼 가치가 있다고 생각한다. 정말로!!)

www.youtube.com/watch?v=BnS6343GTkY

MSA에서 CQRS 사용하는 방법을 이해하기 좋았던 김영한 님의 영상

 

하지만 우리가 높은 수준의 CQRS 패턴을 적용하는 것은 생각보다 요원한 일이고

성능이 필요할 때 제일 간단한 방법은 DB의 성능을 그냥 올리는 것이다. 

 

그러나 낮은 수준의 CQRS는 적용하기 쉬울 뿐 아니라,

실제로 유의미한 의미를 갖는다.

Read하는 엔드포인트를 따로 만들거나, Read Replica를 만든다거나, Read DB 앞에만 Cache를 설정한다거나,

인덱스를 Read나 CUD에 최적화되도록 설정한다거나 등등

다양하게, 또 간단하게 적용해볼 수 있는 방법들이 많아지게 된다.

 

어렵지 않다!

단순히 CUD와 R을 분리하자!!

관련글 더보기

댓글 영역