마이크로서비스 아키텍처

차분한열정·2021년 6월 6일
0

1. 커맨드와 쿼리

커맨드 = CUD
쿼리 = R

단일 책임 원칙은 클래스 뿐만 아니라 마이크로서비스 아키텍처에도 적용된다. 즉, 서비스를 분해하고 났을 때 각 서비스들은 하나의 책임만 가진 작고 응집된 서비스여야만 한다.

2. DDD

배달의 민족에서 만약 Order라는 클래스를 설계하려고 한다. 하지만 Order는 '소비자 주문', '주방에서의 요리', '배달원의 배달' 등 수많은 작업과 연계되어 있기 때문에 가져야할 속성들이 많다. 이렇게 되면 Order 클래스가 너무 비대해지는데 이 문제를 해결하려면 DDD를 적용하여 각 서비스를 자체 도메인 모델을 갖고 있는 개별 하위 도메인으로 취급하면 된다. 즉, 각 서비스마다 Order를 나타내는 그것만의 클래스를 상정하고 설계하면 되는 것이다. 예를 들어 주문에서는 그냥 Order, 주방에서는 Ticket, 배달에서는 Delivery 등 각 서비스의 측면에서 필요로 하는 속성들만을 갖고 있는 클래스를 정의해서 사용하면 된다.

3. 마이크로서비스 아키텍처에서의 IPC 개요

  • HTTP 기반 REST, gRPC 등 동기 요청/응답 기반의 통신
  • AMQP, STOMP 등 비동기 메시지 기반의 통신
  • JSON/XML(텍스트 포맷) vs. 프로토컬 버퍼(이진 포맷)
  • 메시징으로 통신하는 API는 메시지
  • gRPC는 REST를 대체할만한 유력한 방안이지만, REST처럼 동기 통신하는 메커니즘이라서 부분 실패 문제라는 숙제를 풀어야 한다.
  • 동기 통신은 기본적으로 호출한 서비스가 응답할 때까지 HTTP 클라이언트가 마냥 기다려야 한다는 단점이 있다. -> 이는 곧 가용성 저하로 이어진다. 물론 메시징을 사용한다고 해도 메시지 브로커에 메시지를 보내고 응답을 기다려야하는 구조라면 마찬가지로 가용성이 떨어진다.

4. 회로 차단기 패턴

  • 분산 시스템은 서비스가 다른 서비스를 동기 호출할 때마다 부분 실패할 가능성이 항상 있다. 이것이 전체 시스템의 장애로 이어지지 않게 하려면 다음과 같은 방법들을 사용해야 한다.
    (1) 네트워크 타임아웃을 걸어서 응답 대기 중 무한 블로킹하지 않도록 해서 리소스가 계속적으로 사용되는 것을 막는다.
    (2) 클라이언트가 특정 서비스에 일정 수 이상의 요청은 하지 못하도록 막는다.
    (3) 성공/실패 비율을 모니터링하다가 에러율이 일정 기준을 초과하면 그 이후 시도는 모두 실패 처리한다. 타임아웃 시간 이후 재시도했을 때 성공한다면 회로 차단기는 닫는다.

5. 서비스 디스커버리

어떤 서비스를 호출하려면 해당 서비스를 제공하는 인스턴스의 IP 주소와 포트를 알고 있어야 한다. 그런데 오늘날과 같은 클라우드 기반의 시대에서는 네트워크 위치가 동적인 편이라서 이를 고정적으로 사용하는 것이 쉽지 않기 때문에 클라이언트 코드에서 서비스 디스커버리를 사용하는 것이 필요해진 것이다. 보통 특정 서비스 인스턴스가 시작/종료될 때마다 그 네트워크 위치를 기록한 서비스 레지스트리를 사용하는 경우가 많다. 좀더 구체적으로 설명하자면 다음과 같다.

(1) 서비스를 제공하는 인스턴스는 자신의 네트워크 위치를 서비스 레지스트리의 등록 API를 호출해서 셀프 등록한다.
(2) 클라이언트는 서비스를 호출하기 전에 일단 서비스 레지스트리에 서비스 인스턴스 목록을 요청해서 넘겨받는다. 그 후 클라이언트는 라운드 로빈 혹은 랜덤 방식의 부하 분산 알고리즘을 바탕으로 특정 인스턴스를 선택하여 요청을 전송한다.

그런데 이런 서비스 디스커버리는 자체적으로 구현하기보다는 쿠버네티스 등의 플랫폼을 사용할 경우 해당 플랫폼에서 제공하는 것을 사용하는 것이 좋다. 이 경우 (1), (2)의 작업이 모두 플랫폼에 의해 대행되는데 예를 들어

(1-b) 서비스가 자신을 서비스 레지시트리에 직접 등록하는 것이 아니라 플랫폼에서 이 작업을 대신 수행하고
(2-b) 클라이언트가 직접 서비스 레지스트리로부터 서비스 인스턴스들의 목록을 받아오는 것이 아니라 바로 가상의 서비스 주소(VIP)로 요청을 보내면 이것이 자동으로 부하 분산되는 원리이다.

6. 비동기 메시징 패턴

메시징은 서비스 간에 메시지를 서로 비동기적으로 주고받는 통신 방식으로, 보통 서비스 사이에서 중개 역할을 하는 메시지 브로커를 사용하지만 브로커 없는 브로커리스 아키텍처가 있기도 하다.

메시지는 채널(channel, 메시징 인프라를 추상화한 개념)을 통해 교환된다. 채널에는 크게 2가지 종류가 있다.

(1) 점대점(point-to-point) 채널: 채널을 읽는 컨슈머 중 딱 하나만 지정하여 메시지를 전달한다. 커맨드 메시지처럼 일대일로 상호작용하는 서비스가 이 채널을 사용한다.

(2) 발행-구독(publish-subscribe) 채널: 같은 채널을 바라보는 모든 컨슈머에 메시지를 전달한다. 이벤트 메시지처럼 일대다로 상호작용하는 서비스가 이 채널을 사용한다.

메시징의 전형적인 설계를 보자면

클라이언트 ----- > 서비스를 담당하는 요청 채널과
서비스 ----- > 클라이언트를 담당하는 응답 채널이 있다.

이때 맨 처음 클라이언트는 메시지의 헤더에

  • 메시지 ID와
  • 나중에 응답 메시지를 받을 응답 채널

을 표시하여 메시지를 채널에 보내고, 서비스는 해당 작업을 처리하고 나서 메시지의 헤더에 있는 응답 채널로 응답 메시지를 보낸다. 그럼 클라이언트는 메시지 ID를 맞추어봄으로써 어느 메시지에 대한 응답 메시지를 알 수 있게 되는 것이다.

사실 브로커리스 메시징은 일반 동기 요청/응답과 큰 차이는 없기 때문에 큰 기업에서는 모두 메시지 브로커 기반의 서비스를 사용한다. 메시지 브로커의 큰 장점은

  • 송신자가 컨슈머의 네트워크 위치를 몰라도 되고
  • 컨슈머가 메시지를 처리할 수 있을 때까지 메시지 브로커에 메시지들을 버퍼링할 수 있다는 점입니다.

메시지 브로커 제품에는 ActiveMQ, RabbitMQ, Apache Kafka, AWS Kinesis, AWS SQS 등이 있다. 여러 제품 중 하나를 선택할 때는

  • 메시지 순서가 유지되는지
  • 어떤 종류의 전달 보장을 하는지
  • 브로커가 고장나도 문제없도록 메시지를 디스크에 저장하는지
  • 컨슈머가 메시지 브로커에 다시 접속해도 미접속 기간 중 전달된 메시지를 받을 수 있는지
  • 얼마나 확장성이 좋은지
  • 종단 간 지연 시간은 얼마나 되는지

등을 기준으로 판단해야 한다.

(1) 수신자 경합과 메시지 순서 문제

특정 주문에 관한 메시지는 동일 인스턴스에 순서대로 잘 전달되어야 한다. 이때 송신자는 메시지에 '주문 번호' 등과 같은 Shard-key를 넣어서 보내야 하고, 메시지 브로커는 채널을 여러 개의 샤드로 나누어 특정 주문은 특정 샤드로 보내야 한다. 그럼 각 샤드는 본인에게 할당된 서비스 인스턴스에 메시지를 보낸다.

(2) 중복 메시지 문제

이미 보낸 메시지를 다시 보내게 되는 경우에도 큰 문제가 발생할 수 있다. 이미 서비스 인스턴스는 모든 작업을 마쳤는데 네트워크 문제 등으로 인해 클라이언트가 ACK 신호를 받지 못한 경우에는 다시 메시지를 전송할 수도 있는데 이럴 때는 어차피 멱등(idempotent)한 작업이라면 계속 처리해도 되겠지만 보통 그런 경우는 많지 않기 때문에 컨슈머에서 이미 처리한 메시지인지를 확인하는 절차가 필요하다. 예를 들어 메시지 처리 여부에 관한 테이블을 별도로 두고 관리함으로써 이 문제를 해결할 수 있다.

7. 큰 틀

비동기 메시징을 사용하는 아키텍처의 큰 틀은 다음과 같다. 예를 들어 주문이 들어오면 주문 서비스에서는 일단 바로 주문을 데이터베이스에 저장하고, 잘 접수되었다는 응답을 클라이언트에게 준다. 그리고 그 뒤 데이터베이스에 저장된 주문은 어떤 방식으로든 메시징 시스템에 삽입되고, 이것이 회원 서비스, 음식점 서비스 등에서 비동기적으로 검증된 뒤에, 모든 검증이 완료되면 주문 서비스는 데이터베이스에서 해당 주문을 'Validated' 등의 상태로 변경한다. 만약 검증 도중, '주문 불가 음식점' 등의 이유로 검증이 실패하게 되면, 이를 알리기 위해 주문 서비스가 클라이언트에게 추후에 별도의 알림을 주는 식이다.(물론 클라이언트가 주기적 폴링을 할 수도 있겠지만 바람직하지는 않다)

profile
성장의 기쁨

0개의 댓글