본문 바로가기
잡다구리

7장 마이크로서비스 쿼리 구현 (Implementing queries in a microservice architecture)

by Growing! 2021. 4. 25.

DB가 하나인 모놀리식 애플리케이션에서는 쉽게 쿼리를 구현할 수 있다

  • SELECT 쿼리 작성
  • 인덱스 정의

분산 데이터와 관련해서 쿼리를 구현하는 방법이 필요하다. 여러 서비스, 여러 DB에 흩어져 있는 데이터를 조회해야 하는 경우가 많다.

MSA에서는 다음 두 가지 패턴으로 쿼리를 구현한다.

  • API 조합(composition) 패턴:
    • 클라이언트에서 여러 서비스를 직접 호출하여 그 결과를 조합
    • 단순하며 추천됨
  • CQRS 패턴 (Command Query Responsibility Segregation, 커맨드-쿼리 책임 분리) :
    • 쿼리(조회)만 할 수 있는 뷰 전용 DB를 유지하는 패턴
    • API 조합 패턴보다 강력하지만 구현은 더 복잡

7.1 API 조합 패턴 응용 쿼리

FTGO 애플리케이션의 쿼리 중에는 findOrder() 처럼 여러 서비스에 있는 데이터를 조회하는 쿼리가 있는데, MSA에서 쿼리 구현시 문제점을 알아보고, API 조합 패턴을 사용해서 구현하는 방법에 대해 알아본다.

7.1.1 findOrder() 쿼리

  • findOrder(orderId): OrderDetails
  • Frontend에서 주문 상세 정보를 표시하기 위해 호출
  • 주문 상태, 결제 상태, 식당에서의 주문 상태, 배송 상태, 배달중 위치, 배달 소요 시간, ...

모놀리식 Application: 테이블을 JOIN하여 SELECT 하는 방식으로 쉽게 조회

MSA 기반 Application: 여러 서비스에 데이터가 흩어져 있기 때문에 모든 서비스에 요청해야 함

7.1.2 API 조합 패턴 개요

  • 여러 서비스에 걸쳐 있는 데이터를 조회하는 방법 중 한 가지
  • 데이터를 가지고 있는 각 서비스를 호출하고 그 결과를 조합

참여자:

  • 프로바이더 서비스(provider service):
    • 자신이 갖고 있는 어떤 데이터를 조회해서 반환하는 서비스
  • API 조합기(composer):
    • 필요한 데이터를 조회하는 기능을 구현
    • 여러 프로바이더 서비스를 쿼리하고 그 결과를 조합
    • client, service, API gateway 등이 될 수 있음

이 패턴이 적합한지는 다음 항목에 따라 달라질 수 있다:

  • 데이터가 어떻게 파티션 되어 있는지
  • 데이터를 가진 서비스가 제공하는 API의 기능
  • 데이터가 저정되어 있는 DB가 제공하는 기능

대부분 이 패턴으로 처리할 수 있다. 하지만 프로바이더 서비스에서 반환하는 데이터를 조합하기 위해서 비효율적인 대량의 메모리 조인이 필요한 경우도 발생할 수 있다.

7.1.3 findOder() 쿼리 기능을 API 조합 패턴으로 구현하기

  • 각각의 프로바이더 서비스는 orderId로 데이터를 조회하는 REST API를 제공한다.
    • 자신이 가지고 있는 애그리거트 하나에 대한 응답을 반환한다.
  • API 조합기는 네 개의 서비스를 호출해서 그 결과를 조합한다. (+ 주문 상세를 조회할 수 있는 REST API를 노출)

7.1.4 API 조합을 적용할 때의 설계 이슈

  • 어느 컴포넌트가 API 조합기의 역할을 수행할 지 결정하기
  • 효율적으로 집계 로직을 작성하기

누가 API 조합기의 역할을 수행할 것인가? (3가지 옵션)

첫 번째: 서비스의 클라이언트가 수행

동일한 네트워크(LAN)에서 실행중이라면 효율적으로 조회할 수 있지만, 방화벽 바깥에 위치하고 네트워크가 느리다면 적절하지 않다.

클라이언트(예: 웹 애플리케이션)가 프로바이더 서비스들을 호출하여 결과를  조회한다

두 번째: API gateway가 수행

쿼리 기능이 애플리케이션의 외부 API 중 일부인 경우에는 적절하다.

외부로 부터의 요청을 내부로 전달하지 않으며, 외부에서는 한 번의 API 호출로 다수의 서비스에서 데이터를 조회할 수 있다(효율적).

API gateway가 프로바이더 서비스들을 호출하여 결과를 조합한 후 클라이언트에 응답한다

세 번째: 스탠드얼론 서비스로 구현

여러 서비스에서 내부적으로 사용하는 경우에 적합하며, API gateway로 구현하기에는 집계 로직이 너무 복잡한 경우에도 적합하다.

별도의 서비스로 구현한다

API 조합기는 리액티브 프로그래밍 모델을 사용해야 한다

  • 분산 시스템에서는 지연시간을 최소화 하는것이 단골 이슈
  • API 조합기는 가능하면 병렬로 프로바이더 서비스들을 호출해서 쿼리 기능의 응답시간을 최소화해야 한다.
  • 각 프로바이더 서비스 사이에 디펜던시가 없다면 동시에 호출할 수 있다. 하지만 의존성이 있다면 순차적으로 호출해야만 할 수도 있다.
  • 효율성을 위해 순차호출과 병렬 호출을 섞어서 수행하는 것은 매우 복잡 -> 유지보수성, 성능, 확장성을 위해 리액티브 모델을 사용하자
    • 자바의 CompletableFuture, RxJava, 기타(WebFlux, ...)

7.1.5 API 조합 패턴의 단점

  • 오버헤드 증가
  • 가용성 저하 우려
  • 데이터 일관성 결여

컴퓨팅/네트워크 오버헤드 증가

  • 모놀리식 애플리케이션은 한 번의 요청으로(대부분 DB 쿼리 한 번) 데이터를 조회
  • API 조합 패턴은 여러 번의 요청과 여러 번의 DB 쿼리를 실행 + 조합

가용성 저하 우려

  • 가용성은 참여하는 서비스가 많아질 수록 감소 (3장)
  • 하나의 쿼리에 최소한 3개의 서비스(API 조합기 + 프로바이더 서비스들)가 개입하기 때문에 가용성이 현저히 낮아짐

가용성을 높이려면:

  • API 조합기가 캐시 데이터 사용
    • 프로바이더 서비스가 다운된 경우 가용성 향상, 덤으로 성능 향상
  • 프로바이더 서비스가 다운된 경우 API 조합기가 미완성 데이터를 반환
    • 다운된 서비스의 데이터는 채우지 않고 데이터를 반환 (필수 정보가 아닌 경우)

데이터 일관성 결여

  • 모놀리식 애플리케이션은 대부분 하나의 트랜잭션으로 데이터를 쿼리
  • API 조합 패턴은 여러 DB를 대상으로 쿼리하기 때문에 일관되지 않은 데이터가 반환될 수 있음
    • 예) 주문 서비스의 주문 상태는 CANCELLED이지만 주방 서비스는 아직 취소가 되지 않은 시점
    • API 조합기가 이런 모순을 해결해야 하지만 쉽지 않고, 항상 가능하지도 않음

효율적으로 구현하기 어려운 쿼리(대량의 데이터를 메모리상에서 조인하여 집계) 작업은 CQRS 패턴으로 구현하는 것이 바람직 

7.2 CQRS 패턴

  • Command - Query Responsibility Segregation (커맨드와 쿼리의 책임을 분리)
  • 여러 서비스에서 가지고 있는 데이터가 필요하다면 이를 복제하여 읽기 전용의 뷰(테이블)를 유지하는 패턴
  • API 조합 패턴으로는 효율적인 구현이 어려운 쿼리에 사용

7.2.1 CQRS의 필요성

API 조합 패턴으로는 효율적이지 않은 쿼리가 현실적으로 많이 있음

  • 특정 속성으로 데이터를 필터링하거나 정렬을 해야 하는 경우 (모든 서비스에서 해당 속성을 가지고 있지는 않음)
    • 각각의 서비스에서 직접 쿼리를 수행할 수 없음 ->
      • 방법1) 각각의 서비스에서 데이터를 조회 -> 메인이 되는 데이터의 key를 바탕으로 나머지 서비스에서 가져온 데이터(필터링 되지 않은 전체)를 메모리 상에서 조인 => 거대한 데이터를 메모리상에서 조인
      • 방법2) 메인이 되는 서비스의 데이터를 조회 -> 이 데이터의 key로 나머지 서비스를 호출하여 조합 => 과도한 네트웍 호출 발생

  • 데이터를 가진 서비스가 쿼리에 효율적이지 않은 DB를 사용하거나, 효율적이지 않은 형태로 데이터를 저장하는 경우
    • 예) 위치 기반의 정보가 저장되어 있지만, 해당 DB에서 지리 공간의 검색을 지원하지 않는 경우
      • 방법1) 데이터를 지리 공간 쿼리를 할 수 있는 형태로 설계하여 유지
      • 방법2) 지리 공간 검색을 지원하는 DB에 복제하여 저장
  • 관심사를 분리할 필요성이 있을 때
    • 데이터를 가지고 있는 서비스는 비즈니스를 위한 쿼리를 구현하는 것이 중요한 관심사이며, 대용량의 데이터를 검색/조회하는 쿼리를 구현하는 것이 아님
    • 검색/조회 용도의 쿼리는 별도의 다른 서비스가 제공하는 것이 적합

7.2.2 CQRS 개요

CQRS는 커맨드와 쿼리를 서로 분리한다

  • 조회(R) 기능: 쿼리 쪽 모듈 및 데이터 모델에 구현
  • 생성/수정/삭제(CUD) 기능: 커맨드 쪽 모듈 및 데이터 모델에 구현
  • 데이터 모델 사이의 동기화: 커맨드 쪽에서 이벤트를 발행 -> 쿼리 쪽에서 이벤트를 구독

서비스는 다양한 CRUD 작업이 구현된 API를 가지고 있으며, Non-CQRS 혹은 CQRS 형태를 가질 수 있다.

  • Non-CQRS 서비스
    • 도메인 모델(ORM)을 사용하여 구현
    • 성능이 중요한 쿼리는 도메인 모델을 사용하지 않고 DB에 직접 쿼리
  • CQRS 기반 서비스
    • Command 모델
      • 도메인 모델(ORM)을 사용하여 CRUD를 처리하며 전용 DB가 있음
      • 조회: JOIN이 없는 단순 퀴리, PK 기반의 쿼리도 처리
      • 데이터 변경이 발생하면 도메인 이벤트를 발행
    • Query 모델
      • 복잡한 쿼리를 처리하며, 쿼리용 DB를 별도로 가짐
      • 비즈니스 규칙을 구현하지 않아도 되기 때문에 command-side 보다는 단순
      • 쿼리에 적합한 데이터베이스를 사용(N개를 가질 수도 있음)
      • 도메인 이벤트를 구독하고 데이터베이스를 갱신하는 이벤트 핸들러가 있음
      • 여러 개의 쿼리 모델을 가질 수도 있음

CQRS와 쿼리 전용 서비스

CQRS는 서비스 내에 적용할 수 있고, 별도의 쿼리 서비스로 만들 수도 있음

쿼리 전용 서비스:

  • 쿼리 작업에 대한 API만 제공 (커맨드 작업은 없음)
  • 하나 이상의 다른 서비스가 발행한 이벤트를 구독 -> DB를 최신 상태로 유지 (Near-Real Time)
  • 여러 서비스로 부터 이벤트를 수신하여 뷰를 구현
  • 이런 뷰는 특정 서비스에 종속적이지 않아 스탠드얼론 서비스로 구현하는 것이 적절
  • 어떤 서비스가 가진 데이터를 복제하는 수단으로도 유용 - 쿼리에 적합한 데이터베이스에 복제
    • 예) 지리 정보에 적합한 DB에 업데이트 (MongoDB)
    • 예) 텍스트 검색엔진에 업데이트 (ElasticSearch)

주문 이력을 쿼리하는 서비스의 설계. 다수의 서비스로 부터 이벤트를 수신하여 데이터베이스를 최신으로 유지하며, 여기서 쿼리를 수행함

7.2.3 CQRS 장점

MSA에서 쿼리를 효율적으로 구현할 수 있다

  • 여러 서비스에서 가져온 데이터를 미리 조인해 두면 쿼리를 간편하고 효율적으로 할 수 있다

다양한 쿼리를 효율적으로 구현할 수 있다

  • 단일 영속화 데이터 모델에서는 쿼리가 쉽지 않거나 불가능한 경우가 많다
  • 각 쿼리에 특화된 DB를 사용할 수 있다.
  • 쿼리 마다 효율적으로 조회할 수 있는 형태로 뷰를 정의할 수 있다.

이벤트 소싱 애플리케이션에서 쿼리가 가능하다

  • 이벤트 저장소는 PK 기반의 쿼리만 지원하는 한계가 있다
  • CQRS는 애거리거트에 대해 여러 개의 뷰를 정의할 수 있어서 한계를 해결할 수 있다
  • 이 뷰들은 이벤트 소싱 기반의 애그리거트가 발행한 이벤트 스트림을 구독하여 최신으로 유지된다.
  • 그래서 이벤트 소싱 기반의 애플리케이션은 항상 CQRS를 사용한다

관심사를 잘 분리할 수 있다

  • 커맨드와 쿼리 서비스는 각각 적합한 코드 모듈과 데이터베이스 스키마를 정의한다
  • 양쪽은 유지보수가 간단해지고 쉬워진다
  • 쿼리를 수행하는 서비스를 데이터를 가지고 있는 서비스가 아닌 다른 곳에서 구현할 수도 있다 (대용량 쿼리, 성능)

7.2.4 CQRS 단점

아키텍처가 복잡해진다

  • 뷰를 수정/조회하는 쿼리 서비스를 개발해야 한다
  • 추가적인 데이터저장소를 관리/운영해야 한다
  • 다양한 데이터베이스를 사용하는 애플리케이션은 개발하고 운영하는데 복잡도가 더 증가한다

복제 지연을 처리해야 한다

  • Lag(delay): 커맨드 업데이트 -> 이벤트 -> 쿼리 뷰 업데이트
  • 클라이언트가 애그리거트를 업데이트하고 곧바로 뷰를 조회하면 이전의 데이터가 조회됨
  • 회피 방법
    • 커맨드/쿼리의 API가 클라이언트에 버전 정보를 제공 -> 클라이언트는 쿼리한 데이터가 과거 버전인지 확인할 수 있음 (+ 최신 데이터가 조회될 때 까지 폴링)
    • UI 애플리케이션은 커맨드가 성공하면 쿼리를 다시 하여 최신 데이터를 가져오는 것이 아니라 커맨드가 응답한 데이터로 로컬의 모델을 업데이트 (+ 사용자의 행위로 쿼리가 발생하면 최신으로 업데이트 됨)

7.3 CQRS 뷰 설계하기

  • 이벤트 핸들러는 이벤트를 구독하여 뷰 데이터베이스를 업데이트하고, 쿼리 API는 뷰를 조회한다
  • CQRS 뷰 모듈은 뷰 데이터베이스와 3개의 서브 모듈로 구성된다 - 이벤트 핸들러, 쿼리 API, 데이터 접근
  • 뷰 모듈을 개발할 때의 중요한 설계 결정:
    • DB 선정, 스키마 설계
    • 데이터 접근 모듈을 설계할 때 멱등/동시 업데이트 등 다양한 문제 고려
    • 새로운 뷰를 구현하거나 기존 스키마를 변경할 경우, 뷰를 효율적으로 빌드/재빌드할 수 있는 방법
    • 복제 지연을 어떻게 처리할 지

7.3.1 뷰 DB 선택하기

  • 쿼리 작업을 효율적으로 구현할 수 있어야 한다
  • 이벤트 핸들러가 업데이트 작업을 효율적으로 처리할 수 있어야 한다

SQL vs. NoSQL 데이터베이스

  • SQL DB(RDBMS)는 만능이었지만 확장성이 부족함 -> NoSQL 등장
  • NoSQL
    • 트랜잭션 기능이 제한적 (사실상 X)
    • 범용 쿼리 불가 (JOIN 불가, 대부분 PK 기반 조회)
    • 유연한 데이터 모델
    • 우수한 성능, 확장성
    • CQRS 뷰에 적합한 경우가 많음 (강점을 주로 활용 - 풍부한 데이터 모델, 성능, 약점은 문제되지 않는 사용 패턴 - 단순 트랜잭션, 고정된 쿼리 사용)
  • SQL DB를 선택해도 좋은 경우
    • 현대적 RDBMS는 더 나은 성능
    • 개발자, 관리자, 운영자의 친숙함
    • 확장기능 - 비 관계형 기능(지리정보 유형, JSON)
  • 여러 종류의 DB들의 경계가 모호해지면서 선택이 복잡해짐

업데이트 작업 지원

  • 이벤트 핸들러가 실행하는 업데이트 작업이 효율적으로 구현되어야 한다.
  • PK or FK로 레코드를 수정/삭제할 수 있어야 한다.
    • 대부분의 NoSQL 은 FK로 업데이트 하기 쉽지 않음 -> 보조 인덱스 생성 or 매핑 테이블 관리

7.3.2 데이터 접근 모듈 설계

  • 이벤트 핸들러와 쿼리 API 모듈은 DB에 직접 접근하는 것이 아니라 데이터 접근 모듈을 사용하여 접근한다.
  • 데이터 접근 모듈은 DAO(Data Access Object)와 헬퍼 클래스로 구성된다.
    • 업데이트 작업 수행
    • 쿼리 작업 수행
    • 코드의 자료형과 DB API 사이의 매핑
    • 동시 업데이트 처리
    • 멱등성 보장

동시성 처리

  • 동일한 DB 레코드에 대해 여러개의 동시 업데이트를 처리할 수 있어야 한다 (여러 종류의 애그리거트에 대한 이벤트를 구독할 경우 발생할 수 있음)
  • DAO는 동시 업데이트로 서로가 서로의 데이터를 덮어쓰지 않도록 작성되어야 한다 - 낙관적 or 비관적 잠금 사용

멱등한 이벤트 핸들러

  • 이벤트 핸들러에는 동일한 이벤트가 한 번 이상 전달될 수 있고, 과거의 이벤트가 다시 전달될 수도 있다(과거의 상태로 돌아갈 수 있음)
  • 쿼리 쪽 이벤트 핸들러는 이벤트가 중복으로 전달되어도 결과가 동일해야(멱등) 한다.
  • 이벤트 핸들러는 반드시 이벤트 ID를 기록하고, 중복 이벤트가 들어오면 솎아내야 한다.

클라이언트 애플리케이션이 궁극적으로 일관된 뷰를 사용할 수 있게 하기

  • 클라이언트가 커맨드의 업데이트 직후 쿼리를 수행하면, 자신이 발생시킨 변경사항을 볼 수 없을 가능성이 있다.
  • 메시징 인프라의 지연시간은 피할 수 없고, 궁극적으로는 일관된 뷰가 된다.
  • 커맨드 모듈의 API가 클라이언트에 이벤트의 ID 정보를 포함해서 반환하고, 클라이언트가 쿼리 모듈의 API에 이것을 전달하면 일관되지 않은 상태인지 확인할 수 있다.

7.3.3. CQRS 뷰를 추가하고 업데이트 하기

  • 애플리케이션의 생애 동안 CQRS 뷰는 계속 추가되고 수정된다.
    • 추가: 새 쿼리의 지원
    • 재생성: 스키마 변경, 뷰 업데이트 로직 수정 등
  • 뷰를 추가할 때의 작업 순서 (개념적):
    • 쿼리 측 모듈 개발 -> 데이터스토어 세팅 -> 서비스 배포 -> 쿼리 측 이벤트 핸들러가 모든 이벤트를 처리 ----> 뷰는 궁극적으로 최신의 상태가 됨
    • 하지만 실제에서는 잘 동작하지 않음 (다음과 같은 이슈)

CQRS 뷰를 구축할 때 아카이빙된 이벤트를 사용하기

  • 메시지 브로커는 메시지를 무기한 보관할 수 없으므로, 이벤트를 메시지 브로커에서만 읽어서는 뷰를 구축할 수 없다.
  • S3 같은 곳에 저장해 둔 오래된 이벤트들도 읽어와야 뷰를 구축할 수 있다.

CQRS 뷰를 증분으로 구축하기

  • 뷰를 생성하는 것의 또 다른 문제는, 전체 이벤트를 처리하는데 걸리는 시간과 리소스가 시간이 지날수록 증가한다(이벤트가 계속 쌓이니까).
  • 해결책: 2단계의 증분 알고리즘을 사용
    • 1단계: 주기적으로 각 애그리거트의 스냅샷을 계산( = 이전 스냅샷 + 이전 스냅샷 이후에 발생한 이벤트들)
    • 2단계: (최종 스냅샷 + 최종 스냅샷 이후에 발생한 이벤트들)을 사용하여 뷰를 생성

7.4 CQRS 뷰 구현: AWS DynamoDB 응용

7.5 Summary

  • 여러 서비스로 부터 각각의 데이터를 조회하는 것은 쉽지 않다.
  • 이런 쿼리를 조회하는 두 가지 방법은 API 조합 패턴과 CQRS 패턴을 사용하는 것이다.
  • API 조합 패턴: 쿼리를 가장 간단하게 구현할 수 있으며, 가급적 사용하라.
  • API 조합 패턴에서 복잡한 쿼리들은 대량의 데이터를 메모리에서 조인해야 하는 비효율이 발생할 수 있다.
  • CQRS 패턴은 뷰 데이터베이스에서 쿼리하는 방법이며, 더 강력하지만 구현은 더 복잡하다.
  • CQRS 뷰 모듈은 동시 업데이트를 처리할 수 있어야 하고, 중복 이벤트를 감지하고 솎아낼 수 있어야 한다.
  • CQRS는 데이터를 가지고 있는 서비스와는 다른 서비스에서 쿼리를 구현할 수 있으므로 관심사를 잘 분리할 수 있다.
  • 클라이언트는 CQRS 뷰의 궁극적 일관성을 반드시 처리해야 한다.

 

댓글