본문 바로가기

개발 스터디/MSA 공부

마이크로서비스 아키텍처(MSA) - #3 마이크로 서비스 아키텍처 IPC

IPC(Inter Process Communication)는 프로세스 간 통신이란 프로세스들 사이에 서로 데이터를 주고받는 행위 또는 그에 대한 방법이나 경로를 뜻하는 용어로, 애플리케이션이 여러개의 서비스로 구성되는 마이크로서비스 아키텍처에서 필수적이다. 보통은 JSON 기반의 REST가 많이 사용되지만(MSA가 아닌 일반적인 상황에서) 여러가지 옵션을 고려해서 잘 선택해야 한다.

 

IPC는 다음과 같이 분류될 수 있다. 

 

일대일/ 일대다 여부

  • 일대일 : 각 클라이언트 요청은 정확히 한 서비스가 처리
  • 일대다 : 각 클라이언트 요청을 여러 서비스가 협동하여 처리

동기/ 비동기 여부

  • 동기 : 클라이언트는 서비스가 제시간에 응답하는 것을 가정하고 대기 도중 블로킹할 수 있음.
  • 비동기 : 클라이언트가 블로킹하지 않고, 응답은 즉시 전송되지 않아도 된다.

일대일 상호 작용

  • 요청/ 응답, 비동기 요청 /응답, 단방향 알림

일대다 상호 작용

  • 발행/구독, 발행/비동기 응답

 

동기 RPI 패턴 응용 통신

RPI 는 클라이언트가 서비스에 요청을 보내면, 서비스가 처리 후 응답을 회신하는 IPC이다. 종류는 다양할 수 있지만 REST, gRPC가 대표적이다.

 

REST

웹 상 존재하는 자원을 url로 나타내고, 자원의 대한 행위를 HTTP 프로토콜의 메서드로 나타냄. 데이터 형식은 대부분 JSON으로 나타냄.

 

장점

  • 단순하고 익숙
  • 테스트가 쉬움
  • 중간 브로커가 필요 없어서 시스템 아키텍처가 단순

단점

  • 요청/ 응답 스타일의 통신만 지원
  • 가용성이 떨어짐
  • 요청 한번으로 여러 리소스를 가져오기 힘듦 (데이터를 효율적으로 조회할 수 있게 graphl이 떠오르고 있음)
  • 다중 업데이트 작업을 HTTP 동사에 매핑하기 어려울 때가 많음 (특정 데이터에 대한 행위가 여러 개 일 수 있으며 메서드에 다 할 수가 없음)

GRPC

바이너리 메시지 기반의 프로토콜이고, 프로토콜 버퍼를 IDL로 정의하며 프로토콜 버퍼 컴파일러로 클라이언트 쪽 스텁 및 서버쪽 스캘레톤을 생성할 수 있다.

장점

  • 다양한 업데이트 작업이 포함된 API 설계가 쉬움
  • 큰 메시지를 교환할 때 콤팩트하고 효율적인 IPC
  • 양방향 스트리밍 덕분에, RPI, 메시징 두가지 통신 방식

 

서비스 디스커버리

REST API가 있는 어떤 서비스를 호출하는 코드를 개발한다고 가정하면, 서비스를 호출하는 클라이언트는 서비스 인스턴스의 네트워크 위치를 알고 있어야 하는데, 클라우드 기반의 MSA 어플리케이션은 네트워크 위치가 동적이기 때문에 이를 식별하는 일이 간단하지 않다.

이 문제를 해결하기 위해 서비스 디스커버리 메커니즘을 도입할 수 있다. 즉 애플리케이션 서비스 인스턴스의 네트워크 위치를 DB화 한 서비스 레지스트리를 도입하는 것이다.

  • 클라이언트/ 서비스가 직접 서비스 레지스트리와 상호 작용
  • 배포 인프라로 서비스 디스커버리를 처리.
  • 애플리케이션 수준의 서비스 디스커버리 패턴 적용

애플리케이션 클라이언트/서비스가 직접 서비스 레지스트리랑 통신하는 방법이 있다. 서비스 인스턴스들은 자신의 네트워크 위치를 레지스트리에 등록하고, 서비스 클라이언트는 서비스 레지스트리로부터 전체 서비스 인스턴스 목록을 가져와, 그 중 한 인스턴스로 요청을 라우팅 한다.

이런 서비스 디스커버리 패턴은 도커 및 쿠버네티스와 같이 최신 배포 플랫폼에 탑재되어 있으며 해당 플랫폼에 내장된 서비스 디스커버리를 활용할 수 있다.

 

⇒ 보통 직접 구현 보다는, 벤더사에서 제공하는 것을 많이 사용하는 것 같다.

 

비동기 메시지 패턴 응용 통신

메시징은 서비스가 메시지를 서로 비동기적으로 주고받는 통신 방식이며, 보통 서비스간 중개 역할을 하는 메시지 브로커가 존재한다. 클라이언트가 서비스에 메시지를 보내 요청을 하면, 요청을 받은 서비스 인스턴스가 응답 가능할 경우 별도의 메시지를 클라이언트에 응답한다. 비동기 통신을 하기 때문에 클라이언트가 응답을 기다리며 블로킹하지 않고, 클라이언트는 응답을 바로 받지 못한다는 가정하에 메시지를 전송한다.

 

메시지 채널

메시지는 채널을 통해 교환되며, 송신자의 비즈니스 로직은 송신 포트 인터페이스를 호출하여, 메시지 채널을 통해 수신자에게 메시지를 전달한다. 수신자의 메시지 핸들러 어댑터 클래스는 메시지를 처리하기 위해 호출되고, 클래스는 컨슈머 비즈니스 로직으로 구현된 수신 포트 호출 인터페이스를 호출한다.

 

채널은 두 종류로 구성된다.

  • 점대점 : 채널을 읽는 컨슈머 중 하나만 지정하여 메시지를 전달.
  • 발행-구독 채널 : 같은 채널을 바라보는 모든 컨슈머에 메시지를 전달한다. (이벤트 메시지)

메시징 상호 작용 스타일

  • 비동기 요청/응답 스타일

클라이언트/ 서비스는 한 쌍의 메시지를 주고받는 비동기 요청/응답 스타일로 상호 작용한다. 클라이언트는 수행할 작업과 파라미터가 담긴 커맨드 메시지를 메시징 채널에 보내면, 서비스는 요청을 처리하고, 그 결과가 담긴 메시지를 점대점 채널에 돌려보낸다. 이 때 두 채널은 각각 요청채널, 응답채널로 분리되어 있다.

이 때 각 요청- 응답에 대한 메시지의 구분은 MessageId로 할 수 있는데, 서버 쪽에서 처리한 메시지 ID를 응답 메시지에 동일한 CorrelationId로 쓴다면 요청/응답을 식별할 수 있다.

  • 단방향 알림

클라이언트는 서비스에게 단방향으로 알림만 보내고, 서비스는 별도의 응답은 하지 않는다

  • 발행/구독 

클라이언트는 여러 컨슈머가 읽는 채널에 메시지를 발행하고, 서비스는 도메인 객체의 변경 사실을 알리는 도메인 이벤트를 발행한다. 서비스들은 자신이 관심있는 도메인 객체의 이벤트 채널을 구독하여 메시지를 처리한다.

 

메시지 브로커

메시지 브로커는 서비스가 서로 통신할 수 있게 해주는 인프라 서비스다. 브로커 없이 브로커리스 메시징도 가능하지만 서비스 디스커버리 메커니즘을 사용해야하며, 전달 보장과 같은 메커니즘을 구현하기 어렵고, 메시지 송신자 수신자가 모두 실행중이어야 하기 때문에 가용성이 떨어져 메시지 브로커 기반 메시징을 많이 사용한다.

메시지 브로커는 모든 메시지가 지나가는 지점이며, 송신자 및 수신자는 서로의 네트워크 위치를 몰라도 된다. 즉 메시지 브로커만 알면 메시지를 송/수신 하는데 지장이 없다.

메시지 브로커 제품에는 ActiveMQ, RabbitMQ, 아파치 카프카 등이 있으며, 각 제품마다 장단이 있기 때문에 구현하고자 하는 애플리케이션 특징에 맞춰 선택해야 한다.

 

장점

  • 느슨한 결합 : 클라이언트 적절한 채널에 그냥 메시지를 보내는 식으로 요청한다. 클라이언트는 서비스 인스턴스를 몰라도 되므로, 서비스 인스턴스 위치를 알려주는 디스커버리 메커니즘도 필요 없다.
  • 메시지 버퍼링 : 메시지 브로커는 처리 가능한 시점까지 메시지를 버퍼링한다. HTTP같은 동기 요청/응답 프로토콜을 쓰면 교환이 일어나는 동안 클라이언트/서비스 양쪽 모두 가동중이어야 하지만, 메시징을 쓰면 컨슈머가 처리할 수 있을 때까지 그냥 큐에 메시지가 쌓인다.

단점

  • 성능 병목 가능성 : 메시지 브로커가 성능 병목점이 될 위험이 있음.
  • 단일 장애점 가능성 : 메시지 브로커는 가용성이 높아야함.
  • 운영 복잡도 부가: 메시징 시스템 역시 설치, 구성, 운영해야 할 시스템 컴포넌트.

수신자 경합과 메시지 순서 유지

메시지 순서를 유지한 채 메시지 수신자를 scale out할 수 있을까? 다수의 스레드와 서비스 인스턴스를 동원하면 애플리케이션 처리율이 증가하지만, 이렇게 동시 처리를 하려면 메시지를 정확히 한번만 순서대로 처리해야한다.

만약 동일한 점대점 채널을 읽는 서비스 인스턴스가 3개있고, 송신자는 주문 생성됨, 주문 변경됨, 주문 취소됨 이벤트 메시지를 차례로 전송한다고 가정하자. 단순하게 생각하면, 메시지를 종류별로 정해진 수신자에 동시 전달하면 될 것 같지만, 네트워크 이슈나 여러 문제로 메시지 처리 순서가 어긋나면 시스템이 오작동할 수 있다.

 

이 문제를 해결하기 위해 메시지 브로커들은 샤딩된 채널을 이용한다.

  1. 샤딩된 채널은 복수개의 샤드로 구성되며 각 샤드는 채널 처럼 동작한다.
  2. 송신자는 메시지 헤더에 샤드키를 지정하고, 메시지 브로커는 메시지를 샤드 키별로 샤드/파티션에 배정한다.
  3. 메시지 브로커는 수신자 인스턴스들을 묶어 동일한 논리 수신자처럼 취급하고, 각 샤드를 하나의 수신자에 배정하며 수신자가 시동/종료하면 샤드를 재배정한다.

각 주문 이벤트 메시지의 샤드키가 존재하고, 주문별 이벤트는 각각 동일한 샤드에 발행되고, 어느 한 컨슈머 인스턴스만 메시지를 읽기 때문에 메시지 처리 순서가 보장된다.

중복 메시지 처리

중요한 파트이다. 메시지 브로커가 각 메시지를 꼭 한번만 전달하면 좋겠지만, 그것을 강제하는 것은 어려운 일이며,보통 메시지 브로커는 한번 이상 메시지를 전달하겠다 라고 약속한다.

  시스템이 정상일 때는 상관이 없지만, 클라이언트나 네트워크, 또는 브로커 자신이 실패할 경우 같은 메시지를 여러번 전달할 수 있다. 메시지 처리 후 DB업데이트 까지 마쳤는데, 메시지를 ACK하기 전에 클라이언트가 갑자기 fail이 된다면, 클라이언트가 재시동했을 때 메시지 브로커는 ACK가 안된 메시지를 다시 보내거나 클라이언트 레플리카에 전송할 것이다.

  메시지 브로커가 메시지를 재전송할 때 원래 순서까지 보장한다면 이상적이지만, 실제로는 실패한 이벤트에 대해서 모든 연관된 이벤트를 다 재전송하는 것은 쉽지 않다.

 

이 중복 메시지를 처리하기 위해서는 방법이 두가지이다.

  • 멱등한 메시지 핸들러를 작성

  동일한 입력값(ex set=100 | not add +10)을 호출해도 아무런 부수효과가 없을 때 멱등하다고 하며, 이 메시지 재전송 시 메시지 브로커가 순서를 유지한다는 전제하에 멱등한 메시지 핸들러는 여러번 실행해도 별 문제가 없다.

  그러나 이 멱등한 애플리케이션 로직은 실제로 별로 없으며, 메시지 재전송 시 순서가 보장되지 않을 경우가 많다.

  • 메시지를 추적 중복을 솎아내는 방법.

  컨슈머가 메시지 ID를 통해서 메시지 처리 여부를 추적하면서 중복 메시지를 솎아 내면 해결할 수 있다. 즉 컨슈머가 소비하는 메시지 ID를 DB테이블에 저장하고, 메시지를 처리할 때 비즈니스 엔티티를 생성/수정하는 트랜잭션의 일부로 메시지 ID를 DB테이블에 기록할 수 있다.

 

트랜잭셔널 메시징

서비스는 DB를 업데이트하는 트랜잭션의 일부로 메시지를 발행한다. 즉 DB 업데이트와 메시지 전송을 한 트랜잭션으로 묶지 않으면, DB 업데이트 후 메시지는 아직 전송되지 않은 상태에서 서비스가 중단될 수 있으므로 문제가 된다.

분산 트랜잭션이 제공되지 않은 상황에서 애플리케이션에서 메시지를 확실하게 발행할 수 있는 메커니즘이 반드시 필요하다.

 

DB 테이블을 메시지 큐로 활용

RDBMS 기반의 애플리케이션이면, DB테이블을 임시 메시지 큐로 사용하는 트랜잭셔널 아웃백스 패턴을 도입할 수 있다. 즉 메시지를 보내는 서비스에 OUTBOX라는 DB테이블을 만들고, 비즈니스 객체를 생성, 수정, 삭제하는 DB트랜잭션의 일부로 OUTBOX 테이블에 메시지를 삽입한다.

로컬 트랜잭션은 ACID가 보장되기 때문에 OUTBOX 테이블에 확실히 메시지가 삽입되고, 메시지 릴레이는 OUTBOX 테이블을 읽어 메시지 브로커에게 발행하는 메커니즘 이기 때문에 앞서 말한 상황을 확실해 보장해줄 수 있다.

 

 

메시지를 DB에서 메시지 브로커로 옮기는 방법은 크게 두가지 이다.

이벤트 발행: 폴링 발행기 패턴

RDBMS를 쓰는 애플리케이션에서 OUTBOX 테이블에 삽입된 메시지를 발행하는 가장 간단한 방법은 메시지 릴레이로 테이블을 폴링해서 미발행 메시지를 조회하는 것이다. 메시지 릴레이는 메시지 브로커에 조회한 메시지를 발행하고, OUTBOX 테이블에서 메시지를 삭제한다.

⇒ DB 폴링은 규모가 작은 경우 쓸 수 있지만, 자주 폴링할 경우 비용이 발생한다. 따라서 규모가 커질 경우 DB 트랜잭션 로그 테일링 방법을 도입할 수 있다.

이벤트 발행: 트랜잭션 로그 테일링 패턴

메시지 릴레이로 DB 트랜잭션 로그를 테일링하는 방법. 애플리케이션에서 커밋된 업데이트는 각 DB의 트랜잭션 로그 항목으로 남기 때문에, 트랜잭션 로그마이너로 트랜잭션 로그를 읽어 변경분을 하나씩 메시지 브로커에 발행할 수 있다. (쿼리 없이 트랜잭션 로그를 추적, 그러나 개발 공수가 소요됨)

비동기 메시징으로 가용성 개선

  • 동기 통신의 문제점

동기 프로토콜은 호출한 서비스가 응답할 때 까지 클라이언트는 무조건 기다려야하기 떄문에 애플리케이션의 가용성이 저하될 수 있다.

⇒ MSA처럼 소규모 서비스로 구성될 경우 통신 비용이 증대되는데, 동기 통신을 무분별하게 많이 사용하면 이로 인한 가용성 문제가 더 심각해질 수 있음. 따라서 동기 상호 작용을 제거할 수 있다.

 

비동기 상호작용 스타일

메시징 기반 비동기 통신을 활용하여 최종적으로 응답을 전송하는 방법을 통해 동기 상호작용을 제거할 수 있다. 만약 REST 같은 동기 프로토콜을 사용하는 외부 API를 가진 서비스가 있다면 데이터를 복제하여 가용성을 높일 수 있다.

 

데이터 복제

데이터 레플리카는 데이터를 소유한 서비스가 발행하는 이벤트를 구독해서 최신 데이터를 유지할 수 있다. 즉 다른 서비스의 데이터가 필요할 떄마다 요청하고 상호작용해서 데이터를 얻어오는 것이 아니라, 해당 서비스가 발행하는 이벤트를 구독해서 최신 데이터를 유지하는 방법이다.

경우에 따라서 다른 서비스의 데이터를 토대로 비즈니스 로직이 작성되는 경우는 유용할 수 있지만, 대용량의 레플리카를 만드는 것은 비효율적이다.

 

 

응답 반환 후 마무리

요청 처리 중 동기 통신을 제거하려면 다음과 같이 요청을 처리할 수 있다.

  1. 로컬에서 가용한 데이터를 기반으로 요청을 검증(즉 데이터 복제를 통한 로컬 state를 기반으로 요청을 검증)
  2. 메시지를 OUTBOX테이블에 삽입해서 DB를 업데이트
  3. 클라이언트에 응답을 반환.