[DDD&MSA] 마이크로서비스 애플리케이션 패턴
Book/DDD & MSA

[DDD&MSA] 마이크로서비스 애플리케이션 패턴

마이크로서비스 애플리케이션 패턴은 실제로 개발자가 구현해야 할 애플리케이션 영역에서, 좋은 마이크로서비스 애플리케이션을 구성하기 위한 패턴이다.

마이크로서비스의 구성과 관계를 설계할 때도 유연성, 확장성, 독립성 등을 고려해야 한다.

 

하나의 업무 기능은 보통 프론트엔드와 백엔드의 연계로 구현된다.

백엔드의 업무 기능 하나가 변경되어 재배포가 필요할 때, 프론트엔드가 클래식한 단일 모노리스로 구성되어 있다면, 프론트엔드는 하나의 덩어리이기 때문에 재배포가 필요없는(변경이 없는) 기능들 까지도 함께 빌드하고 배포하여야 한다.

이는 백엔드가 모노리스로 구성되었을 때와 똑같은 문제를 안게 된다.


UI 컴포지트 패턴 또는 마이크로 프론트엔드

이를 위한 해결 방안이 UI 컴포지트(Composite) 패턴과 마이크로 프론트엔드(Micro Frontend)라고 하는 패턴이다.

프론트엔드도 백엔드 마이크로서비스처럼 기능별로 분리하고 이를 조합하기 위한 프레임(Frame) 형태의 부모 창을 통해 각 프론트엔드를 조합해서 동작하게 한다. 이 부모 서비스는 틀만 가지고 있고, 실제 각 기능 표현은 마이크로 프론트엔드 조각이 구현하게 한다.

위 그림처럼 각 서비스 팀 별로 별도의 독립된 소스 레포지토리에 백엔드 마이크로서비스와 마이크로 프론트를 관리하고, 이를 독립적으로 빌드/배포할 수 있기 때문에 자율적으로 서비스를 개선할 수 있다.

 

아래 그림은 아마존닷컴 온라인 몰의 사례이다.

메인 화면을 여러 개의 조각으로 구성하고, 각 조각은 여러 개의 마이크로 프론트엔드의 조합으로 서비스를 제공한다. 따라서 하나의 기능이 변경될 때, 이를 제공하는 마이크로 프론트엔드와 백엔드를 구성하는 마이크로서비스가 모두 변경되고 배포된다. 배포 시 잠시 오류가 발생하여도 메인 화면을 제공하는 부모 창은 사용자가 알아채지 못하게 화면 구성을 재배열 하는 역할도 수행한다.


마이크로서비스 통신 패턴

프론트엔드와 백엔드, 백엔드 간의 마이크로서비스 호출 시 통신 방식은 동기 통신 방식, 비동기 통신 방식이 있다.


동기 통신 방식

동기 호출 방식은 클라이언트에서 서버 측에 존재하는 마이크로서비스 REST API를 호출할 때 사용되는 기본 통신 방법이며, 다양한 클라이언트 채널 연계나 라우팅 및 로드 밸런싱을 원활하게 하기 위한 방법으로, 중간에 API 게이트웨이를 둘 수도 있다.

 

아래 그림은 웹, 앱(클라이언트)에서 API 게이트웨이를 통해 상품,추천,리뷰 마이크로서비스(백엔드 서비스)를 동기 호출하는 구성을 나타낸다.

백엔드 서비스 간의 호출은 REST API 같은 동기식으로 사용할 수 있다. 동기방식은 요청(request)하면 바로 응답(response)이 오는 방식을 말한다.

 

아래 그림은 모바일 UI 고객의 주문 내역을 확인하기 위해 주문 서비스에 HTTP GET 요청을 보내면 주문 서비스는 고객 정보를 확인하기 위해 고객 서비스에 GET 방식의 동기 호출을 수행한다. 그에 따라 바로 응답이 발생한다.

여러 서비스 간의 연계를 통해 업무를 처리하는 마이크로서비스 구조에서는 장애가 전파되어 연쇄적으로 장애가 발생할 수 있다. 이러한 방식의 서비스 제공은 독자적인 마이크로서비스별로 비즈니스 기능 처리를 어렵게 만든다.


비동기 통신 방식

비동기 통신 방식은 아래 그림처럼 메시지(request)를 보낸 다음 응답(response)을 기다리지 않고 다음 일을 처리하게끔 한다. 동기 방식처럼 응답을 기다리지 않는다.

보낸 결과가 어떻게 됐는지 응답을 받지 않으므로 동기식처럼 완결성을 보장할 순 없다.

따라서 이를 보장하기 위한 메커니즘으로, 보통 아파치 카프카(Apache Kafka), 래빗엠큐(RabbitMQ), 액티브엠큐(ActiveMQ) 같은 메시지 브로커(Message Broker)를 활용한다.

이러한 메커니즘에서는 메시지를 보내는 생산자(Producer)와 메시지를 가져다가 처리하는 소비자(Consumer)가 서로 직접 접속하지 않고 메시지 브로커에 연결된다. 메시지 브로커에 메시지를 전달하고 자신의 일을 처리하면 메시지 브로커가 전송을 보장하게 된다. 메시지 브로커는 메시지 처리 규모에 따라 확장이 가능하다.

 

이 방식은 메시지 브로커에 의해 중계되기 때문에 서로 통신하는 서비스들이 물리적으로 동일한 시스템에 위치할 필요도 없고, 서로 프로세스를 공유할 필요도 없으며, 동일한 시간대에 동시에 동작하지 않아도 된다.

 

따라서 서비스 요구에 따라 늘어나거나 줄어들 수 있는 탄력성 높은 클라우드 플랫폼 환경에서 서비스가 다운됐을 때 또는 시스템을 더 확장해야 할 때 사용하기에 매우 효과적인 방법이다.

 

클라우드 벤더에서 완전관리형으로 제공하는 AWS의 SQS나 SNS, Azure Event Hub, Azure Event Grid 등도 많이 사용된다.


비동기 방식의 이벤트 기반 아키텍처(Event Driven Architecture)

이벤트 기반 아키텍처는 비동기 통신 방식을 이용해 느슨한 연계를 지향하는 아키텍처로, 분산 시스템 간에 발신자가 이벤트를 생성 및 발행(Publish)하고, 해당 이벤트를 필요로 하는 수시낮에게 전송하면 이벤트를 구독하고(Subscribe) 있던 수신자가 이벤트를 받아 처리하는 형태의 시스템 아키텍처이다.

 

이벤트상태의 변화를 의미하며, 기존의 순차적 방식의 아키텍처와 달리 특정 행동이 자동으로 순서에 따라 발생하는 것이 아닌 어떤 상태의 변경에 대한 반응으로 동작한다는 점이 차이점이다.

 

이벤트 기반 아키텍처는 이벤트를 생산하는 모듈이벤트에 대응하는 모듈을 분리하고 상호 독립적으로 동작하게 함으로써 병렬 처리를 촉진한다.

 

이벤트 기반 아키텍처와 비동기 통신 메커니즘을 함께 사용하는 마이크로서비스를 이벤트 기반 마이크로서비스(Event-driven Microservice)라고도 한다. 이벤트 메시지를 사용하면 발신자와 수신자를 장소와 시간에서 쉽게 분리할 수 있으며, 마이크로서비스가 추구하는 느슨한 결합으로 확장성, 탄력성 측면에서 이점이 많다.


저장소 분리 패턴

기존 모노리스 시스템의 저장소는 통합 저장소이다. 즉, 애플리케이션 모듈을 분리하되 저장 처리는 모듈별로 격리하지 않고 다른 모듈에서의 호출을 허용하는 구조였다. (일반적으로 SQL의 조인 구문을 사용해 다른 모듈이 소유권을 가지고 있는 데이터까지 함께 조합해서 호출하는 방식이다.)

 

이러한 구조를 데이터 중심 애플리케이션이라 하는데, 특정 관계형 데이터베이스 벤더에 구속되고 복잡해져 유지보수가 어려워지고 성능 문제가 발생했을 때 SQL 구문 튜닝이나 저장소 증설(스케일 업)에 의존할 수 밖에 없다.

 

또한 이러한 구조의 애플리케이션은 여러 개의 마이크로서비스로 분리하더라도 요청이 증가할 경우 서비스는 한가하고 여러 서비스에서 호출되는 통합 데이터베이스만 여전히 바쁜 상황이 되어 마이크로서비스의 자동 확장(스케일 아웃)기능이 별 소용 없어질 수 있다.

 

이를 보완할 수 있는 마이크로서비스 패턴이 저장소 분리 패턴이다.

저장소 분리 패턴은 각 마이크로서비스는 각각의 비즈니스를 처리하기 위한 데이터를 직접 소유해야 한다는 것을 말한다. 그렇기 때문에 자신이 소유한 데이터는 다른 서비스에 직접 노출하지 않고 각자가 공개한 API를 통해서만 접근할 수 있다(정보 은닉). 또한 저장소가 격리돼 있기 때문에 각 저장소를 자율적으로 선택할 수 있다(폴리글랏 저장소). 궁극적으로 이 같은 제약이 데이터를 통한 변경의 파급 효과(영향도)를 줄여 서비스를 독립적으로 만든다.

 

아래의 그림처럼, 주문 서비스가 주문 수행을 위해 고객 정보를 필요로 하더라도 바로 고객 테이블에 질의할 수 없고 반드시 고객 서비스의 API를 통해서만 호출되게끔 한다.

하지만 위 상태로는 여러 개의 분산된 서비스에 걸쳐 비즈니스 처리를 수행해야 하는 경우, 비즈니스 정합성 및 데이터 일관성을 어떻게 보장할 것이가에 대한 문제가 생긴다.


분산 트랜잭션 처리 패턴

비즈니스 정합성 및 데이터 일관성을 보장하기 위한 방법 중 손쉽게 적용할 수 있는 방법은 여러 개의 분산된 서비스를 하나의 일관된 트랜잭션으로 묶는 것이다.

 

분산 트랜잭션 처리를 위한 전통적인 방법으로는 2단계 커밋 같은 기법이 있다. 2단계 커밋은 분산 데이터베이스 환경에서 원자성(Atomicity)를 보장하기 위해 분산 트랜잭션에 포함돼 있는 모든 노드가 커밋(Commit)되거나 롤백(Rollback)하는 메커니즘이다.

 

하지만 이 방법은 각 서비스에 잠금(Lock in)이 걸려 발생하는 성능 이슈가 있으며, 특히 각 서비스가 다른 인스턴스로 로딩되기 때문에 통제하기 어렵다. 또한, 서비스의 저장소가 각각 다를 경우에도 문제가 있으며, MongoDB 같은 NoSQL 저장소는 2단계 커밋 자체를 지원하지 않는다.

 

마이크로서비스의 독자적인 분산 트랜잭션 처리를 지원하는 패턴이 바로 사가(Saga) 패턴이다. 아래의 사가는 코레오그래피(Choreography) 사가 방식이다. 오케스트레이션(Orchestration) 사가 방식과의 차이를 간단하게 설명하면 다음과 같다.

  • 코레오그래피(Choreography) 사가 방식: 의사결정과 순서를 참가자에게 맡기고 주로 이벤트 교환 방식으로 통신한다.
  • 오케스트레이션(Orchestration) 사가 방식: 의사결정과 순서가 오케스트레이터에 의해 중앙화해서 통제한다. 사가 오케스트레이터가 참여자에게 커맨드 메시지를 보내 수행할 작업을 지시하는 방식이다.

 

사가 패턴은 각 서비스의 로컬 트랜잭션을 순차적으로 처리하는 패턴이다. 여러 개의 분산된 서비스를 하나의 트랜잭션으로 묶지 않고 각 로컬 트랜잭션과 보상 트랜잭션을 이용해 비즈니스 및 데이터의 정합성을 맞춘다.

 

아래 그림처럼 각 로컬 트랜잭션은 자신의 데이터베이스를 업데이트한 다음, 사가 내에 있는 다음 로컬 트랜잭션을 트리거하는 메시지 또는 이벤트를 게시해서 데이터의 일관성을 맞춘다.

정리하면, 사가는 일관성 유지가 필요한 트랜잭션을 모두 묶어 하나의 트랜잭션으로 처리하지 않고, 각 로컬 트랜잭션으로 분리해서 순차적으로 처리하는 방법이다. 그러다가 트랜잭션이 실패하면, 이전 로컬 트랜잭션이 작성한 변경 사항을 취소하는 일련의 보상 트랜잭션을 통해 비즈니스 처리의 일관성을 유지한다.


결과적 일관성

결과적 일관성(Eventual Consistency)는 데이터의 일관성이 실시간으로 맞지 않더라도, 어느 일정 시점이 됐을 때 데이터의 일관성을 만족한다는 개념이다.

 

결과적 일관성의 개념은 고가용성(HA: High Availability)을 극대화한다. 실시간성의 일관성은 아래 그림에서처럼 결제 서비스에서 발생한 장애가 다른 서비스의 가용성을 떨어뜨릴 수 있다. 결제 서비스의 장애로 주문 서비스마저 사용할 수 없다.

아래 그림은 위 그림 사례에 사가 패턴과 이벤트 메시지 기반 비동기 통신을 적용한 그림이다.

각 마이크로서비스의 트랜잭션은 독립적이고 각 트랜잭션이 성공했을 때 상태 변경 이벤트를 발행해 이 이벤트를 구독한 다른 서비스의 로컬 트랜잭션이 작동되게 한다.

  1. 가주문이 생성되고 '가주문됨' 이벤트를 발행한다. 주문은 독립적 로컬 트랜잭션이기 때문에 끊임없이 받을 수 있다. 주문이 몰릴 경우 주문 서비스만 확장해서 가용성을 높일 수 있다.
  2. '가주문됨' 이벤트는 메시지 브로커에 비동기로 전송된다.
  3. 결제 서비스는 발행된 '가주문됨' 이벤트를 확인하고 대금 결제 트랜잭션을 수행하고 '결제 처리됨' 이벤트를 발행한다.
  4. 이메일 서비스는 '결제 처리됨' 이벤트를 확인하고 주문 결제 완료 이메일을 사용자에게 발송한다.
  5. 주문 서비스는 '결제 처리됨' 이벤트를 확인하고 가주문으로 처리됐던 주문을 최종 승인하다. 그리고 '최종 주문 완료됨' 이벤트를 발행한다.
  6. 이메일 서비스는 주문 서비스가 발행한 '최종 주문 완료됨' 이벤트를 확인해 최종적으로 주문이 완료됐다는 이메일을 사용자에게 발송한다.
  7. 각 서비스는 각기 작업을 수행하다 오류가 발생하면 '실패 이벤트'를 발행해 다른 서비스가 비즈니스 정합성을 맞출 수 있게 한다.
  8. 이때 별도로 메시지 큐에 쌓이는 이벤트들을 모니터링 서비스와 연계해 모니터링하고 추적해서 전체적인 비즈니스 정합성 여부를 관리자가 확인할 수도 있다.

 

위와 같이 이벤트 기반 아키텍처와 메시지 브로커, 사가 패턴으로 비즈니스 정합성을 결과적으로 보장할 수 있고 비즈니스 및 시스템 가용성을 극대화할 수 있다.


읽기와 쓰기 분리: CQRS 패턴

CQRS(Command Query Responsibility Segregation), 명령 조회 책임 분리

 

일반적으로 시스템의 상태를 조회하는 부분이 시스템의 상태를 변경하는 명령보다 많이 쓰이기 때문에, 하나의 저장소에 쓰기 모델과 읽기 모델을 분리하는 방식으로 변화시키는 것이 좋다. (읽기와 쓰기를 하나의 모델에 함께 포함시키면, 읽기 요청 빈도가 증가함에 따라 쓰기 기능도 함께 확장해야 하므로 비효율적이다.)

아래 그림은 CQRS 방식을 이벤트 메시지 주도 아키텍처와 연계한 CQRS 패턴의 개념도이다. 우선 마이크로서비스의 명령 측면과 조회 측면, 두 부분으로 나눈다.

명령 서비스는 저장소에 데이터를 쓰면서 저장한 내역이 담긴 이벤트를 발생시켜 메시지 브로커에 전달한다.

조회 서비스는 메시지 브로커의 이벤트를 구독하고 있다가 이벤트 데이터를 가져와 데이터를 최신 상태로 동기화한다.

물론 명령 서비스에 데이터가 들어간 즉시 조회 서비스의 데이터와 일치할 수는 없지만, 어느 시점이 되면 결과적으로 일치하게 된다. 즉, 이벤트 주도 아키텍처로 구성함으로써 결과적 일관성을 만족할 수 있다.


API 조합과 CQRS

마이크로서비스의 저장소가 격리돼 있고 각 마이크로서비스마다 각기 다른 기능을 구현했을 때 여러 개의 마이크로서비스를 연계해서 서비스로 제공하는 경우에, 두 가지 방법이 존재한다.

 

API 조합(Composition)

다음 그림처럼 각 기능을 제공하는 마이크로서비스를 조합하는 상위 마이크로서비스를 만들어 조합된 기능을 제공할 수 있다. 하위 서비스는 각자 독립적인 API를 제공하면서 연계 API를 위해 상위 서비스에 정보를 제공한다.

 

하지만 이러한 구조는 상위 서비스가 하위 서비스에 의존하는 결과를 낳는다. 상위 서비스가 제공하는 API에 정보를 제공하는 하위 서비스 중 하나라도 API를 변경하면 상위 서비스는 그에 따라 변경이 불가피하다.

 

CQRS

서비스를 제공하는 상위 마이크로서비스가 독자적인 저장소를 갖도록 만든다. 또한 원천 정보를 가지고 있는 각각의 서비스도 독자적으로 자신의 저장소를 가지고 서비스를 제공한다. 이 원천 정보를 보유한 여러 하위 마이크로서비스는 자신의 서비스의 정보가 변경되는 시점에 변경 내역을 각자의 변경 이벤트로 발행한다.

 

그럼 상위 서비스에서는 이 이벤트를 구독하고 있다가 이벤트를 가져와서 자신의 서비스의 저장소에 기록함으로써 다른 서비스의 데이터와 데이터 일관성을 맞추고 서비스를 제공한다.

 

이런 경우에는 다른 원천 서비스가 순간적인 장애를 일으켜도 상위 서비스가 영향을 받지 않는다. 즉, 조회용 마이크로서비스를 별도로 생성하고 다른 서비스로부터 비동기 이벤트로 일관성을 맞춤으로써 API 조합의 단점인 직접적인 의존성을 줄일 수 있다.


쓰기 최적화: 이벤트 소싱 패턴

사가(Saga)패턴 및 CQRS 패턴에서 비즈니스 불일치를 피하기 위해서는 저장소에 저장하는 것과 메시지를 보내는 것이 원자성(Atomicity)을 지녀야한다.

 

하지만 객체의 상태 변화를 이벤트 메시지로 발행하고 또 객체 상태를 관계형 데이터베이스에 저장하는 경우 SQL질의어로 변환해서 처리하기가 매우 귀찮고 까다롭다. 또 메시지 발행과 저장 처리라는 두가지 기능을 수행하므로 속도면에서 빠르지도 않다.

 

보통 비즈니스 처리를 수행할 때 데이터 처리는 항상 처리 상태의 결괏값을 계산하고 데이터의 최종 상태를 확정해서 저장하는 방식으로 진행된다. 하지만 이는 복잡한 변환 처리로 인한 성능 저하와 특히 인스턴스가 여러 개로 확장될 때 동시 업데이트 및 교착상태가 발생할 수 있다.

 

이를 방지하기 위해, 메시지 발행 및 저장 처리의 원자성을 보장하고 성능 또한 최적화하는 방법으로 이벤트 소싱(Event Sourcing) 기법이 있다. 트랜잭션 자체를 저장하자는 전략으로, 상태 변경 이벤트를 계산해서 데이터 모델로 변경하지 않고 바로 이벤트 저장소에 그대로 저장한다. 이렇게 하면 메시지 브로커와 데이터 저장소를 분리하지 않고 하나로 사용할 수 있다.

 

현재 시점의 상태가 필요하면 상태의 출발점부터 모든 기록된 상태 변경 트랜잭션을 순차적으로 계산하여 현재 상태를 업데이트한다. 처음부터 모든 트랜잭션을 처리하는 것이 부담된다면 스냅숏을 사용할 수도 있다.

 

또한, 명령 측면과 조회 측면의 서비스가 이벤트 저장소에 대한 '입력/조회(CR)'만 처리하면 된다. 저장소에서 변경과 삭제가 발생하지 않기 때문에 명령 측면의 서비스를 여러 개 확장해도 동시 업데이트 및 교착상태가 발생하지 않는다.

위 그림은 이벤트 스트림의 저장 과정이며, 이벤트 저장소의 데이터 형태이다. 이벤트 아이디가 있고, 이벤트 타입으로 어떠한 상태인지, 엔티티 타입으로 어떠한 객체의 이벤트인지, 그리고 변경 내용이 엔티티 데이터 항목에 JSON 형태로 저장된다. JSON 객체 형태라서 상태 객체가 그대로 들어간 것과 같다.

 

이벤트는 한번 발생한 후에 수정되지 않고 업데이트나 삭제 없이 입력만 되는 개념이라 동시성이나 정합성 등의 문제에 비교적 자유롭다.

위 그림은 이벤트 소싱 개념도이며, 이벤트 소싱은 모든 트랜잭션의 상태를 바로바로 계산하지 않고 별도의 이벤트 스트림으로 이벤트 스트림 저장소에 저장하는 방식이다. 이벤트 스트림 저장소는 오로지 추가만 가능하게끔 해서 계속 이벤트들이 쌓이게 만들고, 실제로 데이터를 구체화하는 시점에서는 그때까지 축적된 트랜잭션을 바탕으로 상태를 계산해서 구성한다.

 

이벤트 저장소는 이벤트 데이터베이스의 역할뿐 아니라 메시지 브로커처럼 작동한다. 이는 데이터 저장 처리 메커니즘과 메시지 큐 같은 이벤트를 전달하기 위한 메커니즘을 통합해서 복잡성을 줄이고 특히 쓰기 성능을 최적화한다. 또한 상태를 저장하기 때문에 정확한 감사 로깅을 제공하고, 객체의 예전 상태를 재구성하는 것이 간단해지며, 외부 애플리케이션에 이벤트를 전달하는 것도 저장한 이벤트를 그대로 전송하면 되기 때문에 간편하다.


Refer to

 

마이크로 서비스 기반 복합 UI 만들기

마이크로 서비스 아키텍처 백 엔드에 대해서만은 아닙니다. 프런트 엔드에서 사용하는 Peek 뷰를 가져옵니다.

docs.microsoft.com