마이크로서비스 패턴(Microservices Patterns) by Chris Richardson 책의 7장 마이크로서비스 쿼리 구현을 개괄적으로 정리한 내용입니다.
- 여러 DB에 분산된 데이터를 조회해야 하는데 기존 분산쿼리 매커니즘은 기술적으로 가능하다 해도 캡슐화에 위배되어 사용할 수 없음
- API 조합(composition) 패턴
- 클라이언트가 여러 서비스 직접 호출하여 조합
- 가장 단순함
- CQRS(Command and Query Responsibility Segregation) 패턴
- 쿼리만 지원하는 하나 이상의 뷰 전용 DB를 유지
- API 조합 패턴보다 강력하나 구현이 더 복잡
- orderId를 매개변수로 주문 내역이 포함된 OrderDetails 반환
- 주문 상태 뷰가 구현된 모바일 기기 또는 웹 애플리케이션의 frontend module이 이 메서드 호출
- 주문, 주방, 배달, 회계 서비스 등의 정보 fetch
- 참여자(API 조합기와 둘 이상의 provider service로 구성)
- API 조합기: Provider 서비스를 쿼리하여 데이터 조회
- web app 처럼 웹 페이지에 렌더링 하는 클라이언트
- 쿼리 작업을 API endpoint로 표출한 API gateway
- backend for fronted 등
- Provider Service: 최종 결과로 반환할 데이터의 일부를 갖고 있는 서비스
- API 조합기: Provider 서비스를 쿼리하여 데이터 조회
- 어느 컴포넌트를 쿼리 작업의 API 조합기로 선정할 것인가?
- 서비스 클라이언트를 API 조합기로 사용
- (클라이언트가 동일한 LAN에서 실행중이라면 효율적이지만, 클라이언트가 방화벽 외부에 있고 서비스가 위치한 네트워크가 느리면 실용적이진 않음)
- 애플리케이션의 외부 API가 구현된 API 게이트웨이를 API 조합기로 만듦
- 다른 서비스로 요청 보내는 대신 API 게이트웨이에서 차라리 조합 로직을 구현하는 것
- 모바일 기기 등 방화벽 외부에서 접근하는 클라이언트가 API 호출 한 번으로 여러 서비스의 데이터 조회할 수 있어 효율적
- API 조합기를 standalone 서비스로 구현
- 내부적으로 여러 서비스가 사용하는 쿼리라면 좋음
- 취합 로직이 너무 복잡해서 API 게이트웨이 일부로 만들기는 곤란하고, 외부에서 접근 가능한 쿼리 작업을 구현할 경우에도 좋음
- 어떻게 해야 효율적으로 취합 로직을 작성할 것인가?
- API 조합기는 reactive programming model을 사용해야 함
- 분산 시스템 개발 시 latency를 최소화하는 것이 항상 골칫거리
- 최소화하려면 가능한 API 조합기가 provider service를 병렬 호출해야 함
- 하지만 의존관계가 있다면 일부 provider service들을 순차 호출해야 함
- 순차/병렬 서비스 호출이 뒤섞이면 실행 로직 복잡해짐
- 성능/확장성 우수한 API 조합기 작성하려면 자바의 CompletableFuture, RxJava의 observable, 또는 동등한 추상체에 기반한 리액티브 설계 기법 동원해야 함(8장 게이트웨이 패턴참조)
- 장점
- msa에서 쉽고 단순하게 쿼리 작업 구현
- 단점
- 효율적으로 구현하기 어려운(애그리거트가 거대한 데이터를 비효율적으로 in-memory join 해야하는) 경우 CQRS로 구현하는 것이 바람직
- 오버헤드 증가
- 여러 서비스 호출, 여러 DB 쿼리하면 오버헤드 증가
- (모놀리식 app은 클라이언트가 한 번 요청 또는 대부분 db 쿼리문 하나로 조회)
- 가용성 저하 우려
- 더 많은 서비스가 개입할수록 가용성은 감소
- 개별 서비스 가용성이 99.5%면 provider service 4개 호출 시 99.5%(4+1) = 97.5%
- 가용성 높이려면?
- provider service 불능 시 API 조합기가 이전에 캐시한 데이터 반환
- API 조합기가 미완성 데이터 반환
- 데이터 일관성 결여
- 모놀리식은 대부분 한 트랜잭션에서 수행되지만, API 조합 패턴은 여러 DB 대상이므로 일관되지 않을 수 있음
여러 서비스에 있는 데이터를 가져오는 쿼리는 이벤트를 이용하여 해당 서비스의 데이터를 복제한 읽기 전용 뷰를 유지
- API 조합 패턴만으로 효율적으로 구현하기 어려운 다중 서비스 쿼리가 많음
- 모든 서비스가 필터/정렬 용도의 속성을 보관하지 않는 경우
- API 조합기는 2가지 방법(데이터를 in-memory join, 순차적으로 다른 서비스 데이터 요청)으로 해결할 수도 있는데, 데이터가 많으면 효율이 급격히 떨어지고 대량 조회 API 제공하지 않는 경우 과도하게 네트워크 트래픽 유발할 수 있음
- (예를 들어 메뉴 키워드로 쿼리하는 조합기의 경우 각 서비스가 모두 메뉴 키워드로 필터할 수 없을 수 있음)
- 하나의 서비스에 국한된 쿼리도 구현하기 어려울 수 있음
- 데이터 가진 서비스에 쿼리 구현하는 것이 부적절한 경우(관심사 분리 필요)
- 음식점 서비스에서 대용량 데이터 조회 쿼리까지 구현하는 책임까지 지면 안됨(이런건 주문 서비스 개발 팀이 구현)
- 서비스 db가 효율적인 쿼리 지원하지 않을 시(텍스트 검색해야 하는데 해당 검색 기능이 없는 db 사용 등)
- 데이터 가진 서비스에 쿼리 구현하는 것이 부적절한 경우(관심사 분리 필요)
- 모든 서비스가 필터/정렬 용도의 속성을 보관하지 않는 경우
- CQRS는 다음의 세가지 문제를 해결한다!
- API를 조합하여 여러 서비스에 흩어진 데이터 조회하려면 값비싸고 비효율적인 in-memory join을 해야함
- 데이터 가진 서비스는 필요한 쿼리를 효율적으로 지원하지 않는 DB에, 또는 그런 형태로 데이터 저장
- 관심사 분리할 필요가 있다는 것은 데이터를 가진 서비스가 쿼리 작업을 구현할 장소로 적합하지 않다는 뜻
- 영속적 데이터 모델과 그것을 사용하는 모듈을 커맨드와 쿼리로 분리
- 양쪽 데이터 모델 사이의 동기화는 커맨드 쪽에서 발행한 이벤트를 쿼리 쪽에서 구독하는 식으로 이루어짐
- 비CQRS 서비스에서는 보통 DB에 매핑된 도메인 모델로 구현하는데, 성능이 중요하면 도메인 모델을 건너뛰고 직접 DB에 접속하기도 함. 즉, 하나의 영속적 데이터 모델이 커맨드와 쿼리를 모두 지원하는 것
- CQRS 서비스는 커맨드 쪽이 데이터가 바뀔 때마다 (이벤추에이트 트램이나 이벤트 소싱 등의 프레임워크를 이용하여) 도메인 이벤트를 발행함
- 쿼리 서비스
- 별도로 나눠진 쿼리 모델은 다소 복잡한 쿼리를 처리
- 쿼리 쪽은 반드시 지원해야 하는 쿼리에 대해서 모든 종류의 DB를 지원함
- 쿼리 쪽은 도메인 이벤트를 구독하고 DB(들)을 업데이트하는 이벤트 핸들러가 있음
- 커맨드 작업이 전혀 없는 쿼리 작업만으로 구성된 API가 있고, 하나 이상의 다른 서비스가 발행한 이벤트를 구독하여 항상 최산 상태로 유지되는 DB를 쿼리하는 로직이 구현되어 있음
- 쿼리 쪽 서비스는 여러 서비스가 발행한 이벤트를 구독해서 구축된 뷰를 구현하기 좋은 방법
- 이런 뷰는 특정 서비스에 종속되지 않기 때문에 standalone 서비스로 구현하는 것이 타당
- 장점
- msa에서 쿼리를 효율적으로 구현
- 여러 서비스에서 데이터를 미리 조인해 놓는 CQRS 뷰를 이용하는 것이 효율적(대용량 데이터 in-memory join보다 훨씬 효율적)
- 다양한 쿼리를 효율적으로 구현
- 단일 영속화 데이터 모델만으로는 갖가지 종류의 쿼리를 지원하기 어렵고 불가능한 경우도 있음
- 일부 NoSQL DB는 쿼리 능력이 매우 제한적
- 특정 유형의 쿼리를 지원하는 확장팩이 DB에 설치되어 있어도 특화된 DB를 사용하는 것이 더 효율적(단일 데이터 저장소의 한계를 극복)
- 이벤트 소싱 애플리케이션에서 쿼리 가능
- 이벤트 소싱의 중요한 한계(이벤트 저장소는 기본키 쿼리만 지원)를 극복하게 해줌
- 하나 이상의 애그리거트 뷰를 정의하고 이벤트 소싱 기반의 애그리거트가 발행한 이벤트 스트림을 구독해서 항상 최신 상태 유지
- 관심사가 더 분리
- CQRS는 서비스의 커맨드/쿼리 각각에 알맞은 코드 모듈과 DB 스키마를 별도로 정의
- 관심사를 분리하면 커맨드/쿼리 양 쪽이 모두 관리하기 간편해짐
- msa에서 쿼리를 효율적으로 구현
- 단점
- 아키텍처가 더 복잡함
- 뷰를 조회/수정하는 쿼리 서비스 별도 작성해야 함
- 별도 데이터 저장소 관리해야 하는 운영 복잡도 가중
- 종류가 다양한 DB 사용하면 개발/운영 복잡도는 더 가중
- 복제 시차(replication lag) 처리 필요
- 커맨드/쿼리 뷰 사이의 시차(lag)를 처리해야 함
- 일관되지 않은 데이터가 최대한 사용자에게 노출되지 않도록 애플리케이션을 개발해야 함
- 해결 방법?
- 커맨드/쿼리 양쪽 API가 클라이언트에 버전 정보를 함께 전달
- native mobile app or SPA UI 애플리케이션은 쿼리 하지 않고 커맨드 성공하면 자신의 로컬 모델 업데이트(모델을 업데이트하려면 UI 코드가 서버 쪽 코드를 복제해야 하는 단점이 있음)
- 커맨드/쿼리 뷰 사이의 시차(lag)를 처리해야 함
- 아키텍처가 더 복잡함
- 뷰 DB와 세 하위 모듈로 구성
- 뷰 DB
- 모듈
- 데이터 접근(데이터 접근 로직 구현)
- 이벤트 핸들러(command 이벤트 consume하여 뷰DB update)
- 쿼리 API(뷰DB에서 쿼리)
- DB를 선정하고 스키마 설계
- 데이터 접근 모듈 설계 시 멱등한/동시 업데이트 등 다양한 문제 고려
- 기존 애플리케이션에 새 뷰를 구현하거나 기존 스키마를 바꿀 경우 뷰를 효율적으로 (재)빌드할 수 있는 수단 강구
- 뷰 클라이언트에서 복제 시차를 어떻게 처리할지 결정
뷰 모듈의 쿼리 작업을 효율적으로 구현하는 것이 중요하지만, 이벤트 핸들러가 수행하는 업데이트 작업도 효율적으로 지원 가능해야 함
- SQL vs NoSQL
- 풍성한 데이터 모델과 우수한 성능 역시 CQRS 뷰에 유리
- CQRS 뷰는 단순 트랜잭션만 사용하고 고정된 쿼리만 실행하므로 NoSQL DB의 제약사항에도 영향을 받지 않음
- 예시
- JSON 객체를 PK로 검색
- 문서형 스토어(MongoDB, DynamoDB)
- (고객별 MongoDB 문서로 주문 이력 관리)
- 키-값 스토어(redis)
- 쿼리 기반의 JSON 객체 검색
- 문서형 스토어
- (MongoDB, DynamoDB로 고객 뷰 구현)
- 텍스트 쿼리
- 텍스트 검색 엔진(elastic search)
- (주문별 elastic search 문서로 주문 텍스트 검색 구현)
- 그래프 쿼리
- 그래프 DB(Neo4j)
- (고객, 주문, 기타 데이터의 그래프로 부정 탐지 구현)
- 전통적인 SQL 리포팅/BI
- 관계형 DB
- (표준 비지니스 리포트 및 분석)
- JSON 객체를 PK로 검색
- 업데이트 작업 지원
- 이벤트 핸들러는 뷰 DB에 있는 레코드를 기본키로 찾아 수정/삭제하지만, 외래키를 이용하는 경우도 있음
- 1:N 관계에서 N 쪽을 업데이트 해야 하면 문제가 있을 수 있음
- RDBMS, MongoDB는 필요한 컬럼에 인덱스 생성
- 다른 NoSQL에서는 non-primary key로 업데이트하기가 쉽지 않음
- 데이터 접근 객체(DAO) 및 helper class로 구성된 데이터 접근 모듈 사용
- 이벤트 핸들러와 쿼리 API 모듈은 DB에 직접 접근하지 않음
- 이벤트 핸들러가 호출한 업데이트 작업과 쿼리 모듈이 호출한 쿼리 작업을 실질적으로 수행
- 이외에도 고수준 코드에 쓰이는 자료형, DB API 간 매핑, 동시 업데이트 처리, 업데이트 멱등성 보장 등 DAO가 하는 일이 많음
- 동시성 처리
- 특정 하나의 애그리거트 인스턴스가 발행한 이벤트는 순차 처리 되므로 동시 업데이트 되진 않음
- 여러 종류의 애그리거트가 발행한 이베느를 뷰가 구독할 경우, 여러 이벤트 핸들러가 동일한 레코드에 업데이트할 수도 있음
- 예를 들어 Order 이벤트 핸들러와 Delivery 이벤트 핸들러가 동일한 시간에 호출되어 해당 주문의 DB 레코드를 업데이트하는 DAO가 동시 호출될 수도 있음
- 동시 업데이트로 서로가 서로의 데이터를 덮어 쓰지 않도록 작성되어야 함
- 낙관적 / 비관적 잠금을 적용해야 함
- 멱등한 이벤트 핸들러
- 같은 이벤트를 한 번 이상 넘겨받고 호출될 수 있음
- 은행 잔고 증가시키는 이벤트 핸들러는 멱등하지 않으므로, 비멱등적 이벤트 핸들러는 자신이 뷰 데이터 저장소에서 처리한 이벤트ID를 기록해두었다가 중복 이벤트가 들어오면 솎아내야 함
- 즉, 이벤트 핸들러는 반드시 이벤트ID를 기록하고 데이터 저장소를 원자적으로 업데이트해야 함
- RDBMS면 완료한 이벤트를 뷰 업데이트 트랜잭션의 일부로 PROCESSED_EVENTS 테이블에 삽입
- NoSQL이면 이벤트 핸들러는 자신이 업데이트하는 데이터 저장소 '레코드'(e.g. MongoDB의 문서, DynamoDB의 테이블 아이템)에 이벤트를 저장해야 함
- 이벤트 핸들러가 모든 이벤트ID를 일일이 기록할 필요는 없음
- 이벤추에이트처럼 이벤트ID가 하나씩 증가하는 구조라면 주어진 애그리거트 인스턴스에서 전달받은 max(eventId)를 각 레코드에 저장하면 됨
- 레코드가 단일 애그리거트 인스턴스에 해당된다면 이벤트 핸들러는 max(eventId)만 기록하면 됨
- 여러 애그리거트 이벤트가 조합된 결과를 나타내는 레코드는 [aggregate type, aggregate ID] -> max(eventId) 맵을 담고 있어야 함
- 클라이언트 애플리케이션이 최종 일관된 뷰를 사용할 수 있다
- update <-> 쿼리 lag 발생 가능성
- 메시징 인프라의 지연 시간은 불가피하기 때문에 이 뷰는 최종 일관됨
- 커맨드와 쿼리 모듈 API를 이용하면 클라이언트가 비일관성을 감지하게 만들 수 있음
- 커맨드 작업이 클라이언트에 발행된 이벤트 ID가 포함된 토큰을 반환하고, 클라이언트는 이 토큰을 쿼리 작업에 전달하면 해당 이벤트에 의해 뷰가 업데이티되지 않았을 경우 에러 반환됨
- (이런 중복 이벤트 감지 메커니즘을 뷰 모듈에 구현할 수 있음)
- 뷰 추가/수정 작업은 운영 중에 계속해서 발생할 수 있음
- 뷰 추가
- 쿼리 쪽 모듈 개발 -> 데이터 저장소 세팅 -> 서비스 배포
- 쿼리 모듈의 이벤트 핸들러가 모든 이벤트를 처리하고 뷰는 언젠가 최신 상태가 됨
- 뷰 수정
- 이벤트 핸들러 변경 -> 뷰 재생성(이 방법은 실제로는 잘 안됨)
- 뷰 추가
- 아카이빙된 이벤트 이용하여 CQRS 뷰 구축
- 전제
- 메시지 브로커는 메시지를 무기한 보관할 수 없음
- RabbitMQ는 컨슈머가 메시지 처리 직후 메시지 삭제
- Apache Kafka는 미리 설정된 시간 동안 메시지를 보관 가능(이것도 영구 보관은 아님)
- 따라서 필요한 이벤트를 메시지 브로커에서 전부 읽기만 해서는 뷰를 구축할 수 없음
- AWS S3 같은 곳에 archived 된 더 오래된 이벤트도 같이 가져와야 함
- Apache Spark 처럼 확장 가능한 빅데이터 기술 응용하면 가능
- 전제
- CQRS 뷰를 단계적으로 구축
- 전체 이벤트를 처리하는 시간/리소스가 점점 증가하는 것도 뷰 생성의 또 다른 문제점
- 결국 언젠가는 뷰는 너무 느려지고 비용도 많이 들게 됨
- 해결방법
- 2단계 증분 알고리즘(two-step incremental algorithm) 적용
- 1단계: 주기적으로 각 애그리거트 인스턴스의 스냅샷을 그 이전의 스냅샷과 해당 스냅샷이 생성된 이후 발생한 이벤트를 바탕으로 계산
- 2단계: 계산된 스냅샷과 이후 발생한 이벤트를 이용하여 뷰 생성
- 2단계 증분 알고리즘(two-step incremental algorithm) 적용