Post

microservice pattern.03

microservice patterns 책을 통해 학습한 내용을 정리한 글입니다.

microservice pattern.03

IPC

모놀리식 애플리케이션에서 모듈은 언어 레벨 메소드 혹은 함수를 호출하기에, 클라우드 서비스와 연동하는 모듈이나 REST API를 개발하는 것이 아니면 IPC를 고려하지 않았습니다. 마이크로서비스 구조는 애플리케이션이 서비스의 집합으로 구성됩니다. 이 서비스들은 요청을 처리하기 위해 종종 협업합니다. 서비스들은 통상적으로 다수의 머신에서 실행되는 프로레스들이기에, IPC를 이용해 상호작용해야합니다.

현재 통상적으로 많이 쓰이는 IPC 메커니즘은 REST 입니다. REST는 모든 상황에서 최적의 솔루션은 아니기에 다른 옵션들도 신중하게 고려해야 합니다.

overview of IPC in microservice architecture

선택할 수 있는 다양한 IPC 기술들이 존재합니다. 서비스는 synchronous request/response 기반 통신 메커니즘은 REST 혹은 gRPC를 사용할 수 있습니다. 혹은 비동기적, 메세지 기반 통신인 AMQP, STOMP를 사용할 수 있습니다. 메세지 형식도 다양하게 존재합니다. 사람이 읽을 수 있고 텍스트 기반인 JSON, XML 형식이 존재하고, Avro, Protocol Buffer 같은 바이너리 형식도 존재합니다.

interaction styles

서비스 API의 IPC 메커니즘을 결정하기 이전에, 서비스와 클라이언트가 상호작용하는 방식에 대해 이해해야합니다. 상호작용 스타일에 대해 먼저 고려하는 것이 요구 사항에 집중하는 대에 도움을 줍니다.

클라이언트와 서비스는 다양한 방식으로 상호작용할 수 있습니다.

 one-to-oneone-to-many
SynchronousRequest/resposneXXXXX
AsynchronousAsynchronous request/response
One-way notifications
Publish/subscribe
Publish/async responses
  • one-to-one : 각 클라이언트의 리퀘스트는 단 하나의 서비스에서 프로세스됩니다.
  • one-to-many : 각 리퀘스트는 다수의 서비스에서 프로세스됩니다.
  • synchronous : 클라이언트는 서비스로부터 즉각적인 응답을 기대하며, 응답을 기다리는 동안 블록될 수 있습니다.
  • asynchronous : 클라이언트는 블록되지 않으며, 응답이 있더라도 반드시 즉시 전송되지는 않습니다.

다음은 one-to-one 상호작용의 타입들입니다.

  • Request/response : 서비스 클라이언트는 서비스에 요청을 보내고, 응답을 기다립니다. 클라이언트는 즉각적인 응답이 오기를 기다립니다. 응답을 기다리는 동안 block될 수도 있습니다. 주로 단단하게 결합된 서비스들에서의 상호작용 방식입니다.
  • Asynchronous request/response : 서비스 클라이언트는 서비스에 요청을 보내고, 서비스는 비동기적으로 응답합니다. 클라이언트는 대기하는 동안 block되지 않습니다. 서비스가 긴 시간동안 응답을 주지 않을 수도 있기 때문입니다.
  • One-way notifications : 서비스 클라이언트는 서비스에 요청을 보내고, 응답을 기다리지 않고, 서비스도 응답을 보내지 않습니다.

동기식 요청/응답 상호작용 방식은 IPC 기술과 대부분 무관하다는 점을 기억하는 것이 중요합니다. 서비스는 다른 서비스와 request/response 방식을 rest 혹은 메세징을 이용해 상호작용할 수 있습니다. 두 서비스가 메세지 브로커를 이용해 통신하더라도, 클라이언트 서비스는 응답을 기다리기 위해 block 될 수 있습니다. 그리고 이것은 서비스가 느슨하게 결합되었음을 의미하지는 않습니다.

one-to-many 상호작용 타입들은 다음과 같습니다.

  • Publish/subscribe : 클라이언트가 알림 메세지를 발행합니다. 알림 메세지는 해당 메세지에 관심있는 서비스에 의해 소비됩니다.
  • Publish/async responses : 클라이언트는 리퀘스트 메세지를 발행하고, 관심있는 서비스로부터 응답이 오기까지 특정 시간만큼 기다립니다.

서비스들은 통상적으로 다양한 상호작용 조합을 사용합니다.

defining APIs in microservice architecture

API 혹은 인터페이스는 소프트웨어 개발의 중심이 됩니다. 애플리케이션은 모듈들로 구성됩니다. 각 모듈은 모듈의 클라이언트가 호출할 수 있는 작업들을 정의한 인터페이스를 가지고 있습니다. 잘 디자인된 인터페이스는 내부 구현의 정보를 감춘채 사용자에게 기능을 제공합니다.

모놀리식 애플리케이션에서는 인터페이스가 일반적으로 Java 인터페이스와 같은 프로그래밍 언어의 구조를 사용하여 지정됩니다. Java 인터페이스는 클라이언트가 호출할 수 있는 메소드들을 명시합니다. 내부 구현 클래스는 클라이언트로부터 숨겨집니다. 더불어서 자바는 정적인 타입 언어이기에, 만약 인터페이스가 클라이언트와 incompatible 하게 변경된다면, 애플리케이션은 컴파일되지 않습니다.

API와 인터페이스는 마이크로서비스에서 동일하게 중요합니다. 서비스의 API는 서비스와 클라이언트 간의 계약입니다. 서비스의 API는 클라이언트가 호출할 수 있는 오퍼레이션, 서비스로부터 발행되는 이벤트로 구성됩니다. 오퍼레이션은 이름, 파라미터, 리턴 타입을 가집니다. 이벤트는 타입과 필드를 가지고, 메세지 채널로 발행됩니다.

여기서 문제는 서비스 API는 프로그래밍 언어의 구조를 사용하여 간단하게 정의되지 않는 다는 점입니다. 서비스와 클라이언트는 같이 컴파일되지 않습니다. 만약 새로운 서비스 버전이 호환되지 않는 API로 배포된다면, 컴파일 에러가 발생하는 것이 아니라, 런타임 에러가 발생하게 됩니다.

IPC 메커니즘과는 무관하게 서비스의 API를 interface definition language를 사용해서 정교하게 정의하는 것이 중요합니다. 먼저 인터페이스 정의를 작성합니다. 그리고 클라이언트 개발자와 함께 인터페이스 정의를 리뷰합니다. API 정의를 먼저 살펴본 이후에 서비스를 구현합니다. 이런 작업을 통해 클라이언트의 요구사항에 맞는 서비스를 개발할 수 있습니다.

API 정의의 특성은 사용 중인 IPC 메커니즘에 따라 달라집니다. 만약 메세징을 사용한다면, API는 메세지 채널로 구성됩니다. 만약 HTTP를 사용한다면, API는 URL, HTTP 동사, 그리고 리퀘스트, 리스폰스 형식으로 구성됩니다.

evolving APIs

API는 새로운 기능이 추가되고, 기존 기능이 수정되고, 삭제되면서 꾸준히 변화합니다. 모놀리식 애플리케이션에서 API를 변경하고 그에 맞게 호출하는 것들을 변경하는 것이 비교적 간단 명료합니다. 정적인 타입 언어를 사용하면, API를 변경하면, 컴파일 에러가 발생하고, 컴파일 에러들을 수정하는 것으로 API를 변경할 수 있습니다.

마이크로서비스 애플리케이션에서 API를 변경하는 것은 무척이나 더 어렵습니다. 서비스의 클라이언트는 다른 서비스들로 다른 팀으로부터 개발됩니다. 클라이언트는 심지어 조직 외 다른 애플리케이션이 될 수도 있습니다. 보통 모든 클라이언트가 서비스와 동시에 업그레이드하도록 강제할 수는 없습니다. 또한, 현대 애플리케이션은 유지 관리를 위해 다운되는 경우가 거의 없기 때문에, 일반적으로 서비스의 롤링 업그레이드를 수행하게 됩니다. 따라서 구버전과 신버전의 서비스가 동시에 실행되는 경우가 발생합니다.

이러한 문제를 해결하기 위한 전략을 마련하는 것이 중요합니다. API 변경 사항을 어떻게 처리할지는 변경하려는 것의 특성에 따라 달라집니다.

Semantic versioning

semantic versioning 는 API을 버저닝하는데 유용한 가이드입니다. 어떻게 버전 번호를 명시하고 증가하는지에 대한 규칙들로 구성됩니다. semantic versioning 은 초기에 소프트웨어 패키지를 버저닝하기 위해 개발되었습니다. 하지만 분산 시스템에서 API를 버저닝하는데에도 사용할 수 있습니다.

semantic versioning(Semvers) 버전 번호를 3가지 파트로 구분합니다. 버전 번호의 각 부분은 다음과 같이 증가시켜야 합니다.

  • MAJOR : API에 호환되지 않는 변경 사항이 있을 때
  • MINOR : API에 대한 하위 호환 가능한 기능 개선이 있을 때
  • PATCH : 하위 호환 가능한 버그 수정을 할 때

REST API, 메세징 같은 다양한 곳에서 semvers를 적용할 수 있습니다.

  • REST API 같은 경우, 메이저 버전을 URL 경로의 첫번째 엘리멘트로 선언할 수 있습니다.
  • 메세징의 경우, 버전 번호를 메세지에 포함할 수 있습니다.

이상적으로 하위 호환 가능한 기능 개선만 만드는 것이 좋습니다. 하위 호환 가능한 변경 사항들은 다음과 같은 기능을 API에 추가하는 것을 의미합니다.

  • 요청에 추가적인 속성 더하기
  • 응답에 추가적인 속성 더하기
  • 오퍼레이션 추가하기

위와 같은 변경 사항들만 만든다면, 클라이언트는 서비스에 맞춰 변경하지 않아도 원할하게 동작합니다. 서비스는 포함되지 않은 리퀘스트 속성들에 대한 기본 값을 제공해야되며, 클라이언트는 추가적인 속성 값들을 무시해야합니다. 이런 것들이 어렵지 않게 되게하려면, 리퀘스트와 응답 형식은 Robustness 원칙을 따라야합니다.

호환 가능하지 않은 변경 사항

종종 API에 호환되지 않는 변경 사항을 만들어야될 수 있습니다. 클라이언트도 즉시 업데이트되기를 강요할 수 없기에. 서비스는 오래된 버전과 새로운 버전을 동시에 서비스해야합니다. REST 같은 HTTP 기반 IPC 메커니즘을 사용한다면, 메이저 버전의 번호를 경로에 삽입할 수 있습니다.

버전 1의 경로는 /v1/..., 버전 2의 경로는 /v2/...

또 다른 옵션은 HTTP의 콘텐츠 협상 메커니즘을 사용하여 MIME 타입에 버전 번호를 포함하는 것입니다. 예를 들어, 클라이언트가 Order의 1.x 버전을 요청할 때 다음과 같은 요청을 보낼 수 있습니다.

1
2
GET /orders/xyz HTTP/1.1
Accept: application/vnd.example.resource+json; version=1

이 요청은 클라이언트가 1.x 버전의 응답을 기대한다는 의미입니다.

API의 다양한 버전을 지원하기 위해서는, API를 구현하는 서비스의 어댑터는 오래된 버전과 새로운 버전간의 번역을 지원해야 합니다.

message formats

IPC의 본질은 메세지의 교환입니다. 메시지에는 보통 데이터가 포함되며, 중요한 설계 결정 중 하나는 해당 데이터의 형식을 결정하는 것입니다. 메세지의 형식을 결정하는 것은 IPC의 효율성, API의 사용 편의성, 그리고 API의 확장 가능성에 영향을 미칩니다. 만약 메세징 시스템을 사용하거나, HTTP 같은 프로토콜을 사용하면, 사용할 메세지 형식을 선택할 수 있습니다. gRPC 같은 메커니즘 메세지 형식을 지정할 수도 있습니다. 어느 경우든, 언어 간 호환이 가능한 메시지 형식을 사용하는 것이 필수적입니다.

메세지 형식에는 크게 두가지 카테고리가 존재합니다 : 텍스트 기반, 바이너리 기반

text based message formats

첫번째 카테고리는 JSON, XML 같은 텍스트 기반 형식입니다. 이런 형식의 장점은 사람이 읽을 수 있을 뿐만 아니라, 자체적으로 구조를 설명할 수 있다는 점입니다. JSON 메세지는 이름이 지정된 속성의 모음입니다. 유사하게, XML 메시지도 사실상 이름이 지정된 요소와 값의 모음입니다. 이러한 형식은 메시지의 소비자가 관심 있는 값을 선택하고 나머지는 무시할 수 있도록 해줍니다. 결과적으로 메시지 스키마에 대한 많은 변경이 쉽게 하위 호환성을 유지할 수 있습니다.

텍스트 기반 형식의 단점은, 좀 글이 길어진다는 단점이 있습니다. 모든 메시지 프로퍼티의 이름을 포함해야하는 오버헤드가 발생합니다. 또 다른 단점은 텍스트를 파싱하면서 발생하는 오버헤드입니다. 만약 성능이 중요하다면, 바이너리 형식을 고려할 수 있습니다.

binary message formats

선택할 수 있는 다양한 바이너리 형식이 존재합니다. 인기있는 형식으로는 Protocol Buffers, Avro가 존재합니다. 두 형식 모두 메세지 형식을 정의하기 위한 타입 IDL을 제공합니다. 그런 다음 컴파일러는 메세지를 직렬화하고 역직렬화하는 코드를 생성합니다. API 중심의 접근 방식을 서비스 설계에 강제로 적용해야 합니다! 게다가, 클라이언트를 정적 타입 언어로 작성하면 컴파일러가 API를 올바르게 사용하고 있는지 검사합니다.

두가지 바이너리 형식 간의 차이점은 Protocol Buffers는 태그된 필드를 사용하고, Avro는 스키마를 알아야 메세지를 해석할 수 있습니다. 그렇기에, Protocol Buffers를 이용해 API의 발전에 대응하는 것이 더 쉽습니다.

Remote procedure Invocation pattern

remote procedure 기반 IPC 메커니즘을 사용할 때, 클라이언트는 서비스 요청을 보내고, 서비스를 요청을 처리하고, 응답을 보냅니다. 몇몇 클라이언트는 응답을 기다릴 때 block 될 수 도 있고, 다른 클라이언트는 reactive non-blocking architecture를 가질 수도 있습니다. 하지만 메세징을 사용할 때와는 다르게, 클라이언트는 리퀘스트에 대한 응답이 빠른 시간안에 도착할 것이라고 예상합니다.

Remote procedure Invocation은 다음과 같이 동작합니다. Screenshot 2024-10-09 at 17 56 55

클라이언트의 비즈니스 로직은 RPI 프록시 어댑터 클래스를 구현한 프록시 인터페이스를 호출합니다. RPI 프록시는 서비스에 요청을 보냅니다. 이 요청은 서비스의 비즈니스 로직을 인터페이스를 통해 호출하는 RPI 서버 어댑터 클래스에 의해 처리됩니다. 그런 다음 RPI 프록시에게 응답을 다시 보내고, RPI 프록시는 클라이언트의 비즈니스 로직에 결과를 반환합니다.

프록시 인터페이스는 통상적으로 커뮤니케이션 프로토콜을 캡슐화합니다. 선택할 수 있는 프로토콜에는 다양한 것들이 존재합니다. 대표적으로는 REST, gRPC가 존재합니다.

REST

오늘날 API들은 주로 RESTful하게 디자인 됩니다. REST는 IPC 메커니즘으로 거의 항상 HTTP를 사용합니다.

REST의 핵심 컨셉은 고객, 상품 혹은 비즈니스 오브젝트의 컬렉션을 대표하는 비즈니스 오브젝트, 리소스입니다. REST는 HTTP 동사를 이용해서 리소스를 조종합니다. 예를 들어 GET 리퀘스트는 리소스의 representation이 xml 문서 형태 혹은 json 오브젝트 형태로 리턴됩니다. 바이너리 형식도 가능은 하지만 텍스트 기반 형식이 주로 쓰입니다. POST 리퀘스트는 새로운 리소스를 생성하고, PUT 요청은 리소스를 업데이트하기 위해 사용합니다.

많은 개발자들은 자신의 HTTP 기반 API가 RESTful하다고 주장합니다. 하지만, 실제로 RESTful 하지 않을 수도 있습니다.

REST maturity model

Leonard Richardson은 REST에 매우 도움되는 maturity model을 다음과 같이 정의합니다.

  • level0 : 레벨 0 서비스의 클라이언트는 HTTP POST 요청을 통해 서비스의 유일한 URL 엔드포인트를 호출합니다. 각 요청은 수행할 작업, 작업의 대상(예: 비즈니스 객체), 그리고 필요한 매개변수를 지정합니다.
  • level1 : 레벨 1 서비스는 리소스의 개념을 지원합니다. 클라이언트가 리소스에 대해 작업을 수행하려면, 수행할 작업과 필요한 매개변수를 지정한 POST 요청을 보냅니다.
  • level2 : 레벨 2 서비스는 HTTP 동사를 사용해서 작업을 수행합니다. GET 을 통해 데이터를 반환받고, POST로 생성, PUT으로 수정합니다. 요청의 쿼리 매개변수와 바디(있을 경우)가 이 작업의 매개변수를 지정합니다. 이를 통해 서비스는 GET 요청에 대해 캐싱과 같은 웹 인프라를 활용할 수 있습니다.
  • level3 : 레벨 3 서비스는 HATEOAS(Hypertext As The Engine Of Application State) 원칙에 기반합니다. 기본 개념은 GET 요청에 의해 반환된 리소스의 표현에 해당 리소스에 대한 작업을 수행할 수 있는 링크가 포함된다는 것입니다. 예를 들어, 클라이언트는 주문을 조회한 요청에 의해 반환된 표현에 있는 링크를 통해 주문을 취소할 수 있습니다. HATEOAS의 장점은 클라이언트 코드에 URL을 하드코딩할 필요가 없다는 것입니다.

specifying REST APIs

앞서 설명한 것처럼, API는 IDL을 이용해서 정의해야 합니다. REST는 근본적으로 IDL을 가지지는 않았습니다. 다행히도 개발자 커뮤니티는 RESTful APIs들의 IDL에 대해서 재발견하게되었습니다. 가장 인기있는 REST IDL은 스웨거로부터 발전된 Open API Specification 입니다. 스웨거 프로젝트는 REST API를 개발하고 문서화하기 위한 도구들의 모음입니다.

fetching multiple resources in a single request

REST 리소스는 보통 고객이나 주문 같은 비즈니스 오브젝트로부터 유래됩니다. 결과적으로 REST API를 디자인할 때 자주 등장하는 문제는 어떻게 클라이언트로 하여금 한번의 요청으로 연관된 다수의 오브젝트를 반환받게 할 것인가 입니다. 예를 들어, REST 클라이언트가 주문과 주문의 고객을 리턴받고 싶어하는 상황입니다. 순수한 REST API는 클라이언트로 하여금 최소 2번의 요청을 하게 할 것입니다. 한번은 주문, 또 한번은 고객. 보다 더 복잡한 시나리오는 더 많은 round trip을 발생시킬 것입니다.

이런 문제를 해결할 수 있는 한가지 방법은 클라이언트로 하여금 리소스를 받을 때 연관된 리소스도 받는 것을 허용하는 것입니다. 예를 들어 클라이언트는 주문과 고객을 GET /orders/order-id-1345?expand=consumer로 받을 수 있습니다. 쿼리 파라미터는 주문과 함께 리턴될 리소스를 명시합니다. 이런 접근은 많은 시나리오에서 적합하게 동작하지만, 여전히 복잡한 시나리오에서 적용하기는 아쉽습니다. 이런 문제는 GraphQLNetflix Falcor 같은 효율적인 데이터 fetching을 지원하는 대체 API 기술의 인기를 증가시켰습니다.

mapping operations to HTTP verbs

또다른 REST API 디자인 문제는 어느 오퍼레이션을 어느 HTTP 동사와 매핑할 것인지 입니다. REST API는 PUT을 이용해 업데이트를 수행해야합니다. 하지만 주문을 업데이트하는 데에는 다양한 방법이 존재합니다. 주문은 취소될 수도 있고, 수정될 수도 있습니다. 또한 업데이트가 반드시 멱등성을 가지지 않을 수 있는데, PUT을 사용하려면 멱등성이 요구됩니다. 한 가지 솔루션은 리소스의 특정 부분을 업데이트할 때 서브 리소스를 정의하는 것 입니다. 주문 서비스를 예로 들면, POST /orders/{orderId}/cancel은 취소 요청의 엔드포인트이고, POST /orders/{orderId}/revise는 수정의 엔드포인트입니다. 오퍼레이션과 HTTP 동사를 연결짓는 것은 gRPC 같은 REST의 대안의 인기를 증가시켰습니다.

REST의 장점과 단점을 정리하면 다음과 같습니다.

  • 장점
    • 심플하고 익숙하다.
    • HTTP API를 브라우저나 커맨드 라인 툴을 이용해 테스트할 수 있다.
    • 즉각 적으로 request, response 형식 소통을 지원한다.
    • 방화벽 친화적입니다.
    • 중간 브로커를 필요로 하지 않아 시스템 아키텍처가 단순해집니다.
  • 단점
    • request, response 형식 소통만 지원합니다.
    • 가용성이 줄어들게 됩니다. 클라이언트와 서비스가 메시지를 버퍼링할 중간 매개체 없이 직접 통신하므로, 교환 기간 동안 두 시스템 모두 가동되어 있어야 합니다.
    • 클라이언트는 서비스 인스턴스들의 위치(URL)를 알아야합니다. 현대 애플리케이션에서 이는 사소하지 않은 문제입니다.(서비스 디스커버리가 필요합니다.)
    • 단일 요청으로 여러 리소스를 가져오는 것이 어렵습니다.
    • 여러 업데이트 작업을 HTTP 동사에 매핑하는 것이 때때로 어렵습니다.

gRPC

REST를 사용하면서 어려운 점 중 하나는 HTTP는 제한된 수의 동사만 제공하기에, 다양한 업데이트 작업을 지원하는 REST API를 만드는 것이 항상 명료하지는 않다는 점입니다. 이런 문제를 회피하는 IPC 기술은 gRPC입니다. gRPC는 바이너리 메세지 기반 프로토콜입니다. gRPC는 바이너리 메시지 형식이기에 API 우선 접근 방식으로 서비스 설계를 해야 합니다. gRPC API들은 Protocol Buffer 기반 IDL을 이용해 정의합니다. 그리고 Protocol Buffer 컴파일러를 이용해 클라이언트 사이드 stub, 서버 사이드 스켈레톤 코드를 생성할 수 있습니다. 컴파일러는 Java, C#, NodeJS, GoLang 같은 다양한 언어를 지원합니다. 클라이언트와 서버는 Protocol Buffer 형식의 바이너리 메세지를 HTTP/2를 이용해 교환합니다.

gRPC API는 다수의 서비스와 request/response 메세지 정의로 구성됩니다. 서비스 정의는 자바 인터페이스와 유사하며, 강타입 메서드들의 모음입니다. 간단한 request, response도 지원하고, gRPC는 스트리밍 RPC도 지원합니다. 서버는 클라이언트에 메세지의 스트림으로 응답할 수 있습니다. 클라이언트도 서버로 메세지의 스트림을 보낼 수 있습니다.

gRPC는 Protocol Buffers를 메세지 형식으로 사용합니다. Protocol Buffers는 효율적이고 컴팩트한 바이너리 형식입니다. Protocol Buffers 메세지의 각 필드는 번호가 붙여지고 타입 코드를 가지고 있습니다. 메세지 수신자는 필요한 필드를 추출하고 인식하지 못하는 필드는 건너뛸 수 있습니다. 그 결과, gRPC는 API가 진화하면서도 이전 버전과의 호환성을 유지할 수 있게 합니다.

OrderService의 gRPC API는 다음과 같이 정의할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
service OrderService {
  rpc createOrder(CreateOrderRequest) returns (CreateOrderReply) {}
  rpc cancelOrder(CancelOrderRequest) returns (CancelOrderReply) {}
  rpc reviseOrder(ReviseOrderRequest) returns (ReviseOrderReply) {}
  ...
}

message CreateOrderRequest {
  int64 restaurantId = 1;
  int64 consumerId = 2;
  repeated LineItem lineItems = 3;
  ...
}

message LineItem {
  string menuItemId = 1;
  int32 quantity = 2;
}

message CreateOrderReply {
  int64 orderId = 1;
}
...

CreateOrderRequest 그리고 CreateOrderReply는 타입 메세지입니다. 예를들어, CreateOrderRequest 메세지는 restaurantId라는 int64 타입의 필드를 가집니다. 필드의 태그 값은 1입니다.

gRPC는 다음과 같은 장점을 가집니다.

  • 다양한 업데이트 작업을 가진 API를 설계하는 것이 간단합니다.
  • 큰 메시지를 교환할 때 효율적이고 압축된 IPC 메커니즘을 가지고 있습니다.
  • 양방향 스트리밍을 통해 RPC와 메세징 방식의 통신을 모두 사용할 수 있습니다.
  • 다양한 언어로 작성된 클라이언트와 서비스 간의 상호 운용성을 제공합니다.

다음과 같은 단점도 존재합니다.

  • JavaScript 클라이언트가 gRPC 기반 API를 사용하는 것이 REST/JSON 기반 API보다 더 복잡합니다.
  • 오래된 방화벽은 HTTP/2를 지원하지 않을 수 있습니다.

circuit breaker pattern

분산 시스템에서 서비스가 다른 서비스에 동기적인 요청을 할 때, 항상 부분 실패의 위험이 존재합니다. 클라이언트와 서비스가 별도의 프로세스이기 때문에 서비스가 클라이언트의 요청에 신속하게 응답하지 못할 수 있습니다. 서비스는 장애나 유지보수로 인해 다운될 수 있습니다. 또는 서비스가 과부하 상태여서 요청에 매우 느리게 응답할 수도 있습니다. 클라이언트는 응답을 대기하기 때문에, 장애가 지속적으로 전파될 수 있다는 위험이 존재합니다.

Screenshot 2024-10-10 at 11 12 22

OrderService가 응답하지 못하는 시나리오를 생각해봅시다. 모바일 클라이언트는 API 게이트웨이를 통해 REST 요청을 보냅니다. API 게이트웨이는 응답하지 못하는 OrderServices로 요청을 프록시합니다.

OrderServiceProxy을 순진하게 구현했다면, 무한하게 대기할 것입니다. 이런 구현은 유저 경험을 해치고, 스레드같은 중요한 자원을 소모합니다. 결국 API 게이트웨이는 리소스가 고갈될 것이고, 요청을 처리할 수 없는 상태에 놓일 것입니다. API 전체가 사용 불가하게 됩니다.

서비스를 디자인할 때 부분적 장애가 애플리케이션 전반으로 전파되지 않게 디자인하는 것이 중요합니다. 2가지 해결 방법이 존재합니다.

  • OrderServiceProxy 같은 RPI 프록시를 사용해서 디자인해야 원격 서비스의 장애를 대처할 수 있습니다.
  • 원격 서비스의 장애를 어떻게 복구할 것인지를 결정해야 합니다.

robust RPI proxies

한 서비스가 다른 서비스를 동기적으로 호출할 때, 넷플릭스가 묘사한 접근 방법을 사용해서 보호해야 합니다. 이런 접근 방법은 다음과 같은 메커니즘의 조합으로 구성됩니다.

  • network timeouts : 응답을 기다릴 때 무기한으로 대기하지 않으며, 항상 타임아웃을 설정합니다. 타임아웃을 사용하면 리소스가 무기한으로 묶이는 것을 방지할 수 있습니다.
  • limiting the number of outstanding requests from a client to a service : 클라이언트가 특정 서비스에 보낼 수 있는 미해결 요청의 수에 상한을 둡니다. 이 상한에 도달하면 추가 요청을 하는 것은 의미가 없으며, 이러한 시도는 즉시 실패해야 합니다.
  • circuit breaker pattern : 성공 및 실패한 요청의 수를 추적하며, 오류율이 일정 임계치를 초과하면 서킷 브레이커를 작동시켜 추가 시도는 즉시 실패하게 됩니다. 많은 요청이 실패하면 서비스가 이용 불가능함을 나타내며, 더 많은 요청을 보내는 것은 무의미합니다. 일정 타임아웃 이후 클라이언트는 다시 시도해야 하며, 요청이 성공하면 서킷 브레이커를 닫습니다.

Netflix Hystrix는 오픈소스 라이브러리로 서킷 브레이커 패턴을 구현했습니다. 만약 JVM을 사용한다면, RPI 프록시를 구현할 때 먼저 고려해봄직한 라이브러리입니다.

현재 스프링 클라우드는 Hystrix 대신 Resilience4j를 지원합니다.

recovering from an unavailable service

Hystrix 같은 라이브러리를 사용하는 것은 해결 방안의 일부입니다. 서비스가 응답 불가한 원격 서비스로 부터 어떻게 복구할 것인지를 결정해야합니다. 한가지 옵션은 서비스가 간단하게 에러를 클라이언트로 리턴하는 것입니다. 위 주문 서비스 시나리오에서 API 게이트웨이는 주문이 실패했을 때, 모바일 클라이언트에 리턴할 수 있습니다.

다른 시나리오에서 fallback 값을 리턴할 수도 있습니다. fallback 값으로는 기본 값이나, 캐시된 응답이 될 수 있습니다. GET /orders/{orderId} 가 다음처럼 여러 서비스를 호출하고, 결과를 합쳐서 리턴할 수 있습니다. Screenshot 2024-10-10 at 11 47 16

각 서비스의 데이터들은 클라이언트에게 모두 동등하게 중요하지는 않습니다. 주문 서비스의 데이터는 중요합니다. 만약 이 서비스가 응답하지 않는다면, API 게이트웨이는 데이터의 캐시된 버전을 리턴하거나, 에러를 리턴해야합니다. 다른 서비스의 데이터는 비교적 덜 중요합니다. 클라이언트는 배송 상태에 대한 정보가 없더라도 중요한 정보를 디스플레이할 수 있습니다. 만약 DeliveryService가 장애가 났다면, API 게이트웨이는 해당 데이터의 캐시된 버전을 반환하거나 응답에서 이를 생략해야 합니다.

서비스를 설계할 때 부분 실패를 처리하는 것이 필수적이지만, RPI 사용 시 해결해야 할 유일한 문제는 아닙니다. 또 다른 문제는 한 서비스가 RPI를 통해 다른 서비스를 호출하려면, 해당 서비스 인스턴스의 네트워크 위치를 알아야 한다는 점입니다. 이를 해결하려면 서비스 디스커버리 메커니즘을 사용해야 합니다.

service discovery

REST API를 제공하는 서비스를 호출하는 코드를 작성한다고 가정해봅시다. 요청을 수행하려면 코드가 서비스 인스턴스의 네트워크 위치(IP 주소와 포트)를 알아야 합니다. 전통적인 물리적 하드웨어에서 실행되는 애플리케이션에서는 서비스 인스턴스의 네트워크 위치가 보통 고정되어 있습니다. 하지만 현대적인 클라우드 기반 마이크로서비스 애플리케이션에서는 이 과정이 보통 그렇게 간단하지는 않습니다.

서비스 인스턴스는 동적으로 할당된 네트워크 위치를 가지고 있습니다. 또한, 오토 스케일링, 장애, 업그레이드 등으로 인해 서비스 인스턴스의 집합이 동적으로 변경됩니다. 따라서 클라이언트 코드는 서비스 디스커버리를 사용해야 합니다.

Screenshot 2024-10-10 at 12 12 32

클라이언트를 서비스의 IP 주소로 정적으로 구성할 수는 없습니다. 대신 애플리케이션은 동적 서비스 디스커버리 메커니즘을 사용해야 합니다. 서비스 디스커버리는 개념적으로 꽤 간단하며, 그 핵심 구성 요소는 서비스 레지스트리입니다. 서비스 레지스트리는 애플리케이션의 서비스 인스턴스의 네트워크를 담고 있는 데이터베이스입니다. 서비스 디스커버리 메커니즘은 서비스 인스턴스가 시작되고 종료될 때 서비스 레지스트리를 업데이트합니다. 클라이언트가 서비스를 호출할 때, 서비스 디스커버리 메커니즘은 서비스 레지스트리에 쿼리를 수행하여 사용 가능한 서비스 인스턴스의 목록을 얻고 요청을 그 중 하나로 라우팅합니다.

서비스 디스커버리를 구현하는 주요 방법은 2가지입니다.

  • 서비스와 클라이언트가 서비스 레지스트리와 직접 상호작용합니다.
  • 배포 인프라가 서비스 디스커버리를 처리합니다.

applying the application-level service discovery patterns

서비스 디스커버리를 적용하는 한가지 방법은 서비스 레지스트리를 사용해서 애플리케이션의 서비스와 클라이언트가 상호작용하는 것입니다. Screenshot 2024-10-10 at 15 35 31 서비스 인스턴스는 서비스 레지스트리에 자신의 네트워크 주소를 등록합니다. 서비스 클라이언트는 서비스를 호출하기 전에 먼저 서비스 레지스트리에 쿼리를 보내 서비스 인스턴스의 리스트를 받습니다. 그 다음 인스턴스 중 하나에게 리퀘스트를 보냅니다.

이런 방식의 서비스 디스커버리는 두 가지 패턴의 조합으로 구성됩니다. 첫번째 패턴은 Self registration pattern입니다. 서비스 인스턴스는 서비스 레지스트리의 API를 호출해서 자신의 네트워크 주소를 등록합니다. 두번째 패턴은 Client-side discovery pattern입니다. 서비스 클라이언트가 서비스를 호출하고 싶을 때, 서비스 레지스트리에 쿼리를 보내 서비스 인스턴스의 리스트를 반환받습니다. 성능을 향상시키기 위해, 클라이언트는 서비스 인스턴스를 캐시할 수 있습니다. 서비스 클라이언트는 라운드 로빈이나 랜덤같은 라운드 밸런싱 알고리즘을 사용해서 서비스 인스턴스를 선정하고 해당 인스턴스로 요청을 보냅니다.

애플리케이션 래벨 서비스 디스커버리는 넷플릭스와 pivotal로 인해 인기를 얻었습니다. 넷슬릭스는 Eureka(고가용성 서비스 레지스트리), Eureka Java 클라이언트, Ribbon (Eureka 클라이언트를 지원하는 HTTP 클라이언트, spring cloud에서는 load balancer로 사용해야합니다.) 같은 오픈소스 컴포넌트를 개발했습니다. Pivotal은 넷플릭스 컴포넌트를 매우 편하게 사용 가능한 스프링 기반 Spring Cloud를 개발했습니다. 스프링 클라우드 기반 서비스들은 Eureka 를 이용해 자동으로 등록할 수 있고, Spring Cloud 기반 클라이언트는 서비스 디스커버리에 Eureka를 사용합니다.

애플리케이션 레벨 서비스 디스커버리의 장점은, 여러 배포 플랫폼에 서비스가 배포되는 경우를 처리할 수 있다는 점입니다. 예를 들어, 서비스의 일부를 쿠버네티스에 배포할 수 있습니다. Eureka와 같은 애플리케이션 레벨 서비스 디스커버리는 쿠버네티스 환경, 레거시 환경과 모두 상호작용 할 수 있지만, 쿠버네티스 기반 서비스 디스커버리는 쿠버네티스 환경 내에서만 동작합니다.

애플리케이션 레벨 서비스 디스커버리의 단점은, 사용하려는 프레임워크, 언어마다 서비스 디스커버리 라이브러리가 필요하다는 점입니다. Spring Cloud는 Spring 개발자들에게만 도움을 줍니다. NodeJS나 GoLang 개발자들은 다른 서비스 디스커버리 프레임워크를 찾아야합니다. 또 다른 단점은 서비스 레지스트리를 세팅하고 관리하는 책임을 개발자가 가진다는 것입니다. 그렇기에 보통 배포 인프라가 제공하는 서비스 디스커버리를 주로 사용합니다.

applying the platform-provided service discovery patterns

추후에 도커나 쿠버네티스 같은 현대 배포 플랫폼에 대해서 다룰 것입니다. 배포 플랫폼은 각 서비스에 도메인 명, 가상 아이피 (VIP)를 부여합니다. 서비스 클라이언트는 도메인 명 혹은 가상 아이피로 요청을 보내고, 배포 플랫폼은 자동으로 해당 요청을 가용한 서비스 인스턴스 중 하나로 보냅니다. 결과적으로 서비스 등록, 서비스 디스커버리, 리퀘스트 라우팅이 모두 배포 플랫폼을 통해 처리됩니다.

Screenshot 2024-10-10 at 16 10 01

이런 접근 방식은 다음 두 패턴의 조합입니다.

  • 3rd party registration pattern : 서비스가 스스로 서비스 레지스트리에 등록하는 대신, 보통 배포 플랫폼의 일부인 registrar라는 제 3자가 등록을 처리합니다.
  • server-side discovery pattern : 클라이언트가 직접 서비스 레지스트리에 쿼리를 하는 대신, 클라이언트는 DNS 이름으로 요청을 보냅니다. 이 DNS 이름은 요청을 라우팅하는 요청 라우터로 해석되며, 라우터는 서비스 레지스트리에 쿼리를 실행하고 요청을 로드 밸런싱합니다.

플랫폼이 서비스 디스커버리를 제공하는 핵심 이점은 서비스 디스커버리의 모든 측면이 배포 플랫폼에서 처리된다는 점입니다. 서비스나 클라이언트는 어떤 서비스 디스커버리 코드를 작성하지 않아도됩니다. 결과적으로 서비스 디스커버리 메커니즘은 언어나 프레임워크에 관계없이 모든 서비스와 클라이언트에게 제공됩니다.

단점은 해당 플랫폼을 사용하는 서비스들에만 적용된다는 점입니다. 하지만, 모든 서비스를 해당 플랫폼 위에서 사용하면 해결되므로, 애플리케이션 레벨 디스커버리 보다 플랫폼 레벨 디스커버리가 권장됩니다.

This post is licensed under CC BY 4.0 by the author.