모놀리식
애플리케이션은 대부분의 모듈이 언어 수준의 메서드나 함수를 통해 서로 호출하기 때문에 REST API나 클라우드 서비스 연계 모듈을 작성하지 않는 이상 IPC는 크게 신경 쓸 필요가 없습니다.
이와 달리 MSA
는 애플리케이션을 여러 개의 서비스로 구성하며 서비스는 대부분 요청을 처리하기 위해 서로 협동합니다. 서비스 인스턴스는 여러 머신에서 실행되는 프로세스 형태이므로 반드시 IPC(Inter-Process Communication)를 통해 상호 작용해야 합니다.
이번 글에서는 IPC의 종류와 가용성 측면을 따졌을때 MSA에서 어떤 IPC를 쓰는 것이 좋은지 알아보겠습니다.
동기 RPI 통신
💡 RPI는 클라이언트가 서비스에 요청을 보내면 서비스가 처리 후 응답을 회신하는 IPC입니다.
💡 메시징으로 통신하는 클라이언트와 달리 응답이 제때 도착하리라 가정합니다.
동기적으로 소통하는 IPC는 다양하지만 여기서는 REST와 gRPC에 대해서만 간략히 설명하겠습니다.
REST
REST는 (거의 항상)HTTP로 소통하는 IPC입니다. 현재 API 개발은 REST 스타일이 대세입니다.
REST에서 Customr나 Product같은 비즈니스 객체들을 리소스라고 합니다. 해당 리소스들은 URL에 매핑됩니다.
장점
- 단순하고 익숙합니다.
- 간편하게 테스트할 수 있습니다.
- 요청/응답 스타일의 통신을 직접 지원합니다.
- HTTP는 방화벽 친화적 입니다.
- 중간 브로커가 필요하지 않기 때문에 시스템 아키텍처가 단순해집니다.
단점
- 요청/응답 스타일의 통신만 지원합니다.
- 가용성이 떨어집니다. 중간에서 메시지를 버퍼링 하는 매개자 없이 클라이언트/서비스가 직접 통신하기 때문에 교환이 일어나는 동안 양쪽 다 실행 중이어야 합니다.
- 서비스 인스턴스의 위치를 클라이언트가 알고 있어야 합니다.
- 요청 한 번으로 많은 리소스를 가져오기 어렵습니다.
👉 예를 들어 주문과 소비자를 REST로 조회하려고 할 때 순수 REST API라면 요청을 최소 2회 보내야 하고 더 많은 객체를 조회하려면 요청 횟수가 증가하게 됩니다.
(이를 해결 하기 위한 방식으로GraphQL
또는Netflix Falcor
가 있습니다.) - 작업을 HTTP동사에 매핑하기 어렵다
👉 HTTP는 한정된 Method만 지원하기 때문에 다양한 종류의 업데이트를 매핑하기 어렵습니다.
URL끝에 어떤 업데이트인지 명시하거나 (/orders/{order_id}/cancel
OR/orders/{order_id}/revise
) 동사를 URL 쿼리 매개변수로 지정해서 해결할 수도 있지만 이는 REST답지 않습니다.
gRPC
gRPC API는 서비스와 요청/응답 메시지로 구성되며 HTTP2를 사용한 이진 메시지 기반의 프로토콜입니다.
장점
- 다양한 업데이트 작업이 포함된 API를 설계하기 쉽습니다.
- 특히 큰 메시지를 교환할 때 콤팩트하고 효율적인 IPC입니다.
- 양방향 스트리밍 덕분에 RPI, 메시징 두 가지 통신 방식 모두 가능합니다.
- 다양한 언어로 작성된 클라이언트/서버 간 연동이 가능합니다.
단점
- 클라이언트가 하는 일이 REST API보다 많습니다.
- 구형 방화벽은 HTTP/2를 지원하지 않습니다.
부분 실패 처리: 회로 차단기 패턴
회로 차단기 패턴이란 연속 실패 횟수가 임계치를 초과하면 일정 시간 동안 호출을 즉시 거부하는 RPI 프락시입니다.
분산 시스템은 서비스가 다른 서비스를 동기 호출할 때마다 부분 실패할 가능성이 항상 존재합니다.
비동기 응답 통신과 다르게 동기 통신은 응답을 기다리기 때문에 서비스 실패는 클라이언트의 클라이언트로 거슬러 올라가면서 전체 시스템의 중단을 초래할 위험도 있습니다.
그림과 같은 상황을 생각해 봅시다. 모바일 클라이언트가 REST 요청을 하지만 API 게이트웨이가 요청을 위임한 서비스는 무응답 상태입니다
주문 서비스 프록시는 응답을 기다리며 무한정 블로킹할 것입니다. 결국 스레드 같은 주요 리소스가 고갈되어서 API 게이트웨이가 요청을 처리할 수 없게 되고 전체 API는 사용 불능 상태가 될 것입니다.
따라서 부분 실패가 애플리케이션 전체에 전파되지 않도록 서비스를 설계해야 합니다.
솔루션은 두 부분으로 나뉩니다.
견고한 RPI 프록시 설계(클라이언트가 자기 스스로를 방어하는 방법들입니다.)
- 네트워크 타임아웃
👉 응답 대기 중에 무한정 블로킹하면 안 되고 항상 타임아웃을 걸어 둡니다. 이렇게 해야 리소스가 마냥 붙잡히지 않습니다. - 미처리 요청 개수 제한
👉 클라이언트가 특정 서비스에 요청 가능한 미처리 요청의 최대 개수를 설정합니다. 이 개수에 이르면 더 이상의 요청은 무의미하므로 즉시 실패 처리하는 것이 타당합니다. - 회로 차단기 패턴
👉 성공/실패 요청 개수를 지켜보다가 에러율이 주어진 임계치를 초과하면 그 이후 시도는 바로 실패 처리합니다. 실패된 요청이 많다는 것은 서비스가 불능 상태고 더 이상의 요청은 무의미하다는 뜻입니다. 타임아웃 시간 이후 클라이언트가 재시도해서 성공하면 에러율을 초기화합니다.
대체 값 반환
- 부분 실패 시 미리 정해진 기본값이나 캐시 된 응답 등 대체 값을 반환합니다.
비동기 메시징 통신
메시징 통신은 서비스가 메시지를 서로 비동기적으로
주고받는 통신 방법입니다.
비동기 통신을 하기 때문에 클라이언트가 응답을 기다리며 블로킹하지 않습니다. 클라이언트는 응답을 바로 받지 못할 것이라는 전체 하에 작성합니다.
메시징 패턴에서 사용하는 채널에는 두 가지 종류가 있습니다.
- 점대점 채널
👉 채널을 읽는 컨슈머 중 딱 하나만 지정하여 메시지를 전달합니다. - 발행-구독 채널
👉 같은 채널을 바라보는 모든 컨슈머에 메시지를 전달합니다.
메시징 패턴에서 클라이언트/서비스는 한 쌍의 메시지를 주고받는 비동기 요청/응답 스타일로 상호 작용합니다.
먼저 클라이언트는 수행할 작업과 매개변수가 담긴 커맨드 메시지를 서비스가 소유한 메시징 채널에 보냅니다. 그러면 서비스는 요청을 처리한 후 그 결과가 담긴 응답 메시지를 클라이언트가 소유한 채널로 돌려보냅니다.
메시지 브로커
메시징 기반의 애플리케이션은 대부분 메시지 브로커를 사용합니다. 메시지 브로커는 서비스가 서로 통신할 수 있게 해주는 인프라 서비스입니다.
브로커리스 메시징
장점
- 송신자가 보낸 메시지가 브로커를 거쳐 수신자로 이동하는 것이 아니라, 송신자에서 수신자로 직접 전달되므로 네트워크 트래픽이 가볍고 지연 시간이 짧습니다.
- 메시지 브로커가 성능 병목 점이 될 일이 없습니다.
- 메시지 브로커를 설정/관리할 필요가 없으므로 운영 복잡도가 낮습니다.
단점
- 서비스가 서로의 위치를 알고 있어야 하므로 서비스 디스커버리 메커니즘을 사용해야 합니다.
- 메시지 교환 시 송신자/수신자 모두 실행 중이어야 하므로 가용성이 떨어집니다.
- 전달 보장(반드시 한번 또는 한번 이상 전달) 같은 메커니즘을 구현하기가 더 어렵습니다.
유명한 브로커리스 메시징 기술로는 ZeroMQ
가 있습니다.
가용성 저하 및 서비스 디스커버리가 필요하기 때문에 브로커 기반의 아키텍처가 더 선호됩니다.
브로커 기반 메시징
메시지 브로커는 모든 메시지가 지나가는 중간 지점입니다.
메시지 브로커의 가장 큰 장점은 송신자가 컨슈머의 네트워크 위치를 몰라도 된다는 것입니다. 또 컨슈머가 메시지를 처리할 수 있을 때까지 메시지 브로커에 메시지를 버퍼링 할 수도 있는 점입니다.
관련 제품들은 아래에 나열했습니다.
- ActiveMQ
- RabbitMQ
- Apache Kafka
- AWS Kinesis
- AWS Simple Queue Service
장점
- 느슨한 결합
👉 클라이언트는 적절한 채널에 그냥 메시지를 보내는 식으로 요청합니다. 클라이언트는 서비스 인스턴스를 몰라도 되므 로 서비스 인스턴스 위치를 알려주는 디스커버리 메커니즘도 필요 없습니다. - 메시지 버퍼링
👉 메시지 브로커는 처리 가능한 시점까지 메시지를 버퍼링 합니다. HTTP 같은 동기 요청/응답 프로토콜을 쓰면 교환이 일어나는 동안 클라이언트/서비스 양쪽 모두 가동 중이어야 하지만 메시징을 쓰면 컨슈머가 처리할 수 있을 때까지 메시지가 쌓입니다. 언젠가는 처리되겠거니 간주하고 메시지를 차곡차곡 쌓아 두는 것입니다.
단점
- 성목 병목 가능성
👉 브로커가 성능 병목 점이 될 위험이 있습니다. 하지만 요즘 브로커는 대부분 확장이 잘 되도록 설계되었습니다. - 단일 장애점 가능성
👉 브로커는 가용성이 높아야 합니다. 그렇지 않으면 시스템의 신뢰성에 흠이 갈 수 있습니다. 다행히 요즘 브로커는 대부분 고가용성이 보장되도록 설계되었습니다. - 운영 복잡도 증가
👉 브로커 역시 설치 구성 운영해야 할 시스템 컴포넌트입니다.
메시지 순서 유지
일반적으로 메시지를 동시 처리하려면 서비스 인스턴스를 여럿 두어야 합니다.
💡 물론 멀티 스레드를 이용하면 단일 인스턴스에서도 동시 처리할 수 있지만 다수의 스레드 다수의 인스턴스가 더 처리율이 좋습니다.
그런데 메시지를 동시에 처리하면 각 메시지를 정확히 한 번만 순서대로 처리해야 합니다.
갖가지 네트워크 이슈나 가비지 컬렉션 문제로 지연이 발생하고 메시지 처리 순서가 어긋나면 시스템이 오동작할 수 있습니다. 다른 서비스가 주문 생성됨 메시지를 처리하기도 전에 주문 취소됨 메시지를 처리하는 진풍경이 실제로 가능하다는 뜻입니다.
이와 같은 문제를 해결하기 위해 요즘 메시지 브로커는 샤딩(파티션)된 채널을 이용합니다.
쉽게 설명하면 순서가 지켜지기 원하는 메시지들을 특정 키로 묶어 하나의 채널에 모아둡니다. 그렇게 되면 메시지는 하나의 채널에서만 들어가고 나가기 때문에 순서가 지켜집니다.
동시에 다른 채널들에는 다른 메시지가 들어갈 수 있기 때문에 동시성 또한 보장됩니다.
자세한 설명은 아래와 같습니다.
- 샤딩된 채널은 복수의 샤드로 구성되며, 각 샤드는 채널처럼 작동합니다.
- 송신자는 메시지 헤더에 키값을 지정합니다. 메시지 브로커는 메시지를 샤드 키별로 샤드에 배정합니다.
- 브로커는 여러 수신자 인스턴스를 묶어 동일한 수신자그룹 으로 취급합니다. 브로커는 각 샤드를 하나의 수신자에 배정하고 수신자가 시동/종료하면 샤드를 재배정합니다.
수신자그룹에 대한 자세한 설명은 kafka consumer group
검색을 추천드립니다.
중복 메시지 처리
메시지 브로커가 각 메시지를 꼭 한 번만 전달하는 것은 값비싼 대가를 치러야 합니다.
그래서 보통 적어도 한 번 이상 메시지를 전달하겠노라 약속하는 경우가 많습니다.
중복 메시지를 처리하는 방법은 다음 두 가지입니다.
- 멱등한(idempotent) 메시지 핸들러를 작성합니다.
👉 동일한 입력 값을 반복 호출해도 아무런 부수 효과가 없을 때 멱등하다고 말합니다.
애플리케이션의 메시지 처리 로직이 멱등하면 중복 메시지는 전혀 해롭지 않습니다. 메시지 브로커가 순서를 유지한다는 전체 하에 멱등한 세미지 핸들러는 여러 번 실행해도 별 문제가 없습니다.
그러나 멱등한 애플리케이션 로직은 실제로 별로 없습니다. - 메시지를 추적하고 중복을 솎아 냅니다.
👉 소비자 신용카드를 승인하는 메시지 핸들러가 있다고 생각해봅시다.
이런 종류의 애플리케이션 로직은 호출될 때마다 영향을 미치므로 중복 메시지 때문에 같은 로직이 여러 번 실행되면 문제가 심각해집니다.
중복 메시지를 걸러내는 다양한 방법이 있겠지만 여기서는 컨슈머 메시지 ID를 이용해서 메시지를 솎아내는 방법을 소개하겠습니다.
컨슈머가 소비하는 메시지 ID를 무조건 DB 테이블에 저장하는 것입니다. 메시지 ID는 고유한 값인데 해당 값이 이미 DB에 존재한다면 한 번 처리된 메시지기 때문에 스킵하는 방법입니다.
따라서 반드시 메시지 핸들러가 중복 메시지를 걸러 내서 멱등하게 만들어줘야 합니다.
트랜잭셔널 메시징
서비스는 보통 DB를 업데이트하는 트랜잭션의 일부로 메시지를 발행합니다.
DB 업데이트와 메시지 전송을 한 트랜잭션으로 묶지 않으면, DB 업데이트 후 메시지는 아직 전송되지 않은 상태에서 서비스가 중단될 수 있기 때문에 문제가 됩니다.
이때 DB 테이블을 메시지 큐로 활용하면 해결할 수 있습니다.
이처럼 비즈니스 객체를 생성 수정 삭제하는 DB 트랜잭션의 일부로 QUEUE테이블에 메시지를 삽입합니다.
메시지를 DB에서 브로커로 옮기는 방법은 두 가지입니다.
- 폴링 발행기 패턴
👉 쿼리를 주기적으로 실행해서 처리하지 못한 메시지가 있는지 확인하고 있다면 처리 후 삭제해줍니다. 단순한 방법이라 쉽지만 쿼리의 비용이 클 경우 사용하지 못합니다. - 트랜잭션 로그 테일링 패턴
👉 트랜잭션 로그 마이너를 통해서 트랜잭션이 일어날 때마다 메시지를 발행합니다.
아래 리스트들이 실제 응용 사례입니다.
- 디비지움
- 링크드인 데이터버스
- DynamoDB 스트림즈
- 이벤추에이트 트램
비동기 메시징으로 가용성 개선
REST는 너무나 대중적이라 끌리는 IPC이지만 동기 프로토콜이라는 치명적인 문제점이 있습니다.
호출한 서비스가 응답할 때까지 HTTP 클라이언트가 마냥 기다려야 하죠.
즉 가용성이 저하됩니다 따라서 모든 트랜잭션은 비동기 상호 작용 스타일로 처리하는 것이 가장 좋습니다.
클라이언트/서비스는 메시징 채널을 통해 메시지를 전송해서 서로 비동기 통신합니다. 이런 상호 작용 과정에서는 어느 쪽도 응답을 대기하며 블로킹되지 않습니다.
하지만 꼭 동기 API를 사용해야 하는 경우엔 데이터를 복제하면 가용성을 높일 수 있습니다. 서비스 요청 처리에 필요한 데이터의 레플리카를 유지하는 방법입니다.
예를 들어 주문이 처리되고 주문에 대한 소비자/음식점 정보가 함께 반환되어야 한다면 응답을 대기해야 합니다. 이때 주문 서비스가 이미 소비자와 음식점에 대한 데이터 레플리카를 갖고 있다면 주문생성 시 굳이 소비자/음식점 서비스와 상호 작용할 필요가 없게 됩니다.
그림처럼 소비자/음식점 서비스의 데이터가 변경될 때마다 이벤트를 발행하고 주문 서비스는 이 이벤트를 구독하여 자기 레플리카를 업데이트하는 것입니다.
만약 너무 방대한 데이터를 복제하는 것이 어려운 상황이라면 응답 반환 후 마무리하는 방법이 있습니다.
주문 서비스는 주문을 PENDING 상태로 생성하고 응답을 반환합니다. 그 후 다른 서비스와 메시지를 교환하여 주문을 검증하게 됩니다.
정리
- IPC 기술은 무척 다양하지만 각자 장단점이 있습니다. 동기적이냐 비동기적이냐는 중요한 설계 결정입니다. 사용성은 동기 프로토콜이 좋지만 서비스 가용성을 높이려면 비동기 메시징 기반으로 서비스끼리 통신하는 것이 좋습니다.
- 메시징에서 관건은 DB를 원자적으로 업데이트하고 메시지를 발행하는 일입니다. 일단 메시지를 DB 트랜잭션의 일부로 DB에 쓰는 것이 좋은 방법입니다. 이후 별도 프로세스가 DB의 변화를 확인해 DB에서 메시지 조회 후 메시지 브로커에 발행하면 됩니다.
Rerference