CQS를 생활화 하자

이상민·2022년 5월 2일
1

CQS... CQRS... 자주 언급 얘기이지만, 사실 실제 매우 큰 모노리틱 앱을 관리하고 있는 회사에서 일하기 전까지는 크게 와닿지 않았었다. 오늘은 시스템을 나누는 정도는 아니어도, 그냥 평소 코딩할 때 조회와 명령을 나누면 좋다고 생각이 들어서 이에대해 정리한다.

1. CQS - 명령 조회 분리 원칙

Bertrand Meyer가 제안한 개념으로 메소드는 액션을 수행하는 명령(command)이거나 데이터를 반환하는 조회(query) 둘중 하나이어야 한다는 원칙이다. 이름만 보면 뭔가 어려워보이지만, 사실 이게 끝이다. 기능이 명령이라면 명령쪽 로직을 타고, 조회라면 조회쪽 로직을 타면 된다. 그렇다면 CQS의 개념은 왜 어렵게 다가오는 것일까?


2. CQS가 어렵게 느껴지는 이유

CQS가 어렵게 느껴지는 이유는 주로 다른 개념들과 함께 사용되기 때문이다. CQS에 대해 검색하면 아래와 같은 다이어그램을 쉽게 찾을 수 있다.

출처 : https://codeopinion.com/cqrs-event-sourcing-code-walk-through/

위 그림은 CQS를 위해 메소드를 분리하는 것 뿐만 아니라, 모델을 읽기 전용과 쓰기 전용으로 나누고 이벤트 소싱의 개념을 접목시켜 데이터 저장소마저 분리 시킨 구조이다. 하지만 위에서 언급했던 CQS의 기본적 개념은 단순히 메소드를 조회와 명령으로 구분하는 것이다. 이벤트 소싱과 읽기/쓰기 전용 모델은 구조를 더욱 분리하기 위한 것일뿐, CQS를 위한 필수요건은 아니다. CQS의 용어를 통해 CQRS라는 개념을 제안한 Greg Young은 CQRS에 대해 다음과 같이 설명한다

  • CQRS is not a silver bullet. (CQRS는 만병통치약이 아니다)
  • CQRS is not intrinsically linked to DDD. (CQRS는 DDD와 본질적으로 연결되어 있지 않다)
  • CQRS is not Event Sourcing (CQRS는 이벤트 소싱이 아니다)
  • CQRS does not require a message bus (CQRS는 이벤트 버스를 필요로하지 않는다)
  • CQRS is learnable in 5 minutes. (CQRS는 5분안에 배울 수 있다)

3. CQS의 예시

아래 예시는 딱히 설명이 필요하지도 않다.

interface UserService {

    User getUserBy(long userId);
    User getUserBy(String token);
    List<User> getUsersBy(List<Long> userIds);
    List<User> getUsersBy(List<String> tokens);
    void updateUserPassword(String newPassword);
	void registerUser(User user);
    void deleteUser(User user);
    
}
interface UserReadService {

    User getUserBy(long userId);
    User getUserBy(String token);
    List<User> getUsersBy(List<Long> userIds);
    List<User> getUsersBy(List<String> tokens);

}
interface UserWriteService {

    void updateUserPassword(String newPassword);
	void registerUser(User user);
    void deleteUser(User user);
    
}

4. CQS를 해야하는 이유 - 응집도와 결합도

정말 정말 단순한 CRUD 기반 어플리케이션이라면 극초기에는 흐름이 같을 수도 있지만, 어플리케이션에 기능이 추가되는 등 발전함에 따라 명령과 조회는 금방 다른 흐름을 타게 된다. 매우 간단한 어플리케이션에서도 아래와 같이 흐름이 나뉠 수 있다.

Command -> |CommandHandler| -> |Domain Model| -> |Database|

Query -> |QueryHandler| -> |Database|
                        -> |Cache|

명령은 명령 핸들러를 통해 비즈니스 규칙을 강제하기 위한 도메인 모델을 통한다. 도메인 모델을 통하도록 하여 다양한 핸들러에서 관련 도메인을 수정하더라고 객체가 잘못된 상태를 가지는 것을 방지할 수 있다. 이런 도메인 모델을 저장함에 있어도 올바른 상태를 보장하기 위해 트랜잭션에 관해서도 자주 고민하게 된다. 한 명령을 수행하기 위해, 다른 명령을 호출하게 될 수도 있다.

조회는 조회 핸들러를 통해 그냥 필요한 데이터를 가져온다. 이때 명령과 같은 데이터 저장소에서 읽어 올 수도 있지만, 성능을 위해 캐시에서도 읽어 올 수 있다. 조회는 일반적으로 명령보다 로직이 간단하다. 사이드 이펙트가 없으니 비즈니스 규칙을 강제하기 위한 도메인 객체가 필요하지도 않다.

이처럼 명령과 조회가 흐름이 다르니 자연스럽게 기능의 진화도 다르게 발생한다. 이 둘을 분리를 하므로서 자연스럽게 객체의 응집도를 높이고 결합도를 낮출 수 있다.


4. 객체를 2개로 나눴다고 끝이 아니다

객체를 2개로 나누어 시작하자는 것이지 거기서 끝내자는 이야기는 아니다. 아무리 객체를 2개로 분리했다고 하더라도, 다양한 기능이 추가되며 명령과 조회 내에서도 다른 흐름이 생기기 시작할 수 있다. 기능이 추가되면 추가 될 수록 명령/조회 객체로의 진입 의존성과 객체로부터의 진출 의존성 또한 높아지게 될것이다. 그렇다면 다시 응집도는 낮아지고, 결합도는 높아진다. 이러한 경우 기능별로 다시 객체를 나눌 수 있다. 이를 가장 잘 반영한 아키텍처가 C# 진영해서 사용되는 버티컬 슬라이스 아키텍처인데 이는 아직 공부/검증 중이라 다음에 다뤄보도록 하겠다.


5. CQS를 생활화 하자

정리하자면 CQS는 단순히 기능을 수행하는 하나의 객체였던 것을 명령과 조회 2개로 분리하는 것이다. 이 과정에서 이벤트 소싱, DDD, 이벤트 주도 개발, 읽기/쓰기 전용 모델의 개념을 도입할 수도 있지만 필수는 아니다. 명령과 조회 기능은 근본적으로 성격이 다르기 때문에 아무리 단순한 기능으로 시작했다고 해도 금방 흐름이 달라진다. 따라서 시작부터 이 둘을 분리할 것을 제안한다.

profile
편하게 읽기 좋은 단위의 포스트를 추구하는 개발자입니다

0개의 댓글