🖥️ CS/Architecture

[마이크로서비스 아키텍처 구축] 5. MSA 마이크로서비스 통신 구현 (1)

한국의 메타몽 2024. 10. 26. 21:49

book

해당 글은 마이크로서비스 아키텍처 구축에서 학습한 내용을 다룹니다.



목차

  1. MSA 서비스간 통신 종류
    1. 원격 프로시저 호출
    2. REST
    3. GraphQL
    4. 메시지 브로커
  2. 중단 변경 관리하기
    1. 락스텝 배포 (lockstep deployment)
    2. 호환도지지 않는 마이크로서비스 버전의 공존
    3. 기존 인터페이스 에뮬레이션
    4. 무엇이 최선일까?
    5. 극단적 조치


1-1. MSA 서비스간 통신 종류 - 원격 프로시저 호출 (Remote Procedure Call)


로컬 호출을 통해 어딘가에 있는 원격 서비스를 실행(RPC)하는 기술을 말한다.
일반적으로 RPC 기술은 직렬화 프로토콜에 투자한다는 의미이며, RPC 프레임워크는 데이터가 직렬화 되거나 역직렬화 되는 방법을 정의한다.
대표적으로 gRPC가 있으므로 이를 중점으로 설명한다.
gRPC는 프로토콜 버퍼 (아래 코드 참고) 직렬화 형식을 사용한다.


gRPC 코드 예시

// gRPC 프로토콜 버퍼 정의
syntax = "proto3";

package hello;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}
// 서버 코드
public class HelloServer {
    public static void main(String[] args) throws Exception {
        Server server = ServerBuilder.forPort(8080)
            .addService(new GreeterImpl())
            .build()
            .start();

        System.out.println("Server started...");
        server.awaitTermination();
    }

    static class GreeterImpl extends GreeterGrpc.GreeterImplBase {
        @Override
        public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
            String greeting = "Hello, " + request.getName();
            HelloReply reply = HelloReply.newBuilder().setMessage(greeting).build();
            responseObserver.onNext(reply);
            responseObserver.onCompleted();
        }
    }
}

// 클라이언트 코드

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

public class HelloClient {
    public static void main(String[] args) {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080)
            .usePlaintext()
            .build();

        GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);

        HelloRequest request = HelloRequest.newBuilder().setName("World").build();
        HelloReply response = stub.sayHello(request);

        System.out.println(response.getMessage());
        channel.shutdown();
    }
}

장점으로 클라이언트 코드를 생성하기 매우 쉽다.
.proto 파일을 gRPC 플러그인과 함께 사용하면 서버와 클라이언트에서 사용할 코드를 자동으로 생성할 수 있는데, Java Python Go C++ Node.js 등 다양한 언어로 해당 코드를 생성할 수 있다.
또한 이렇게 구현된 코드는 개발자가 직접 소켓 연결을 처리하거나 직렬 / 역직렬화를 구현할 필요 없이 gRPC가 알아서 자동으로 처리해준다.


단점으로는 HTTP/2가 지원되지 않으면 네트워크 환경에서 이슈가 발생할 수 있다. (gRPC는 비교적 최근에 등장했다.)
또한 gRPC는 브라우저를 기본적으로 지원하지 않는다. (Default 환경은 모바일로 이해하면 쉽다.)
gRPC-Web이라는 브라우저용 확장을 제공하고 있지만, 일반적인 gRPC랑은 차이가 있다.


또한 proto 파일을 기반으로 서비스 정의가 강하게 연결되어 있어서, 클라이언트 <-> 서버간의 의존도가 높다는 점도 단점이 될 수 있다.



1-2. MSA 서비스간 통신 종류 - REST


웹에서 영감을 받은 아키텍처 형식이다.
보통 HTTP 프로토콜로 JSON 표현을 요청, 처리한다.


이미 오래전에 정리한 내용이 있으므로, 해당 글을 확인하자.



1-3. MSA 서비스간 통신 종류 - GraphQL


최근 몇 년 GraphQL은 특정 분야에서 많은 인기를 얻고 있다.
대표적으로, 클라이언트 장치가 동일한 정보를 검색하기 위해 여러 번 요청할 필요가 없도록 쿼리를 정의할 수 있으며, 이로써 제한된 클라이언트 측 장치의 성능 면에서 상당한 향상을 이뤄내 맞춤형 서버 측 집계를 구현하지 않아도 될 수 있다.


간단한 예로, 고객의 최근 주문 정보의 개요를 표시하는 모바일 장치가 있다.
이 페이지에는 최근 주문 5건에 대한 일부 정보와 함께 고객의 일부 정보가 포함되어야 한다.


이를 조회하기 위해서는 최소 2개 이상의 MSA 다운스트림 서비스에 대한 호출을 실행할 수 있지만, 실제로는 불필요한 정보를 가져오는 등 여러 번의 호출을 수행해야 한다. (다시 말하지만 클라이언트에서 필요한 건 일부 정보들 뿐이다.)


모바일 기계의 경우, 이러한 방식은 모바일의 데이터 요금제를 필요 이상으로 많이 요구할 수 있으며, 시간도 웹 보다 더 오래 걸릴 수 있다.
GraphQL을 사용하면 모바일 장치에서 필요한 정보를 모두 가져오는 단일 쿼리를 실행할 수 있다.
이를 위해서는 GraphQL 엔드포인트를 클라이언트 장치에 노출하는 MSA 서비스가 필요하다.


단점으로는 클라이언트 장치는 동적으로 변경되는 쿼리를 실행할 수 있다는 것이다.
이 기능은 결국 양날의 검이 되는데, 결과적으로 서버 측에 상당한 부하를 줄 수 있는 쿼리 문제가 있다는 뜻이다.


SQL에서는 쿼리를 진단하는데 도움이 되는 DB용 쿼리 플래너가 있지만 (ex : EXPLAIN, EXPLAIN ANALYZE)
동적으로 변경되는 쿼리에서는 쿼리 진단의 적용이 쉽지 않다.
서버 측에서 요청을 조절하는 것(= throtthlling)이 잠재적인 솔루션 중 하니지만, 호출이 실행되면 여러 MSA 서비스로 분산될 수 있으므로 간단하지 않다.


이에 따라 캐싱이 복잡하다는 것도 이슈가 된다.
REST 기반 API에서는 클라이언트 장치나 CDN과 같은 중간 캐시가 응답을 캐싱하거나 다시 요청할 필요가 없도록 할 수 있다.
이 문제를 해결하는 방안으로 모든 리소스에 대해 ID로 연관관계를 만든 후 클라이언트 장치가 해당 ID에 대한 요청을 캐싱하는 것이지만, 구현 방법이 어렵다. 대안책으로 아폴로같은 구현체가 있지만, 완벽한 대안책은 아니다.


또한 GraphQL은 이론적으로 데이터를 쓰는 로직은 가능하지만 읽는 로직만큼 적합하지는 않다.
이러한 제약으로 인해 읽기에는 GraphQL을, 쓰기에는 REST를 사용하는 상황이 발생하기도 한다.


이런 저런 제약점들로 인해, GraphQL은 모바일 장치에 적합하며, 일반적인 MSA 서비스간의 통신보다는 MSA 서비스 하나가 하위 MSA 서비스에 대한 호출을 집계하는데 특화되어있다.


만약 GraphQL을 사용해야하지만 모바일이 아닌 웹을 고려한다면 대안책으로 BFF(Backend For Frontend)를 고민할 수 있다.



1-4. MSA 서비스간 통신 종류 - 메시지 브로커


미들웨어라고 하는 중개자로서 프로세스간의 통신을 관리한다.
한 MSA 서비스가 직접 통신하지 않고, 메시지 전송 방법에 대한 정보와 함께 메시지를 메시지 브로커에 전달한다.


소통 방식으로는 토픽가 있다.


는 일반적으로 두 지점(point to point)간이다. 발신자는 큐에 메시지를 넣고 소비자는 해당 큐에서 읽는다.
토픽 기반 시스템을 사용하면 여러 소비자가 토픽을 구독할 수 있으며, 구독한 각 소비자는 해당 메시지의 복사본을 받는다.


언뜻 보기에 큐는 단일 소비자 그룹이 있는 토픽과 비슷하지만, 큐는 메시지에 전송되는 대상에 대한 정보가 있는 반면,
토픽에서는 이 정보가 발실자에게 숨겨지므로 메시지를 누가 받을지 발신자는 알지 못한다.


메시지 브로커의 장점으로는 비동기 통신에 특화되어있다는 것이다.
또한 메시지를 보관하는 기능도 있기 때문에, 다운스트림에서 당장 수신을 못했다 하더라도 문제가 되지 않는다.
동일한 상황에서 HTTP 프로토콜을 사용했다면 데이터가 다운스트림에 도달하지 못했을 때, '호출을 재시도 할까? 얼마나 재시도를 해야할까? 아니면 포기해야할까?'와 같은 실패 정책을 별도로 고민했어야 할 것이다.


전달 보장을 하기 위해 브로커는 아직 전달되지 않은 메시지가 배달될 떄까지 지속적인 방법으로 메시지를 유지해야한다.
이를 보장하기 위한 방법으로 클러스터 기반 시스템으로 실행돼, 한 머신이 고장나더라도 메시지가 손실되지 않을 수 있다.


또한 정확히 한 번 메시지를 전달해야 하는 것도 보장해야하는데, 간단한 예시로는 메시지에 고유 ID를 포함시켜 수신자 쪽에서 해당 메시지를 이미 받은적이 있는지 확인하는 것이다.



카프카

대규모를 위해 설계된 대표적은 메세지 브로커이다.
메시지 영속성이라는 특징이 있는데, 카프카는 메시지 저장 기간을 설정할 수 있다.



2. 중단 변경 관리하기


MSA 서비스 인터페이스에 대한 변경 사항이 하위 호환성을 보장하도록 노력했지만, 중단 변경이 필요한 상황이 발생할 수 있다.
(중단변경 : 기존에 사용 중이던 서비스나 API를 업데이트하거나 수정할 때 다른 서비스들과의 호환성을 깨뜨리는 변경 사항)


이런 상황이 발생했을 때 크게 아래와 같은 3가지 선택지가 있다.


  1. 락스텝 배포 (lockstep deployment)
    • 인터페이스를 노출하는 MSA 서비스와 이 인터페이스의 모든 소비자를 동시에 변경하는 것
  2. 호횐되지 않는 마이크로서비스 버전의 공존
    • MSA의 이전 버전과 새 버전을 나란히 실행
  3. 기존 인터페이스 에뮬레이션
    • MSA가 새 인터페이스를 노출하고 기존 인터페이스도 에뮬레이트 하도록 함


(1) 락스텝 배포


락스텝 배포의 경우 독립적인 배포가 가능하다는 MSA의 장점과는 대치된다.


(2) 호환되지 않는 마이크로서비스 버전의 공존


호환되지 않는 마이크로서비스 버전의 공존은 넷플릭스에서 드물게 사용하는 방식인데, 구 버전과 신 버전을 나란히 공존시키고 신규 버전 서비스를 사용중인 유저는 신규 버전의 피쳐를, 구 버전 서비스를 사용중인 유저는 구 버전의 피쳐를 상요하도록 라우팅하는 것이다.

레거시 장치가 여전히 이전 버전의 API와 묶인 경우 오래된 소비자들을 변경하는 비용이 너무 높은 상황에서 생각할 수 있는 방법이지만, 이는 최선의 방법은 아니다.


먼저, 내부 버그를 수정해야하는 경우 이젠 서로 다른 2개의 서비스를 세트로 수정하고 배포해야한다.
둘째로, 소비자를 올바른 MSA 서비스로 라우팅하는데 지능이 필요하다. 이 행위는 틀림없이 미들웨어 어딘가, 또는 Nginx 스크립트가 상주하게 돼 시스템의 동작을 추론하기 어렵게 만든다.
마지막으로, 저장되는 데이터의 영속성을 보장하려면 개발 로직이 복잡해질 수 밖에 없다.


때문에 구 버전과 신 버전이 공존하는 방법은, 카나리아 배포와 같은 작업을 수행할때가 가장 적합하다.
(무중단 배포 - 카나리아 릴리즈와 관련된 내용은 해당 글을 참고하자.)

카나리아 배포 방식은 '단계적으로, 점진적으로 서비스를 제공'하는 방식에 초점을 맞췄기 때문에, 구 버전과 신 버전이 공존해야하는 상황에서 적합한 방식이다.


(3) 기존 인터페이스 에뮬레이션


동일 MSA 서비스에서 신 / 구 인터페이스를 함께 재공한다.
이를 통해 새로운 인터페이스와 함께 새로운 MSA 서비스를 가능한 빨리 출시할 수 있으며, 동시에 소비자가 옮겨갈 시간을 확보할 수 있다.
모든 소바자가 더 이상 구 엔드포인트를 사용하지 않으면 구 인터페이스를 제거한다.


단, 삭제되어야 할 인터페이스에 대해서는 명확한 네이밍이 필요하다. (저자는 폐기 예정인 인터페이스는 v1과 같은 접두사를 붙여 구분했다.)
또한 명확한 라우팅을 위해 API 경로에도 신 / 구 인터페이스의 구분이 필요된다.


(4) 무엇이 최선일까?


같은 팀에서 MSA 생산 서비스와 소비 서비스를 모두 관리하는 상황이라면 락스텝 릴리스를 사용하는 것이 다소 안심이 된다.
하지만 락스텝 릴리스를 자주 사용하면 이는 MSA 라기보다는 분산형 모놀리스에 더 가깝다는 것을 주의하자.


서로 다른 MSA 서비스의 버전을 공존시키는 것은 짧은 시간 동안 MSA 서비스 버전을 나란히 실행할 계획인 상황에서 적절하다.
이는 블루 - 그린 배포, 또는 카나리아 릴리스 처럼 MSA 서비스 버전이 공존할 수 있는 상황에서 권장되는 방식이다.


필자는 가능하면 세 번째 방법, 즉, 기존 인터페이스를 에뮬레이션 하는 방식을 권장한다.
이슈가 있다고 가정할때, (2)번과 같은 공존하는 MSA 서비스의 버전 문제보다는 (3)번의 기존 인터페이스 에뮬레이션이 이슈를 훨씬 쉽게 처리할 수 있기 때문이다.


(5) 극단적 조치


구 버전의 기능을 제거하는 것은 쉬운 일이 아니다.
필자의 경우, 구 버전의 라이브러리를 제거하기 위해 sleep을 삽입해 호출에 더 느리게 응답하는 기능을 포함시킨 적도 있다. (발생 상황에 대한 로깅도 물론 포함했다.)


이렇게까지 해서 해당 구 버전의 라이브러리의 점유율을 최소화 한 다음에야 구 버전의 라이브러리를 안전하게 deprecated 할 수 있었다.