본문 바로가기
DDD&MSA

[DDD] CQRS - Command 와 Query 의 분리

by 덩라 2023. 9. 7.

본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.

출처: 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)

1. CRUD 를 처리하는 Service

객체지향을 추구하면서 개발을 하다보면, 가장 힘든 일 중 하나가 "class 들의 역할을 적절히 배분하는 것" 일 것 입니다.

개발을 하다보면 아래와 같이 코드를 작성하는 경우가 자주 발생합니다.

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class CartService {
    private final CartRepository cartRepository;
    private final CartQueryRepository cartQueryRepository;

    @Transactional
    public CartResponse create(Long userId, CartRequest cartRequest) {
        // 장바구니 정보 추가 로직
    }

    public List<CartDto> findAllByUser(Long userId) {
        // 장바구니 목록 조회 로직
    }

    @Transactional
    public void delete(Long userId, Long cartId) {
        // 장바구니 제거 로직
    }

}

어떻게 보면 장바구니 관련 Service 로써의 역할을 충분히 수행하고 있는 것 처럼 보이지만, 아쉬운 점이 몇 가지 있습니다.

  1. 객체의 단일 책임 원칙이 지켜지지 않았다.
  2. 데이터의 쓰기와 읽기가 같은 곳에 위치해있다.

여기서 좀 더 유심히 볼 것은 두 번째, "데이터의 쓰기(조회)와 읽기(명령)가 같은 곳에 위치한다." 입니다.

흔히, CRUD 라고 불리는 추가/조회/수정/삭제 는 DB 입장에서 바라보면 2가지로 나눌 수 있습니다. 

바로, R(조회) 와 CUD(추가/수정/삭제) 입니다.  이 둘의 차이점은 데이터가 변경하느냐 안하느냐 입니다. DB에서 데이터를 변경할 때는 반드시, 트랜잭션을 발생시켜 커밋을 하는 과정이 필요합니다. (읽기전용 트랜잭션도 있지만, 해당 트랜잭션에서 커밋해도 아무일도 생기지 않습니다.)

그래서 개발자들은 Spring 에서 제공하는 @Transactional 어노테이션을 통해, 해당 로직을 트랜잭션을 발생시킬 것인가 안 시킬것인가 를 어느정도 제어할 수 있습니다.

 

위 코드를 놓고 봤을 때, 데이터 변경을 위한 트랜잭션을 발생시키는 메서드가 2개(create, delete), 읽기만 하는 메서드가 1개(findAllByUser) 있는 것을 확인할 수 있습니다. 이 두 종류의 메서드가 하나의 서비스에서 실행되는 구조인 것이죠.

여기서 "데이터를 변경하는 메서드와 조회를 위한 Repository 인 CartQueryRepository가 굳이 같은 곳에 위치할 필요가 있을까?" 를 한 번 생각해볼 필요가 있습니다.

 

Repository 자체를 하나로 합치는 방법을 생각해볼 수 도 있겠지만 사실 그 방법도 지금하는 고민과 같은 고민을 해볼 필요가 있습니다. 

예를 들어, 우리가 인터넷 카페를 사용해본다고 가정해봅시다. 인터넷 카페에 들어가서 글을 쓰는 행위글을 읽는 행위 중에 어느 것을 더 많이 하게 될까요? 거의 100명 중 99명이 글을 읽는 행위를 더 많이 한다고 답할 것 입니다. 이처럼 우리가 일상 생활에서 은연중에 사용하는 여러 서비스를 생각해보면, 조회를 하는 행위를 훨씬 많이 합니다. 즉, 시스템 입장에서 바라보면 insert/update 와 같은 쿼리 보다 select 쿼리가 훨씬 많이 발생한다는 뜻입니다.

또한, insert와 update 는 어느 정도 형태가 정형화 되어 있습니다. DB 내에 설계된 테이블의 구조에 맞게 SQL 이 생성되어야 하는 반면,

select 는 사용자가 보고 있는 화면에 따라 매우 많은 경우의 수가 발생할 수 있습니다.

insert/update/delete 쿼리는 형태가 크게 변하지 않는 반면, select 쿼리는 어떤 테이블의 어떤 칼럼들을 조회할 지가 화면마다 다를 수 있기 때문에 애플리케이션에서 여러 기능으로 구현될 여지가 상당합니다.

이렇게 여러 경우의 수가 고려되는 select 와 어느정도 형태가 정형화된 insert/update/delete 를 담당하는 메서드가 한 곳에 위치한다는게 과연 자연스러운 일일까요? 사실, 저를 포함한 많은 개발자들이 너무나 당연하게 구현했던 방식일 수 있습니다.

 

그래서 지금부터 한 곳에서 처리하는 것과 나눠서 처리하는 것이 각각 어떤 장단점을 가지고, 어떤 특징을 가지는지를 한 번 알아보려 합니다.

2. CQRS - Command Query Responsibility Segregation

앞에서 얘기했던 selectinsert/update/delete 를 분리하는 것,

데이터의 변경(명령모델)과 조회(조회모델)를 다른 모델로 처리하는 패턴CQRS(Command Query Responsibility Segregation, 명령과 조회의 책임 분리) 라고 부릅니다.

CQRS 를 적용해 명령모델과 조회모델을 구분하게 되면, 아래와 같은 형태를 띄게 됩니다.

그럼 CQRS 는 어떤 특징을 가지게 되는지 한 번 장단점으로 알아보겠습니다.

(CQRS 적용 시 장점이 CQRS 미적용 시의 단점으로, CQRS 적용 시 단점이 CQRS 미적용 시의 장점이 됩니다.)

 

3. CQRS 장점

1. 조회모델과 명령모델에 서로 다른 구현 기술을 적용할 수 있다.

명령모델에서 발생하는 일은 테이블 스키마를 어느정도 따라가는 정형화된 형태가 됩니다. insert/update 같은 경우, 테이블에 어떤 칼럼을 추가/수정 할지 미리 알아야 가능하며, 이는 테이블 스키마와 영향을 주기 때문에 쿼리가 어느정도 정해진 형태를 가지게 됩니다.

하지만, 조회모델의 경우 여러 테이블을 조인하여 여러 데이터를 조회해올 수 있는데 이런 경우 많은 경우의 수를 구현할 수 있게 됩니다.

또한, 명령모델에서는 데이터를 추가할 때는 데이터 간의 정합성도 고려해야 하므로, 주로 도메인 계층에서 비지니스 로직을 구현함으로써 이러한 정합성을 맞추게 됩니다. 

 

이런 경우, 조회모델은 동적쿼리에 유연한 myBatis 를, 명령모델의 경우 객체지향적인 JPA를 사용하도록 처리할 수 있게됩니다.

CQRS 를 적용하지 않아 조회모델/명령모델 이 같은 구현 기술의 Repository 객체를 사용하게 된다면, 둘 중 한 모델은 자신의 단점을 그대로 안고 가야하는 상황이 나타날 수 있게됩니다.

 

2. 조회를 위한 별도의 조회용 모델을 사용하기 용이하다.

앞에서 얘기한 것 처럼, 명령모델은 데이터의 정합성을 도메인 계층의 비지니스 로직을 통해 맞추고, 조회모델은 여러 테이블의 여러 칼럼을 조회하는 많은 경우의 수를 다루게 됩니다. 

즉, 명령모델은 도메인 계층의 비지니스 로직을 객체지향적으로 다룰 수 있는 모델로, 조회모델은 어떠한 쿼리에 딱 맞는 모델로 구현하는 것이 유리할 수 있습니다.

주문 도메인의 명령/조회 모델 예시

이 경우, 조회모델과 명령모델을 나누지 않으면 어느 한쪽이 불편을 감수하고 조회를 하던, 명령을 수행하던 할 것 입니다.

이는 개발생산성을 대단히 떨어뜨릴 수 있는 요소로 작용됩니다.

 

3. 조회 성능 극대화를 위해 명령모델과 저장소를 다르게 사용할 수 있다.

보통 데이터 저장소를 이야기할 때, 대표적으로 RDBMS(MySql, MSSQL, Oracle 등)을 주로 이야기합니다.

 

데이터를 저장/변경 할 때, 반드시 존재해야 하는 데이터와 각 객체 간의 정합성이 맞는 데이터 인지를 확인하고 저장해야 하는 경우가 많습니다. 대표적인 예로, 주문 정보를 저장할 때는 주문 상품들에 대한 정보도 같이 저장해야 하고, 반대로 주문 상품정보가 저장될 때는 반드시 주문 정보가 존재해야 합니다. 이러한 데이터 간의 정합성은 RDB에서 외래키 제약 조건을 추가함으로써 강력하게 보장받을 수 있습니다.

그리고 데이터를 조회할 때, 데이터가 수 백/수 천 만 건이 존재한다면, RDB 보다 비교적 조회 성능이 높다고 알려진 NoSQL을 사용할 수 있을 것 입니다.

 

하지만, 이렇게 조회/명령 에 따라 다른 저장소를 가져가는 것은 조회/명령 을 담당하는 Repository가 분리되지 않고, 같은 구현체를 사용해선 구현이 불가능할 것 입니다.

데이터 저장소를 별도로 사용하는 CQRS 적용 구성도 예시

CQRS 패턴을 적용하는 경우, 이처럼 성능 향상을 위한 시스템 구성을 폭 넒게 고려해볼 수 있다는 점도 장점이라 볼 수 있습니다.

 

4. CQRS 단점

앞서, CQRS 의 장점들을 알아봤는데 그럼 단점은 없는 것일까요?

모든 것이 그렇듯 CQRS 패턴도 적용할 경우, 고려해야 하는 부분이 있습니다.

 

1. 구현할 코드의 양이 많아진다.

CQRS 를 적용하면 기본적으로 평소보다 많은 코드가 생깁니다.

아래는 CQRS 를 적용했을 때와 적용하지 않았을 때의 객체 구성입니다.

Layered Architecture 구조 기준 CQRS 적용/미적용 예시

CQRS 를 적용하지 않으면, Controller, Service, Repository 에서 API Mapping, 트랜잭션 관리, 데이터 핸들링을 처리하면 되지만,

CQRS 를 적용하면, 위 과정을 조회모델과 명령모델 2 세트로 구현을 해야 논리적으로 명확하게 분리할 수 있습니다.

물론, CQRS 패턴 적용 시, 조회모델에서 Service 를 사용하지 않고, Controller 에서 바로 DAO(조회용 Repository) 를 불러서 사용해도 되지만, 그렇게 한다 한들 CQRS 를 적용하지 않았을 때 보다 코드량이 많아지는 것은 변하지 않습니다.

 

코드량이 많아 진다는 것은 그만큼 개발자들에게 관리포인트가 늘어나는 영역일 수 있으므로, 본인이 개발하고 있는 프로젝트의 규모에 맞게 CQRS 패턴 적용 여부를 검토해 보는 것이 좋습니다.

 

2. 필요한 기술이 많아질 수 있다.

앞서, CQRS 패턴의 장점을 언급하면서, "데이터 저장소를 다르게 적용할 수 있다." 라고 했습니다.

하지만, 이것이 CQRS 의 단점을 야기하게 되는데, 바로 "데이터 동기화가 불가피 하다." 는 점입니다.

명령모델에서 데이터가 변경/추가/삭제 되면, 조회모델에 적용된 데이터 저장소에도 해당 변경내역을 알려줘야 합니다.

이런 경우, 서로 다른 데이터 저장소를 사용하게 되면 별도의 메세징 큐(Kafka, RabbitMQ 등)를 적용하여 데이터 동기화가 이루어지게 됩니다.

즉, 성능 향상을 위해 CQRS 패턴을 적용하여 조회모델과 명령모델의 저장소를 따로 가져간다면, 데이터 동기화에 대한 구현은 자연스럽게 따라오게 되는 것 입니다.

이럴 경우, 프로젝트의 규모가 작고, 트래픽이 많지 않은 경우는 오버 엔지니어링(Over Engineering)이 될 위험이 있습니다.

댓글