MSA로 전환 시 고민해야 할 분산 데이터 관련 문제가 트랜잭션만 있는 것은 아닙니다.
쿼리를 구현하는 방법도 찾아내야 합니다.
💡 트랜잭션 문제 해결 법 → [MSA] 마이크로서비스 사가 패턴
DB가 하나뿐인 모놀리식 애플리케이션에서는 비교적 쉽게 쿼리를 구현했습니다.
하지만 MSA에서는 의외로 쿼리를 작성하기가 어렵습니다. 여러 서비스 여러 DB에 분산된 데이터를 조회해야 하기 때문입니다.
이를 해결하기 위한 2가지 패턴이 있습니다.
- API 조합(composition) 패턴
→ 서비스 클라이언트가 데이터를 가진 여러 서비스를 직접 호출하여 그 결과를 조합하는 패턴입니다.
→ 가장 단순한 방법으로 가급적 이 방법을 쓰는 것이 좋습니다. - CQRS(커맨드 쿼리 책임 분산) 패턴
→ 쿼리를 지원하는 하나 이상의 뷰 전용 DB를 유지하는 패턴입니다. API 조합 패턴보다 강력한 만큼 구현하기는 더 복잡합니다.
API 조합 패턴
findOrder()
는 주문 정보를 조회하는 메서드입니다. orderID
를 매개변수로 받아 주문 내역이 포함된 OrderDetails
객체를 반환합니다.
OrderDetails
를 얻기 위해서는 아래 서비스들의 정보가 모두 필요합니다.
- 주문 서비스: 주문 기본 정보(주문 내역, 주문 상태 등)
- 주방 서비스: 음식점 관점의 주문 상태, 픽업 준비까지 예상 소요 시간
- 배달 서비스: 주문 배달 상태, 배달 예상 정보, 현재 배달원 위치
- 회계 서비스: 주문 지불 상태
따라서 API 조합 패턴이란 다음 그림처럼 특정 컴포넌트에서 여러 서비스들의 정보를 조합하여 반환하는 패턴입니다.
API 조합 설계 이슈
어느 컴포넌트를 쿼리 작업의 API 조합기로 선정할 것인가?
API 조합기: 서비스를 쿼리하여 데이터를 조회하는 컴포넌트
- 서비스 클라이언트(웹 애플리케이션)를 API 조합기로 임명하기
→ 웹 애플리케이션 같은 클라이언트가 동일한 LAN에서 실행 중이라면 가장 효율적으로 주문 내역을 조회할 수 있습니다. 하지만 클라이언트가 방화벽 외부에 있고 서비스가 위치한 네트워크가 느리다면 실용적이지 않습니다. - API 게이트웨이를 API 조합기로 만들기
→ 쿼리 작업이 애플리케이션의 외부 API 중 일부라면 이 방법이 타당합니다. 다른 서비스로 요청을 보내는 대신 차라리 API 게이트웨이에 API 조합 로직을 구현하는 것입니다. 모바일 기기 등 방화벽 외부에서 접근하는 클라이언트가 API 호출 한 번으로 여러 서비스의 데이터를 조회할 수 있기 때문에 효율적입니다. - 서비스 중 하나에 API 조합기를 설치하는 방법
→ 내부적으로 여러 서비스가 사용하는 쿼리 작업이라면 이 방법이 좋습니다. 취합 로직이 너무 복잡해서 API 게이트웨이 일부로 만들기는 곤란하고 외부에서 접근 가능한 쿼리 작업을 구현할 경우에도 좋은 방법입니다.
API 조합 패턴 단점
API 조합 패턴은 MSA에서 아주 쉽고 단순하게 쿼리 작업을 구현할 수 있게 해 주지만 아래와 같은 단점도 있습니다
- 오버헤드 증가
→ API 조합 패턴은 여러 번 요청하고 여러 DB 쿼리를 실행해야 합니다. 따라서 그만큼 컴퓨팅/네트워크 리소스가 더 많이 소모되고 애플리케이션 운영 비용도 늘어납니다. - 가용성 저하 우려
→ 어떤 작업의 가용성은 더 많은 서비스가 개입할수록 감소합니다.
→ 가용성을 높이기 위해서 데이터를 캐시 할 수 있습니다. 또는 UI에 지장을 주지 않는 선에서 미완성된 데이터를 보냅니다. - 데이터 일관성이 결여된다.
→ 여러 DB를 대상으로 여러 쿼리를 실행하기 때문에 일관되지 않은 데이터가 반환될 수 있습니다.
→ 예를 들어 주문 서비스가 조회한 주문 상태는CANCELLED
이지만, 주방 서비스가 조회한 이 주문의 티켓은 아직 취소되지 않았을 수도 있습니다.
API 조합 패턴 문제
API 조합 패턴은 반쪽짜리 솔루션에 불과합니다. 이 패턴만으로는 효율적으로 구현하기 어려운 다중 서비스 쿼리가 많기 때문입니다.
API 조합 패턴으로 구현하기 어려운 쿼리는 무엇이 있는지 알아보겠습니다.
findOrderHistory() - 다중 서비스 쿼리
findOrderHistory()
는 다음 매개변수를 받아 소비자의 주문 이력을 조회하는 쿼리 작업입니다.
- consumerID: 소비자 식별자
- OrderHistoryFilter: 필터 조건
→ 어느 시점 이후 주문까지 반환할 것인가 (필수)
→ 주문 상태 (옵션)
→ 음식점명 및 메뉴 항목을 검색할 키워드 (옵션)
findOrderHistory()
가 findOrder()
와 다르게 API 조합기로 구현이 어려운 이유는 모든 서비스가 필터/정렬 용도의 속성을 보관하는 것이 아니기 때문입니다.
예를 들어 OrderHistoryFilter
에는 메뉴 항목이 존재하지만 실제로 메뉴 항목을 저장하는 서비스는 주문 주방 2개뿐이고 나머지 배달 회계 서비스들은 저장하지 않기 때문에 메뉴 항목으로 데이터를 필터링할 수 없습니다.
API 조합기는 이 문제를 두 가지 방법으로 해결할 수 있습니다.
- API 조합기로 데이터를 인메모리 조인을 합니다.
→ 어떤 소비자의 모든 주문 데이터를 배달 서비스, 회계 서비스에서 가져온 후 주문 서비스, 주방 서비스에서 가져온 데이터와 조인하는 것입니다.
→ 거대한 데이터 뭉치를 이런 식으로 API 조합기에서 조인하면 급격히 효율이 떨어질 것입니다. - API 조합기로 주문 서비스, 주방 서비스에서 데이터를 조회하고, 주문 ID를 이용하여 다른 서비스에 있는 데이터를 요청합니다.
→ 대량 조회 API를 제공할 경우에만 현실성이 있습니다.
→ 주문 데이터를 하나하나 요청하는 것은 과도한 네트워크 트래픽이 유발되므로 비휴율적입니다.
findAvailableRestaurants() - 단일 서비스 쿼리
하나의 서비스에 국한된 쿼리도 구현하기 어려운 경우가 있습니다.
서비스 DB가 효율적인 쿼리를 지원하지 않기 때문입니다.
예를 들어 findAvailableRestaurants()
쿼리 작업을 봅시다. 이 쿼리는 주어진 시점에 주어진 위치로 배달 가능한 음식점을 검색합니다.
이 쿼리의 핵심은 배달 주소의 특정 거리 내에 있는 음식점을 지리 공간 검색하는 기능입니다. 이 기능은 가용 음식점을 표시하는 UI 모듈에 의해 호출되며, 주문 프로세스에 있어서 매우 중요한 부분입니다.
findAvailableRestaurants()
쿼리에서 가장 어려운 부분은 효율적으로 지리 공간 쿼리를 수행하는 작업입니다.
만약 사용 중인 DB가 지리 공간 기능을 지원하지 않을 경우 findAvailableRestaurants()
는 구현하기가 엄청 까다롭습니다.
라이브러리를 사용하거나 음식점 데이터의 레플리카를 전혀 다른 종류의 DB에 저장해야 할 것입니다.
단일 서비스가 쿼리를 구현하기 까다로운 또 다른 이유는 데이터를 가진 서비스에 쿼리를 구현하는 것이 부적절한 경우가 있기 때문입니다.
findAvailableRestaurants()
는 음식점 서비스에 있는 데이터를 조회하는 쿼리 작업입니다.
음식점 서비스는 음식점 프로필, 메뉴 항목 등을 음식점에서 관리할 수 있게 해주는 서비스입니다. 음식점명, 주소, 요리, 메뉴, 오픈 시간 등 다양한 속성을 저장합니다.
언뜻 보면 마땅히 음식점 데이터를 가진 음식점 서비스에 쿼리를 구현해야 하는 것처럼 느껴지지만, 이는 데이터 소유권만 보고 판단할 문제는 아닙니다.
관심사를 어떻게 분리하면 좋을지 어느 한 서비스에 너무 많은 책임을 부과하지 않으려면 어떻게 해야 할까 하는 문제도 함께 고민해야 합니다.
가령 음식점 서비스 개발 팀의 주 임무는 음식점 주인이 자기가 운영하는 음식점을 잘 관리할 수 있게 해주는 서비스를 개발하는 일이지, 성능이 매우 중요한 대용량 데이터를 조회하는 쿼리를 구현하는 일은 아닐 것입니다. 그리고 만약 이 팀의 개발자가 findAvailableRestaurants()
개발까지 담당할 경우, 나중에 자신이 변경한 코드를 배포하면 만에 하나 소비자가 주문을 못 하게 되지는 않을까 걱정해야 합니다.
그러므로 findAvailableRestaurants()
쿼리는 다른 팀(주문 서비스 개발 팀)이 구현하고 음식점 서비스는 검색할 음식점 데이터만 제공하는 구조가 낫습니다.
CQRS
API 조합 패턴의 문제를 요약하면
- API를 조합하여 여러 서비스에 흩어진 데이터를 조회하려면 값비싸고 비효율적인 인-메모리 조인을 해야 합니다.
- 데이터를 가진 서비스는 필요한 쿼리를 효율적으로 지원하지 않은 DB에, 또는 그런 형태로 데이터를 저장합니다.
- 관심사를 분리할 필요가 있다는 것은 데이터를 가진 서비스가 쿼리 작업을 구현할 장소로 적합하지 않다는 뜻입니다.
이 세 가지 문제를 해결할 수 있는 묘안이 바로 CQRS 패턴입니다.
CQRS는 커맨드와 쿼리를 서로 분리한다
CQRS(Command and Query Responsibility Segregation)는 이름처럼 관심사의 분리/구분에 과한 패턴입니다.
조회 기능은 쿼리 쪽 모듈 및 데이터 모델에, 생성 /수정/삭제 기능은 커맨드 쪽 모듈 및 데이터 모델에 구현하는 것입니다.
양쪽 데이터 모델 사이의 동기화는 커맨드 쪽에서 발행한 이벤트를 쿼리 쪽에서 구독하는 식으로 이루어집니다.
CQRS 장점
- 마이크로서비스 아키텍처에서 효율적인 쿼리가 가능하다
→ CQRS 패턴은 여러 서비스의 데이터를 조회하는 쿼리를 효율적으로 구현할 수 있게 해 줍니다.
→ 여러 서비스에서 데이터를 미리 조인해 놓는 CQRS 뷰를 이용하는 것이 간편하고 효율적입니다. - 다양한 쿼리를 효율적으로 구현할 수 있다.
→ CQRS 패턴을 이용하면 각 쿼리가 효율적으로 구현된 하나 이상의 뷰를 정의하여 단일 데이터 저장소의 한계를 극복할 수 있습니다. - 이벤트 소싱 애플리케이션에서 쿼리가 가능하다
→ CQRS는 이벤트 소싱의 한계(기본키 쿼리만 지원)를 극복하게 해 줍니다.
→ 이벤트 소싱의 이벤트 스트림을 구독해서 항상 최신 상태를 유지합니다. - 관심사가 더 분리된다
→ 커맨드와 쿼리 쪽에 각각 알맞은 코드 모듈과 DB 스키마를 별도로 정의합니다.
→ 관심사를 분리하면 커맨드/쿼리 양쪽 모두 관리 하기 간편해지는 이점이 있습니다.
→ 쿼리를 구현한 서비스와 데이터를 소유한 서비스를 달리할 수 있습니다.
CQRS 단점
- 아키텍처가 더 복잡합니다
→ 별도의 데이터 저장소를 관리해야 하는 운영 복잡도가 증가합니다. - 복제 시차를 신경 써야 한다
→ 커맨드/쿼리 양쪽 뷰 사이의 시간 차이를 처리해야 합니다.
→ 당연히 이벤트를 받아 업데이트하는 시점 사이에 지연이 발생할 것입니다. 일관되지 않은 데이터가 최대한 사용자에게 노출되지 않도록 애플리케이션을 개발해야 합니다.
CQRS 뷰 설계
CQRS 뷰 모듈에는 하나 이상의 서비스가 발행한 이벤트를 구독해서 최신 상태로 유지된 DB를 조회하는 쿼리 API가 있습니다.
이벤트 핸들러, 쿼리 API 모듈은 데이터 접근 모듈을 통해 DB를 조회/수정합니다. 이벤트 핸들러 모듈은 구독해서 DB를 업데이트하고 쿼리 API 모듈은 데이터를 조회합니다.
뷰 모듈을 개발할 때에는 몇 가지 중요한 설계 결정을 해야 합니다.
- DB를 선정하고 스키마를 설계해야 합니다.
- 데이터 접근 모듈을 설계할 때 멱등한/동시 업데이트 등 다양한 문제를 고려해야 합니다.
- 기존 애플리케이션에 새 뷰를 구현하거나 기존 스키마를 바꿀 경우, 뷰를 효율적으로 빌드할 수 있는 수단을 강구해야 합니다.
- 뷰 클라이언트에서 복제 시차를 어떻게 처리할지 결정해야 합니다.
뷰 DB 선택
SQL 대 NoSQL DB
거의 최근까지 DB 세상은 SQL 기반의 RDBMS의 독무대였지만, 웹이 점차 확산되면서 많은 기업이 RDBMS로는 자사의 웹 확장 요건을 충족시킬 수 없다는 것을 깨닫게 되었습니다. 그 결과, NoSQL DB가 나타나게 된 것입니다.
NoSQL DB는 대부분 트랜잭션 기능이 제한적이고 범용적인 쿼리 능력은 없지만, 어떤 유스 케이스는 유연한 데이터 모델, 우수한 성능/확장성 등 SQL 기반 DB보다 더 낫습니다.
NoSQL DB는 CQRS 뷰와 잘 맞는 편입니다. NoSQL DB의 풍성한 데이터 모델과 우수한 성능 역시 CQRS 뷰에 유리합니다. 또 CQRS 뷰는 단순 트랜잭션만 사용하고 고정된 쿼리만 실행하므로 NoSQL DB의 제약 사항에도 영향을 받지 않습니다.
물론 SQL DB를 사용하여 CQRS 뷰를 구현하는 것이 타당한 경우도 있습니다. 최신 RDBMS는 예전보다 성능이 뛰어나고, 아무래도 대부분이 NoSQL보다 SQL DB가 더 익숙합니다.
아래 표에서 보시다시피 다양한 옵션이 있습니다. DB 종류마다 경계선이 흐려지면서 막상 선택을 하기가 복잡해진 부분은 있습니다.
~가 필요하면 | ~를 사용한다 | 예시 |
JSON 객체를 PK로 검색 | 문서형 스토어(MongoDB, DynamoDB) 키-값 스토어(Redis) | 고객별 MongoDB 문서로 주문 이력 관리 |
쿼리 기반의 JSON 객체 검색 | 문서형 스토어 | MongoDB, DynamoDB로 고객 뷰 구현 |
텍스트 쿼리 | 텍스트 검색 엔진(일래스틱서치) | 주문별 일래스틱서치 문서로 주문 텍스트 검색 구현 |
그래프 쿼리 | 그래프 DB(Neo4j) | 고객, 주문, 기타 데이터의 그래프로 부정 탐지 구현 |
전통적인 SQL 리포팅 | 관계형 DB | 표준 비즈니스 리포트 및 분석 |
데이터 접근 모듈 설계
이벤트 핸들러와 쿼리 API 모듈은 DB에 직접 접근하지 않습니다. 그 대신 데이터 접근 객체(DAO, Data Access Object) 및 헬퍼 클래스로 구성된 데이터 접근 모듈을 사용합니다. DAO는 고수준 코드에 쓰이는 자료형과 DB API 간 매핑, 동시 업데이트 처리 및 업데이트 멱등성 보장 등을 수행합니다.
동시성 처리
동일한 DB 레코드에 대해 DAO가 여러 동시 업데이트를 처리하는 경우가 있습니다.
예르 들어 동일한 주문을 대상으로 Order* 이벤트 핸들러와 Delivery* 이벤트 핸들러가 동일한 시간에 호출되어 해당 주문의 DB 레코드를 업데이트하는 DAO가 동시에 호출될 수 있습니다.
이때 DAO는 동시 업데이트로 서로가 서로의 데이터를 덮어쓰지 않도록 비관적 잠금 또는 낙관적 잠금을 적용해야 합니다.
멱등한 이벤트 핸들러
💡 중복 메시지 처리 부분을 확인해주세요 → [MSA] 마이크로서비스 서비스간 통신
클라이언트 애플리케이션이 최종 일관된 뷰를 사용할 수 있다
CQRS를 적용하면 커맨드 쪽을 업데이트한 직후 쿼리를 실행하는 클라이언트가 자신이 업데이트 한 내용을 바라보지 못하게 될 가능성이 있다고 했습니다. 메시징 인프라의 지연 시간은 불가피하기 때문에 이 뷰는 최종 일관됩니다.
💡 최종 일관된다는 뜻은 지금은 업데이트되지 않은 데이터를 제공하지만 결국엔(최종적으로는) 업데이트된 뷰를 제공한다는 뜻이며 Eventually Consistency라고도 합니다.
즉 업데이트된 뷰를 제공하기까지 시간이 걸릴 수 있으나 결국엔 제공된다입니다.
커맨드와 쿼리 모듈 API를 이용하면 클라이언트가 비일관성을 감지하게 만들 수 있습니다. 커맨드 쪽 작업이 클라이언트에 발행된 이벤트의 ID가 포함된 토큰을 반환하고, 클라이언트는 이 토큰을 쿼리 작업에 전달하면 해당 이벤트에 의해 뷰가 업데이트되지 않았을 경우 에러가 반환될 것입니다.
CQRS 뷰 추가 및 업데이트
CQRS 뷰는 애플리케이션이 살아 있는 동안 계속 추가/수정될 것입니다. 이때 알아야 할 이슈들이 있습니다.
아카이빙된 이벤트를 이용하여 CQRS 뷰 구축
우선 메시지 브로커는 메시지를 무기한 보관할 수 없습니다. 그러므로 뷰를 구축하기 위해 필요한 이벤트를 AWS S3 같은 곳에 아카이빙된(archived), 더 오래된 이벤트도 같이 가져와야 합니다.
아파치 스파크처럼 확장 가능한 빅데이터 기술을 응용하면 가능합니다.
CQRS 뷰를 단계적으로 구축
전체 이벤트를 처리하는 시간/리소스가 점점 증가하는 것도 뷰 생성의 또 다른 문제점입니다.
결국 언젠가 뷰는 너무 느려지고 비용도 많이 들 것입니다. 해결 방법은 2단계 증분 알고리즘을 적용하는 것입니다.
1단계는 주기적으로 스냅샷을 그 이전의 스냅샷과 이 스냅샷이 생성된 이후 발생한 이벤트를 바탕으로 계산합니다.
2단계는 이렇게 계산된 스냅샷과 그 이후 발생한 이벤트를 이용하여 뷰를 생성합니다.
CQRS 뷰 구현
모델만 분리
가장 간단하게 적용할 수 있는 일반 방식입니다.
단일 DB에 커맨드와 쿼리를 분리된 계층으로 나누는 방식입니다.
그림처럼 RDBMS는 분리하지 않고 기존 구조 그대로 유지시키고 커맨드와 쿼리만 분리합니다.
하지만 이는 DB의 성능상 문제점은 개선하지 못합니다.
일반
해당 형태는 DB를 분리하고 별도의 브로커를 통해서 이 둘 간의 데이터 동기화를 처리하는 방식입니다.
해당 구현은 데이터를 조회하려는 대상 서비스들은 각자 자신의 서비스에 맞는 저장소를 선택 할 수 있습니다.
하지만 이 구현은 동기화 처리를 위한 이벤트 핸들러들의 가용성과 신뢰도가 보장되어야 합니다.
고급
이벤트 소싱을 적용한 구조입니다.
이벤트 소싱이란 애플리케이션의 모든 행동을 이벤트로 전환해서 이벤트 스트림을 변도의 DB에 저장하는 방식입니다.
이벤트 스트림을 저장하는 DB에는 오직 데이터 추가만 가능하고 계속적으로 쌓인 데이터를 구체화시키는 시점에서 그때까지 구축된 데이터를 바탕으로 조회대상 데이터를 작성하는 방법을 말합니다.
이벤트 소싱의 이벤트 스트림은 오직 추가만 가능하고 이를 필요로 하는 시점에서 구체화 단계를 거치게 되고 이런 처리 구조가 CQRS의 커맨드 쿼리 분리 관점과 굉장히 잘 맞기 때문에 대부분 CQRS 패턴을 적용하고자 할 때 이벤트 소싱이 적용된 구조를 선택하게 됩니다.
Reference