본문 바로가기

IT

CQRS ? AbstractRoutingDatasource

728x90

CQRS 패턴은 무엇일까? 종종 듣는 분들도 많을 것이고 실제 회사에서 도입하여 사용하는 케이스도 많다고 들었다. 

필자는 CQRS란 무엇인지 스프링을 사용한다면 애플리케이션 레벨에서 어떻게 적용해 볼 수 있는지 작성해보려고 한다.

 

CQRS는 Command Query Responsibility Segregation의 약자로 해석하면 명령 조회 책임 분리이다. 

애플리케이션들을 구성하는 아키텍처에 대한 하나의 패턴이다. 즉 애플리케이션을 구현함에 있어 명령과 조회에 대한 책임을 분리하는 것이다. 

CQRS 패턴

CQRS 패턴에 대해 구글링해보면 자주 마주치는 그림이다.

필자는 그냥 간단하게 생각했다. DB를 조회할 때 조회(READ)와 처리작업(CREATE, UPDATE, DELETE 등)을 다른 노드에게 할당하면 기본적인 베이스가 되는게 아닐까생각했고 그럼 이것에 장점이 무엇일까 고민하게 되었다. 시스템의 복잡도가 올라가고 쓰기 노드와 읽기 노드 간의 동기화가 가능한가에 대한 의문점이 들었지만 이만큼 대중화가 된 이유도 있지 않을까 생각했다. 

 

전통적인 아키텍처에서는, DB에서 데이터를 조회하고 업데이트하는데 같은 데이터 모델이 사용되었다. 간단한 CRUD 작업에 대해서라면, 이것은 문제 없이 동작한다. 하지만 좀 더 복잡한 어플리케이션에서 이러한 접근 방식은 유지 보수를 어렵게 만들 수 있다. 예를 들면, 어플리케이션은 데이터 조회 시, 각기 다른 형태의 DTO를 반환하는 매우 다양한 쿼리들을 수행할 수 있다. 각각 다른 형태의 DTO들에 대해 객체 매핑을 하는 것은 복잡해질 수 있다. 또한 데이터를 쓰거나 업데이트를 할 때는, 복잡한 유효성 검사와 비즈니스 로직이 수행되어야 한다. 결과적으로 이 모든 걸 하나의 데이터 모델이 한다면, 너무 많은 것을 수행하는 복잡한 모델이 되는 것이다.

또한 읽기와 쓰기의 부하는 보통 같지 않다. 따라서 각각에 대해 다른 성능이 요구된다.

 

그밖에도 전통적인 아키텍처에서는 다음과 같은 문제점들이 있다.

  • 읽기와 쓰기 작업에서 사용되는 데이터 표현들이 서로 일치하지 않는 경우가 많다. 그로 인해 일부 작업에서는 필요하지 않은 추가적인 컬럼이나 속성의 업데이트가 이뤄져야 한다.
  • 동일한 데이터 세트에 대해 병렬로 작업이 수행될 때 데이터 경합이 발생할 수 있다.
  • 정보 조회를 위해 요구되는 복잡한 쿼리로 인해 성능에 부정적인 영향을 줄 수 있다.
  • 하나의 데이터 모델이 읽기와 쓰기를 모두 수행하기 때문에, 보안 관리가 복잡해질 수 있다. (예를 들면 사용자 데이터의 경우 비밀번호가 노출되선 안된다)

 

이밖에도 검색하다보면 다양한 개발자분들이 작성해놓은 단점들이 있다. 그렇지만 기존의 아키텍쳐에서 아직 경험해보지 못해 이해가 안되는 부분도 있었지만 데이터 모델을 분리하여 R과 C,U,D에 대한 부하를 성능에 맞게 분리하고 유지보수가 편하다고 느낀점은 있었다.

 

객체지향을 추구하는 자바를 포함하여 다양한 아키텍쳐 관련 아티클이나 도큐먼트, 책 등을 읽다보면 항상 추구하는게 비슷하다는 것을 느낀다. 책임을 분리하고 그에 따른 각각의 역할을 부여하고 서로 간(객체 또는 서비스)에 영향력을 줄이는 등 내용이 다 비슷비슷하다. 아직 시야가 좁아 깊은 뜻을 이해하지 못한 것도 있다. 하지만 이러한 아키텍쳐나 기술이 대세이고 장점이 확실하다는 것은 분명하다고 생각한다. 

 

다시 본론으로 들어가서 필자는 어떻게 CQRS를 간단 도입했는지 이어서 설명해보려 한다. 

R과 CUD에 대해서 어떻게 라우팅을 하면 좋을지 고민해보면 애플리케이션 레벨 또는 인프라 관점에서도 가능하다. 필자는 Amazon Aurora PostgerSQL을 사용했기에 엔트포인트를 읽기 전용, 쓰기 전용으로 두가지를 가져갔다. 그리고 애플리케이션에서 라우팅을 해주었다. 찾아보니 Amazon RDS Proxy라는 것도 존재했지만 아키텍쳐를 복잡하게 가져가지 않고 다양한 인터페이스를 지원해주는 스프링을 사용하니 애플리케이션에서도 충분히 가능할 것이라고 믿었다.

 

개인적인 생각을 또 포함하자면 스프링은 정말 다양한 인터페이스를 지원해준다... 스프링을 만들고 버전업을 계속 시도하는 사람들은 대단하다. 필자도 기회가 된다면 오픈 소스 라이브러리를 만들어 넥서스에 올리고 제공해보고 싶은 욕심도 있다.

 

스프링의 AbstractRoutingDatasource는 spring-jdbc 모듈에 포함되어 있는 클래스로써, 여러 DataSource를 등록하고 특정 상황에 맞게 원하는 DataSource를 사용할 수 있도록 추상화한 클래스이다.

spirng:
	datasource:
    		hikari:
        		pool-name: Leader
        		...
            	        jdbc-url: leader-jdbc-url
            	        ...
            
            	    follower:
            		    pool-name: Follower
               	 	    ...
                	    jdbc-url: follower-jdbc-url
                	    ...

데이터 처리를 위한 Leader와 조회를 위한 Follwer에 대한 datasource를 등록하고 이를 빈으로 등록해준다. 

@Bean
	public DataSource routingDataSource(@Qualifier("leaderDataSource") DataSource leader, @Qualifier("followerDataSource") DataSource follower) {
		
		var dataSource = new ReplicationRoutingDataSource();
		dataSource.setTargetDataSources(LEADER, leader, FOLLOWER, follower));
		dataSource.setDefaultTargetDataSource(leader);		
		return dataSource;
	}

그리고 이를 AbstractRoutingDataSource 추상 클래스를 이용하여 DB source를 라우팅을 해줄 수 있다. 이를 상속받는 클래스를 만들어보자.

public class DataSourceRouting extends AbstractRoutingDataSource {

	@Override
	protected Object determineCurrentLookupKey() {
		return RoutingDataSourceTartget.target();
	}
}

determineCurrentLookupKey에서 사용될 타겟 데이터 소스를 가져와야한다. 

lookup key를 저장하는 용도로 ThreadLocal을 사용했다. 현재 컨텍스트에서 사용될 DataSource의 key을 ThreadLocal에 저장하고 AbstractRoutingDataSource 에서 ThreadLocal에 저장된 값을 참조하여 타겟 DataSource를 결정할 수 있다. 필자는 패키지명에 따라 DataSource를 라우팅 해주었다. 

public static String target() {
		
		if(threadLocal.get().isEmpty()) {
			return FOLLOWER;
		} else {
	
			String target = peek();
			if(target.contains(".service.") && target.contains("CommandService")) {
				return LEADER;
			} else {
				return FOLLOWER;
			}
		}
	}

이런 방법을 사용하기 위해서는 ThreadLocal에 해당 값을 push 해주어야하는데 AOP를 이용하면 간단하게 적용할 수 있다.

@Slf4j
@Aspect
@Component
public class AspectForTransaction implements Ordered {
	
    @Around("within(com.totoro.service..*) && execution(* com.totoro..*CommandService*.*(..))")    
    public Object target(ProceedingJoinPoint pjp) throws Throwable {	
    	String target = pjp.getSignature().getDeclaringTypeName()+"."+ pjp.getSignature().getName();
    	RoutingDataSourceTartget.push(target);
    	
    	...   	
    }

다양한 방법이 있겠지만 AOP를 활용하여 ThreadLocal에 해당 값을 넣고 라우팅 소스를 분리하는 방법을 사용했다. 

물론 이 방법에는 코드를 보면 알겠지만 service 패키지의 CommandService가 붙어 있는 경우에만 라우팅을 Leader 소스로 분리하도록 설정한 방법이다. R, U, D 작업에는 CommandService를 붙이고 나머지는 R은 Follower 데이터 소스를 타는  방법으로 애플리케이션 레벨에서 라우팅을 설정한 것이다. 

 

이로써 간단하게 CQRS의 기초가 되는 셋팅을 끝냈다. 첫 기존의 목적대로 라우팅을 애플리케이션 레벨에서 시도한 것이다. 적어도 READ를 분리했다면 더 성능을 최적화 할 수 있지 않을까 생각이 들었다. 앞단에 캐싱을 두거나 DocumentDB를 활용하면 어떤지 등 의문점이 들었다. 반대로 데이터 동기화는 문제가 없을지 생각이 들었다. 만약 데이터를 INSERT하고 그 값을 바로 조회하는 애플리케이션의 속도가 더 빠른 경우가 존재하지 않을까. 이 부분에 대해서도 다양한 개발자들의 다양한 해결 사례들을 볼 수 있었다.

 

CQRS를 검색하면 이벤트 소싱에서 많이 쓰이고 MSA 등 연관 검색어 중  트렌디한 패턴등을 볼 수 있다. 필자는 브랜딩한 견해를 앞단에 두고 정말 어떻게 유용하게 내가 적용할 수 있을지 고민해보는 습관을 가지려 한다. 

 

 

 

 

 

 

 

 

 

AbstractRoutingDatasource 사용 참고: https://velog.io/@ghkvud2/AbstractRoutingDataSource-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

'IT' 카테고리의 다른 글

[Java] MDC를 활용한 로그 추적  (0) 2023.08.04
[Java] ThreadLocal  (0) 2023.07.31
JPA N+1 문제 응용 사례  (2) 2023.06.17
JAVA 불필요한 객체 생성  (1) 2023.06.11
JPA N+1 문제 1분 이해하기  (0) 2023.06.01