본문 바로가기

카테고리 없음

마이크로서비스 쿼리 구현(Microservices Patterns)

 
마이크로서비스 패턴(Microservices Patterns) by Chris Richardson 책의 7장 마이크로서비스 쿼리 구현을 개괄적으로 정리한 내용입니다.
 
마이크로서비스 아키텍쳐에서는 여러 서비스에 흩어져 있는 데이터를 쿼리하기 어려움
  • 여러 DB에 분산된 데이터를 조회해야 하는데 기존 분산쿼리 매커니즘은 기술적으로 가능하다 해도 캡슐화에 위배되어 사용할 수 없음
마이크로서비스 아키텍쳐에서의 쿼리 구현
  • API 조합(composition) 패턴
    • 클라이언트가 여러 서비스 직접 호출하여 조합
    • 가장 단순함
  • CQRS(Command and Query Responsibility Segregation) 패턴
    • 쿼리만 지원하는 하나 이상의 뷰 전용 DB를 유지
    • API 조합 패턴보다 강력하나 구현이 더 복잡

 

7.1 API 조합 패턴 응용 쿼리

findOrder() 쿼리
  • orderId를 매개변수로 주문 내역이 포함된 OrderDetails 반환
  • 주문 상태 뷰가 구현된 모바일 기기 또는 웹 애플리케이션의 frontend module이 이 메서드 호출
    • 주문, 주방, 배달, 회계 서비스 등의 정보 fetch
  • 참여자(API 조합기와 둘 이상의 provider service로 구성)
    • API 조합기: Provider 서비스를 쿼리하여 데이터 조회
      • web app 처럼 웹 페이지에 렌더링 하는 클라이언트
      • 쿼리 작업을 API endpoint로 표출한 API gateway
      • backend for fronted 등
    • Provider Service: 최종 결과로 반환할 데이터의 일부를 갖고 있는 서비스
API 조합 설계 이슈
  • 어느 컴포넌트를 쿼리 작업의 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장 게이트웨이 패턴참조)
API 조합 패턴의 장단점
  • 장점
    • 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 대상이므로 일관되지 않을 수 있음

 

7.2 CQRS 패턴

여러 서비스에 있는 데이터를 가져오는 쿼리는 이벤트를 이용하여 해당 서비스의 데이터를 복제한 읽기 전용 뷰를 유지

CQRS의 필요성
  • API 조합 패턴만으로 효율적으로 구현하기 어려운 다중 서비스 쿼리가 많음
    • 모든 서비스가 필터/정렬 용도의 속성을 보관하지 않는 경우
      • API 조합기는 2가지 방법(데이터를 in-memory join, 순차적으로 다른 서비스 데이터 요청)으로 해결할 수도 있는데, 데이터가 많으면 효율이 급격히 떨어지고 대량 조회 API 제공하지 않는 경우 과도하게 네트워크 트래픽 유발할 수 있음
    • (예를 들어 메뉴 키워드로 쿼리하는 조합기의 경우 각 서비스가 모두 메뉴 키워드로 필터할 수 없을 수 있음)
    • 하나의 서비스에 국한된 쿼리도 구현하기 어려울 수 있음
      • 데이터 가진 서비스에 쿼리 구현하는 것이 부적절한 경우(관심사 분리 필요)
        • 음식점 서비스에서 대용량 데이터 조회 쿼리까지 구현하는 책임까지 지면 안됨(이런건 주문 서비스 개발 팀이 구현)
      • 서비스 db가 효율적인 쿼리 지원하지 않을 시(텍스트 검색해야 하는데 해당 검색 기능이 없는 db 사용 등)
  • CQRS는 다음의 세가지 문제를 해결한다!
    • API를 조합하여 여러 서비스에 흩어진 데이터 조회하려면 값비싸고 비효율적인 in-memory join을 해야함
    • 데이터 가진 서비스는 필요한 쿼리를 효율적으로 지원하지 않는 DB에, 또는 그런 형태로 데이터 저장
    • 관심사 분리할 필요가 있다는 것은 데이터를 가진 서비스가 쿼리 작업을 구현할 장소로 적합하지 않다는 뜻

 

CQRS는 커맨드와 쿼리를 서로 분리한다
  • 영속적 데이터 모델과 그것을 사용하는 모듈을 커맨드와 쿼리로 분리
  • 양쪽 데이터 모델 사이의 동기화는 커맨드 쪽에서 발행한 이벤트를 쿼리 쪽에서 구독하는 식으로 이루어짐
    • 비CQRS 서비스에서는 보통 DB에 매핑된 도메인 모델로 구현하는데, 성능이 중요하면 도메인 모델을 건너뛰고 직접 DB에 접속하기도 함. 즉, 하나의 영속적 데이터 모델이 커맨드와 쿼리를 모두 지원하는 것
    • CQRS 서비스는 커맨드 쪽이 데이터가 바뀔 때마다 (이벤추에이트 트램이나 이벤트 소싱 등의 프레임워크를 이용하여) 도메인 이벤트를 발행함
  • 쿼리 서비스
    • 별도로 나눠진 쿼리 모델은 다소 복잡한 쿼리를 처리
    • 쿼리 쪽은 반드시 지원해야 하는 쿼리에 대해서 모든 종류의 DB를 지원함
    • 쿼리 쪽은 도메인 이벤트를 구독하고 DB(들)을 업데이트하는 이벤트 핸들러가 있음
    • 커맨드 작업이 전혀 없는 쿼리 작업만으로 구성된 API가 있고, 하나 이상의 다른 서비스가 발행한 이벤트를 구독하여 항상 최산 상태로 유지되는 DB를 쿼리하는 로직이 구현되어 있음
    • 쿼리 쪽 서비스는 여러 서비스가 발행한 이벤트를 구독해서 구축된 뷰를 구현하기 좋은 방법
    • 이런 뷰는 특정 서비스에 종속되지 않기 때문에 standalone 서비스로 구현하는 것이 타당

 

CQRS의 장단점
  • 장점
    • msa에서 쿼리를 효율적으로 구현
      • 여러 서비스에서 데이터를 미리 조인해 놓는 CQRS 뷰를 이용하는 것이 효율적(대용량 데이터 in-memory join보다 훨씬 효율적)
    • 다양한 쿼리를 효율적으로 구현
      • 단일 영속화 데이터 모델만으로는 갖가지 종류의 쿼리를 지원하기 어렵고 불가능한 경우도 있음
      • 일부 NoSQL DB는 쿼리 능력이 매우 제한적
      • 특정 유형의 쿼리를 지원하는 확장팩이 DB에 설치되어 있어도 특화된 DB를 사용하는 것이 더 효율적(단일 데이터 저장소의 한계를 극복)
    • 이벤트 소싱 애플리케이션에서 쿼리 가능
      • 이벤트 소싱의 중요한 한계(이벤트 저장소는 기본키 쿼리만 지원)를 극복하게 해줌
      • 하나 이상의 애그리거트 뷰를 정의하고 이벤트 소싱 기반의 애그리거트가 발행한 이벤트 스트림을 구독해서 항상 최신 상태 유지
    • 관심사가 더 분리
      • CQRS는 서비스의 커맨드/쿼리 각각에 알맞은 코드 모듈과 DB 스키마를 별도로 정의
      • 관심사를 분리하면 커맨드/쿼리 양 쪽이 모두 관리하기 간편해짐
  • 단점
    • 아키텍처가 더 복잡함
      • 뷰를 조회/수정하는 쿼리 서비스 별도 작성해야 함
      • 별도 데이터 저장소 관리해야 하는 운영 복잡도 가중
      • 종류가 다양한 DB 사용하면 개발/운영 복잡도는 더 가중
    • 복제 시차(replication lag) 처리 필요
      • 커맨드/쿼리 뷰 사이의 시차(lag)를 처리해야 함
        • 일관되지 않은 데이터가 최대한 사용자에게 노출되지 않도록 애플리케이션을 개발해야 함
      • 해결 방법?
        • 커맨드/쿼리 양쪽 API가 클라이언트에 버전 정보를 함께 전달
        • native mobile app or SPA UI 애플리케이션은 쿼리 하지 않고 커맨드 성공하면 자신의 로컬 모델 업데이트(모델을 업데이트하려면 UI 코드가 서버 쪽 코드를 복제해야 하는 단점이 있음)

 

7.3 CQRS 뷰 설계

뷰 모듈의 구성
  • 뷰 DB와 세 하위 모듈로 구성
    • 뷰 DB
    • 모듈
      • 데이터 접근(데이터 접근 로직 구현)
      • 이벤트 핸들러(command 이벤트 consume하여 뷰DB update)
      • 쿼리 API(뷰DB에서 쿼리)
뷰 모듈 설계 시 주의사항
  • 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
      • (표준 비지니스 리포트 및 분석)
  • 업데이트 작업 지원
    • 이벤트 핸들러는 뷰 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 뷰 추가 및 업데이트
  • 뷰 추가/수정 작업은 운영 중에 계속해서 발생할 수 있음
    • 뷰 추가
      • 쿼리 쪽 모듈 개발 -> 데이터 저장소 세팅 -> 서비스 배포
      • 쿼리 모듈의 이벤트 핸들러가 모든 이벤트를 처리하고 뷰는 언젠가 최신 상태가 됨
    • 뷰 수정
      • 이벤트 핸들러 변경 -> 뷰 재생성(이 방법은 실제로는 잘 안됨)
  • 아카이빙된 이벤트 이용하여 CQRS 뷰 구축
    • 전제
      • 메시지 브로커는 메시지를 무기한 보관할 수 없음
      • RabbitMQ는 컨슈머가 메시지 처리 직후 메시지 삭제
      • Apache Kafka는 미리 설정된 시간 동안 메시지를 보관 가능(이것도 영구 보관은 아님)
      • 따라서 필요한 이벤트를 메시지 브로커에서 전부 읽기만 해서는 뷰를 구축할 수 없음
    • AWS S3 같은 곳에 archived 된 더 오래된 이벤트도 같이 가져와야 함
      • Apache Spark 처럼 확장 가능한 빅데이터 기술 응용하면 가능
  • CQRS 뷰를 단계적으로 구축
    • 전체 이벤트를 처리하는 시간/리소스가 점점 증가하는 것도 뷰 생성의 또 다른 문제점
    • 결국 언젠가는 뷰는 너무 느려지고 비용도 많이 들게 됨
    • 해결방법
      • 2단계 증분 알고리즘(two-step incremental algorithm) 적용
        • 1단계: 주기적으로 각 애그리거트 인스턴스의 스냅샷을 그 이전의 스냅샷과 해당 스냅샷이 생성된 이후 발생한 이벤트를 바탕으로 계산
        • 2단계: 계산된 스냅샷과 이후 발생한 이벤트를 이용하여 뷰 생성