MSA(MicroService Architecture)
기존 시스템들이 하나의 거대한 형태로 구축되어서 서비스되었다고 하면 마이크로 서비스라는 것은 전체 서비스를 구축하고 있는 개별적인 모듈이나 기능을 독립적으로 개발하고 운영할 수 있도록 세분화된 서비스라고 볼 수 있다. 일반적으로 사용하는 모놀리식 아키텍처와 MSA를 비교하고, MSA를 사용하는 이유에 대해 알아보자.
모놀리식 아키텍처(Monolithic Architecture)
모놀리식 아키텍처는 모든 종류의 서비스가 하나의 애플리케이션으로 구성되어 있는 아키텍처를 의미하고 다음과 같은 특징이 있다.
- 하나의 주요 프로세스로 구성
- 모든 서비스가 하나의 DB 엔드포인트를 사용
- 단 한줄만 코드 수정하더라도 모든 서비스 애플리케이션의 재배포가 따름
- 싱글 혹은 멀티 모듈로 구성할 수는 있지만 CI의 단위가 달라질 뿐, CD(배포)의 범위는 여전히 전체
초기 버전을 개발할 때는 굳이 마이크로 아키텍처를 사용해서 해결할 이슈가 거의 없다. 오히려 분산 아키텍처를 사용함으로써 개발 속도가 더딜 수 있고, 관리 비용 등 리소스가 증가하기 때문에 비즈니스 모델과 이를 뒷받침하는 애플리케이션을 재빠르게 발전시켜야 하는 스타트업 회사는 모놀리식 애플리케이션으로 시작하는 것이 좋다.
모놀리식 아키텍처의 장점
- 개발이 간단하다.
- IDE 등 개발 툴은 단일 애플리케이션 구축에 초점이 맞추어져 있다.
- 초기 스타트업에 맞춰 MVP를 빠르게 개발할 수 있다.
- 테스트하기 쉽다.
- 개발자가 애플리케이션을 띄우고, REST API를 호출하고, 종단 간 테스트를 작성하기 쉽다.
- 해당 장점이 모놀리식 아키텍처에서 가장 큰 장점이라고 할 수 있다. MSA가 되면 각각의 서비스가 따로 운영 및 관리되고 테스트 시 테스트하고자 하는 시스템에 대해 원하는 환경과 이상적으로 맞춰져야 하는데 거의 불가능하기 때문에 통합 테스트는 매우 힘들다고 할 수 있다. 하지만 모놀리식 아키텍처를 하면 가능하기 때문에 가장 큰 장점이라고 할 수 있다.
모놀리식 아키텍처의 단점
- 서비스가 커짐에 따라 복잡해진다.
- 대규모 개발 조직에서 유저, 상품, 주문, 결제 등 각각의 서비스가 커짐에 따라 코드를 변경해야 할 때마다 소통/조정 오버헤드를 유발하고, 점점 더 복잡하고 난해한 코드를 유발할 수도 있다.
- 커밋부터 배포에 이르는 길고 험난한 여정
- 여러 개발자가 같은 코드베이스에 소스 커밋을 하다보니 완성된 코드가 프로덕션에 실행되기 전까지 바로 릴리스할 수 없는 경우가 발생할 수 있다.
- 대규모 애플리케이션 환경에서는 단순한 수정 사항일지라도, 배포까지 오래 걸릴 수 있다.
- 확장하기 어렵다.
- 애플리케이션은 모듈마다 리소스 요건이 맞지 않아 확장하기 어려운 문제가 발생할 수 있다. 어떤 서비스는 In-memory DB에 많아 메모리가 많은 서버에 배포되는 것이 좋은 반면, 어떤 서비스는 CPU 코어 수가 많은 서버에 배포하는 것이 최적일 수 있다.
- 단일 DB에 대한 의존성과 하나의 애플리케이션에 모든 서비스를 포함하고 있기 때문에 수평 확장이 어렵다.
정말 작은 규모의 서비스에서라면 빠르게 개발하고 테스트하기 쉬운 등 모놀리식 아키텍처로 빠르게 MVP를 개발하는 것이 맞다고 할 수 있다. 하지만 모놀리식 아키텍처의 규모가 커지면은 위에서 말한 장점이 될 수가 없다. 왜냐하면 어떤 수준의 코드들은 빌드하는 데만 1시간 걸리는 코드가 생기던지 여러 서비스를 가지고 있는 모놀리식 아키텍처에서 일부 서비스 기능만 바꾼다고 하더라도 전체 서비스를 재배포해야 하기 때문에 애플리케이션을 쉽게 변경할 수가 없다.
마이크로서비스 아키텍처(MicroService Architecture)
마이크로서비스 아키텍처는 모놀리식 아키텍처와 다르게 애플리케이션을 독립적인 서비스로 구성하는 방식이다. 각 서비스는 특정 비즈니스 기능을 수행하며, 별도의 코드베이스와 데이터 저장소를 가져 이를 통해 더 효율적인 개발이 가능하고 확장성을 가지고 있다. 이러한 마이크로서비스 아키텍처의 장/단점은 다음과 같다.
마이크로서비스 아키텍처 장점
- 서비스를 독립적으로 배포/확장할 수 있다.
- 모놀리식 아키텍처와 다르게 각 서비스에 대한 리소스 요건에 맞는 서버에 배포를 할 수 있다.
- 확장이라고 함은 단순히 기능, 인프라적인 확장만을 의미하는 것이 아니라 RNR(Role and Responsibility)로 나눠 팀을 구성하고 협업이 용이해진다. 각 팀은 자신이 맡은 서비스의 전 과정을 관리할 수 있기 때문에 개발, 배포, 운영에 대한 책임감이 커지며, 팀 안뒤의 독립성이 강화되어 팀을 확장시키면서 조직을 쉽게 키울 수 있다는 장점이 있다.
- 결함 격리가 잘된다.
- 모놀리식 아키텍처는 어느 한 서비스만 장애나도 전체 시스템에 문제가 발생할 수 있다. 하지만 MSA에서는 어느 한 서비스에서 장애가 발생하더라도 전체 서비스가 아닌 해당 서비스만 영향을 받기 때문에 모놀리식 아키텍처에 비해 장점이라고 할 수는 있다. 하지만 회원 서비스가 장애났다고 가정한 경우 회원 서비스를 이용해야 하는 다른 서비스들도 장애가 전파되는 것은 마찬가지이기 때문에 서비스 관점에서는 단일 장애 지점은 피할 수 없다.
마이크로서비스 아키텍처 단점
- 딱 맞는 서비스들로 분해하기 어렵다.
- 분산 시스템은 복잡해서 개발, 테스트, 배포가 어렵다.
- 서비스 간 통신에 필수적인 IPC 역시 단순 메서드 호출 보다는 복잡하며, 사용 불능 또는 지연 시간(latency)이 긴 원격 서비스, 부분 실패한 서비스를 처리할 수 있게 설계해야 하고, 다중 DB에 접속하여 조회하고 트랜잭션을 구현하는 일은 어렵다.
- 여러 서비스가 연관된 테스트를 자동화하는 것은 쉽지 않다.
- 운영 복잡도 역시 가중시킨다. 종류가 다른 서비스가 여러 인스턴스로 떠 있어 프로덕션에서 관리해야 할 리소스가 증가한다.
- 위에서 말했듯 테스트하기가 개발자 수준에서 하기가 정말 힘들어져 테스트를 보완하기 위해 a/b 테스트, 카나리 배포 등 다양한 툴과 기법들이 생겨 단점을 보완할 수 있다.
서비스 분해의 장애물
비즈니스 능력과 하위 도메인별로 서비스를 정의해서 마이크로서비스 아키텍처를 구축하는 전략은 아래와 같은 장애 요소가 많다.
- 네트워크 지연
- 서비스를 여러 개로 나누면 서비스 간 왕복 횟수가 급증하고, 그에 따라 RTT가 늘어난다고 할 수 있다.
- 동기 통신으로 인한 가용성 저하
- 주문 서비스의 createOrder()에서 타 서비스에서 REST API를 동기 호출하는 것은 쉽지만 타 서비스 중 하나라도 불능일 경우 주문은 생성되지 않기 때문에 가용성이 떨어질 수 있다.
- 이는 비동기 메시징으로 강한 결합도를 제거하고 가용성을 높이는 방법을 사용할 수 있다.
- 여러 서비스에 걸쳐 데이터 일관성 유지
- 여러 서비스에 걸쳐 데이터를 원자적으로 업데이트하는 것은 어렵다.
- 2PC 분산 트랜잭션이나 SAGA 패턴 등으로 기존 ACID 트랜잭션보다는 복잡하지만 다양한 상황에서 트랜잭션을 관리할 수 있다. 한 가지 단점으로는 최종 일관성(eventual consistency, 실시간 동기화는 불가능하지만, 결국 언젠간(eventually) 데이터가 동기화되어 일관성 맞추는 것)을 보장한다는 것이다.
이처럼 마이크로서비스 아키텍처를 채택해서 개발을 할 때 고려해야 할 사항이 늘어난다. 서비스 간 어떻게 통신을 할 지 결정하는 것은 중요한 사항 중 하나인데 요즘은 JSON으로 주고받는 REST가 대세이지만, 모든 경우를 만족한다고는 할 수 없어 여러가지 옵션을 잘 검토해야 한다. 프로세스 간 통신을 의미하는 IPC에는 어떤 다양한 옵션들이 있는지 확인해야 한다.
IPC(Inter Process Communication)
IPC는 여러 프로세스 간의 데이터 전송 및 통신을 가능하게 하는 메커니즘을 말한다. 프로세스는 독립적으로 실행되는 프로그램의 인스턴스이며, IPC는 이들 간의 협력을 필요로 할 때 서비스에 적용 가능한 다양한 옵션이 있다. HTTP 기반 REST나 gRPC 등 동기 요청/응답 기반의 통신 메커니즘도 있고, AMQP, STOMP 등 비동기 메시지 기반의 통신 메커니즘도 있다. 메시지 포맷 역시 JSON, XML처럼 인간이 읽을 수 있는 텍스트 포맷부터 아브로나 프로토콜 버퍼처럼 효율이 우수한 이진 포맷까지 다양하다.
동기식 IPC - REST
동기식 IPC에서 클라이언트가 서비스에 요청을 보내면 서비스가 처리 후 응답을 회신하는 패턴으로 HTTP 기반 REST나 gRPC 등의 프로토콜이 있다. REST는 HTTP로 통신하는 IPC이다. 서비스는 HTTP Method를 이용해서 액션을 수행하고(ex: GET은 조회, POST는 생성, PUT은 수정), 요청 쿼리 매개변수 및 본문, 필요 시 매개변수를 지정한다.
부분 실패 처리
클라이언트/서비스는 모두 개별 프로세스로 동작하기 때문에 서비스가 클라이언트 요청에 제때 응답하지 못하거나, 유지보수 또는 기술적 오류때문에 하나의 서비스가 장애가 발생하는 경우 장애난 서비스에 요청한 서비스에서는 응답 대기 도중 블로킹되기 때문에 스레드 같은 주요 리소스가 고갈되어 서비스 장애가 전파되는 문제가 발생할 수도 있다. 서비스가 다른 서비스를 동기 호출할 때 자신의 서비스를 방어하는 방법은 다음과 같다.
- 네트워크 타임아웃
- 응답 대기 중에 무한정 블로킹하면 안 되고 항상 타임아웃을 설정해 리소스가 계속 붙잡히지 않도록 할 수 있다.
- 미처리 요청(outstanding request) 개수 제한
- 클라이언트가 특정 서비스에 요청 가능한 미처리 요청의 최대 개수를 설정하여 이 개수에 이르면 즉시 실패 처리하도록 할 수 있다.
- 회로 차단기 패턴
- 실패된 요청이 많다는 것은 서비스가 불능 상태고 더 이상의 요청이 무의미하다는 뜻이기 때문에 성공/실패 요청 개수에 따른 에러율이 주어진 임계치를 초과하면 회로 차단기를 열고, 타임아웃 시간 이후 클라이언트가 재시도해서 성공하면 회로 차단기를 닫을 수 있다.
위와 같은 방법으로 부분 실패를 처리하는 방법 외에도 어떤 서비스가 다른 서비스를 호출할 때 해당 서비스 인스턴스의 네트워크 위치를 알고 있어야 하는 것도 문제가 될 수 있다. 이런 문제를 해결하기 위해 서비스 디스커버리 메커니즘에 대해서 알아보자.
서비스 디스커버리
서비스 디스커버리는 서비스 IP 주소가 정적으로 구성된 인스턴스와 달리 네트워크 위치가 동적으로 배정될 때 네트워크 내에서 서비스나 리소스를 자동으로 찾고, 이를 이용할 수 있도록 도와주는 메커니즘을 말한다. 해당 메커니즘의 핵심은 애플리케이션 서비스 인스턴스의 네트워크 위치를 DB화한 서비스 레지스트리(service registry)이다. 서비스 인스턴스는 자신의 네트워크 위치를 서비스 레지스트리에 등록하고, 서비스 클라이언트는 이 서비스 레지스트리로부터 전체 서비스 인스턴스 목록을 가져와 그 중 한 인스턴스로 요청을 라우팅한다.
도커나 쿠버네티스 등 최신 배포 플랫폼에는 대부분 서비스 레지스트리, 서비스 디스커버리 메커니즘이 탑재되어 있다. 서비스 클라이언트가 DNS명/VIP(Virtual IP)를 요청하면 배포 플랫폼이 알아서 가용 서비스 인스턴스 중 하나로 요청을 라우팅한다. 서비스 IP 주소를 추적하는 서비스 레지스트리는 배포 플랫폼에 내장되어 있다. 예를 들어, 클라이언트는 order-service라는 DNS 명으로 주문 서비스에 접근하고, 라우터는 서비스 레지스트리를 질의한 후 가용 서비스 인스턴스들에 고루 요청을 부하 분상하여 이 DNS명은 가상 IP 주소 10.1.3.4로 해석될 수 있다.
비동기 IPC - 비동기 메시징 패턴
메시징은 서비스가 메시지를 서로 비동기적으로 주고받는 통신 방식이다. 메시징 기반의 애플리케이션은 보통 서비스 간 중개 역할을 하는 메시지 브로커를 사용하지만 서비스가 직접 서로 통신하는 브로커리스(brokerless, 브로커가 없는) 아키텍처도 있다. 일반적으로 브로커 기반의 아키텍처를 사용한다. 그리고 비동기 통신을 하기 때문에 클라이언트가 응답을 기다리며 블로킹하지 않는다.
비동기 메시징 패턴을 사용하는 이유
비동기로 처리하는 것이 아닌 메시지 큐를 사용하는 이유는 중요한 메세지를 소비하는 다수의 서비스가 있다면 api 콜 방식을 사용하는 경우 네트워크 트래픽이 엄청 많아질 것이고,
메시지 브로커
메시지 브로커는 모든 메시지가 지나자는 중간 지점이다. 송신자가 메시지 브로커에 메시지를 쓰면 메시지 브로커는 메시지를 수신자에게 전달한다. 메시지 브로커의 가장 큰 장점은 송신자가 컨슈머의 네트워크 위치를 몰라도 된다는 것이다. 또 컨슈머가 메시지를 처리할 수 있을 때까지 메시지 브로커에 메시지를 버퍼링할 수도 있다. 다음과 같은 오픈 소스 메시지 브로커들이 있다.
- RabbitMQ
- Apache/Confluent Kafka
메시지 브로커 기반의 메시징은 여러모로 장점이 많다.
- 느슨한 결합
- 클라이언트는 적절한 채널에 그냥 메시지를 보내는 식으로 요청한다. 클라이언트는 서비스 인스턴스를 몰라도 되므로 서비스 인스턴스 위치를 알려 주는 디스커버리 메커니즘도 필요없다.
- 예를 들어, 중요한 메시지 데이터를 소비하는 여러 서비스가 있다면 api 콜 방식의 경우에는 네트워크 트래픽이 엄청 많아질 것이고, 각각의 서비스가 다대다(N:M)을 하게 되기 때문에 서비스에 대한 의존성이 강해지고, 관리가 어려워진다고 할 수 있다. 하지만 메시지 브로커를 중심으로 연결하는 경우에는 위에서 말했듯 클라이언트는 메시지 브로커에 메세지를 보내기만 하기 때문에 느슨한 결합으로 확장성이 용이하다고 할 수 있다.
- 메시지 버퍼링
- 메시지 브로커는 처리 가능한 시점까지 메시지를 버퍼링한다. HTTP 같은 동기 요청/응답 프로토콜을 쓰면 교환이 일어나는 동안 클라이언트/서비스 양쪽 모두 가동 중이어야 하지만 메시징을 쓰면 컨슈머가 처리할 수 있을 때까지 그냥 큐에 메시지가 쌓여 컨슈가 느려지거나 불능 상태에 빠지더라도 클라이언트는 계속 요청을 받아 메시지를 보낼 수 있다.
하지만 메시징의 다음과 같은 단점이 있을 수 있다.
- 성능 병목 가능성
- 메시지 브로커가 성능 병목점이 될 위험이 있다. 하지만 다행히 요즘 메시지 브로커는 대부분 확장이 잘되도록 설계되어있다.
- 단일 장애점 가능성
- 메시지 브로커는 시스템의 신뢰성에 흠이 가지 않게 가용성이 높아야 한다. 요즘 브로커는 대부분 고가용성이 보장되도록 설계되어있다.
- 운영 복잡도 부가
- 메시징 시스템 역시 설치, 구성 운영해야할 시스템 컴포너트로 이를 위한 리소스가 증가하게 된다.
수신자 경합과 메시지 순서
메시지를 동시에 처리하기 위해 다수의 스레드와 서비스 인스턴스(메시지 수신자)를 늘리면 애플리케이션 처리율이 증가한다. 하지만 메시지를 정확히 한 번만 순서대로 처리해야하는 상황에서는 문제가 발생할 수 있다. 예를 들어, 송신자는 주문 생성, 주문 변경, 주문 취소 이벤트 메시지를 차례로 전송한다고 가정한 경우, 메시지를 종류별로 정해진 수신자에 동시 전달하면 될 것 같지만 갖가지 네트워크 이슈나 가비지 컬렉션 문제로 지연이 발생할 수 있다. 따라서 다른 서비스가 주문 생성 메시지를 처리하기도 전에 주문 취소 메시지를 처리할 수 있기 때문에 메시지 처리 순서가 어긋나면 시스템이 오작동할 수 있다.
그래서 카프카 같은 메시지 브로커는 샤딩된 채널을 이용한다. 송신자는 메시지 헤더에 샤드 키를 지정하면 메시지 브로커가 메시지를 샤드 키별로 샤드/파티션에 배정하여 같은 주문에 대해 동일한 샤드에 발행되고, 어느 한 컨슈머 인스턴스만 해당 주문 메시지를 읽기 때문에 메시지 처리 순서를 보장할 수 있다.
중복 메시지 처리
메시지 브로커가 꼭 한 번만 메시지를 전달하면 좋지만 그만큼 값비싼 대가를 치러야 한다. 그래서 메시지 브로커는 보통 적어도 한 번 이상 메시지를 전달하는 옵션으로 설정하기 때문에 클라이언트나 네트워크 또는 브로커 자신이 실패할 경우, 같은 메시지를 여러 번 전달할 수도 있다. 예를 들어, 메시지 처리 후 DB 업데이트까지 마쳤는데, 메시지를 ACK하기 전에 클라이언트에 장애가 발생하여 다운된다면 클라이언트를 재기동할 때 메시지 브로커는 ACK 안 된 메시지를 다시 보내거나 다른 클라이언트 레플리카에 전송할 수 있다.
따라서 컨슈머가 메시지 id를 이용해 중복 메시지를 확인하면 이를 해결할 수 있다. 컨슈머는 메시지를 처리할 때 비즈니스 엔티티를 생성/수정하는 트랜잭션의 일부로 메시지 id를 DB 테이블에 기록한다. 그리고 중복된 메시지라면 위 그림처럼 INSERT 쿼리가 실패하고 중복된 처리가 발생하지 않도록 방지할 수 있다.
트랜잭셔널 메시징
서비스는 보통 DB를 업데이트하는 트랜잭션의 일부로 메시지를 발행한다. 이 두 작업이 서비스에서 원자적으로 수행되지 않으면 시스템이 실패할 경우 문제가 발생할 수도 있다. 그러면 애플리케이션에서 메시지를 확실하게 발행하려면 어떻게 해야 할까?
요청 정보를 저장하면 그거에 대해 발행자 측에서 트레킹하여 메세지를 보내는 방식을 통해 이와 같은 문제를 해결해볼 수 있다.
DB 테이블을 메시지 큐로 활용
RDBMS 기반의 애플리케이션이라면 DB 테이블을 임시 메시지 큐로 사용하는 트랜잭셔널 아웃 박스 패턴을 사용할 수 있다. 메시지를 보내는 서비스에 outbox 테이블을 만들고 DB 트랜잭션의 일부로 outbox 테이블에 메시지를 삽입한다. 그러면 메시지 릴레이로 테이블을 폴링해서 미발행 메시지를 조회하여 메시지 브로커로 보내고 추후 outbox 테이블에서 메시지를 발행한 메시지를 상태에 따라 삭제할 수 있다.
또는, 메시지 릴레이로 DB 트랜잭션 로그(커밋 로그)를 테일링(tailing)하는 방법이 있다. 애플리케이션에서 커밋된 업데이트는 각 DB의 트랜잭션 로그 항목(log entry, 로그 엔트리)으로 남는데 트랜잭션 로그 마이너(transaction log miner)로 트랜잭션 로그를 읽어 변경분을 하나씩 메시지로 전환하여 메시지 브로커에 발행할 수 있다.
출처
'기타 > MSA' 카테고리의 다른 글
Kafka 핵심 개념 (1) | 2024.11.13 |
---|---|
사가(Saga) vs 2단계 커밋(2-phase commit, 2PC) (0) | 2024.11.11 |