[카테고리:] gRPC

  • gRPC 인터셉터 구현 – 클라이언트 (Java)

    지난 글에서 이어서 이번 글에서 클라이언트에서의 인터셉터 구현을 살펴보겠습니다.

    gRPC 인터셉터 – 클라이언트

    클라이언트에서의 gRPC 인터셉터도 이전 글에서 살펴본 서버와 동작 원리 및 구현 방법이 비슷합니다. 마찬가지로 라이브러리의 구조를 살펴보면서 gRPC 인터셉터를 구현해보도록 하겠습니다.

    (1) ClientCall.Listener<RespT> 정적 추상 클래스를 상속받는 클래스를 생성합니다.

    ClientCall.Listener<RespT>는 ClientCall 추상 클래스 안에 선언된 정적 추상 이너 클래스로, ServerCall.Listener와 유사합니다. 라이브러리에 선언된 ClientCall.Listener<RespT> 추상 클래스는 다음과 같으며, 모두 4개의 메서드가 정의되어 있습니다. onHeaders 메서드는 모든 RPC에 대해 서버로부터 응답 메시지가 처음 수신할 때 한 번만 호출되며, onMessage 메서드는 Stream RPC인 경우에도 서버로부터 응답 메시지를 수신받을 때마다 호출됩니다.

    public abstract class ClientCall<ReqT, RespT> {
    
      public abstract static class Listener<T> {
    
        public void onHeaders(Metadata headers) {}
        public void onMessage(T message) {}
        public void onClose(Status status, Metadata trailers) {}
        public void onReady() {}
      }
      ...
    }

    ClicentCall.Listener<RespT>는 이전에 살펴본 ServerCall.Listener<ReqT>와 같이 추상 클래스이므로 모든 메서드에 대한 오버라이딩이 필요합니다. 마찬가지로, 라이브러리에서는 delegate 패턴을 이용하여 다음과 같은 상속 관계를 가지는 클래스들을 제공합니다.

    • (abstract) ClientCall.Listener<RespT>
      • (abstract) PartialForwardingClientCallListener<RespT>
        • (abstract) ForwardingClientCallListener<RespT>
          • (abstract) ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>

    따라서 ClientCall.Listener<RespT> 추상 클래스 대신 ForwardingClientCallListener<RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingClientCallListener 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingClientCallListener을 이용하여 간단하게 구현합니다.

    이제 ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT> 추상 클래스를 상속받는 UserClientCallListener을 다음과 같이 구현합니다. onHeaders 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 서버로부터 응답 메시지가 처음 도달하면 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserClientCallListener.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ClientCall;
    import io.grpc.ForwardingClientCallListener;
    import io.grpc.Metadata;
    
    public class UserClientCallListener<RespT> extends ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT> {
        protected UserClientCallListener(ClientCall.Listener<RespT> delegate) {
            super(delegate);
        }
    
        @Override
        public void onHeaders(Metadata headers) {
            System.out.println("[Client Call Listener] client header : " + headers);
            super.onHeaders(headers);
        }
    }

    (2) ClientCall<ReqT, RespT> 추상 클래스를 상속받는 클래스를 생성합니다.

    ClientCall<ReqT, RespT> 추상 클래스에는 gRPC 클라이언트에서의 메시지 송수신을 처리하는 메서드들이 정의되어 있습니다. 과정 (1)에서 살펴본 ClientCall.Listener<RespT> 클래스도 ClientCall<ReqT, RespT> 추상 클래스의 이너 클래스입니다. ClientCall 클래스에 선언된 sendMessage 메서드를 이용하여 서버로 요청 메시지를 전송할 수 있으며, 이 메서드를 오버라이딩하여 요청 메시지를 서버로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다.

    package io.grpc;
    
    import javax.annotation.Nullable;
    
    public abstract class ClientCall<ReqT, RespT> {
    
      public abstract static class Listener<T> { ... }
    
      public abstract void start(Listener<RespT> responseListener, Metadata headers);
      public abstract void request(int numMessages);
      public abstract void cancel(@Nullable String message, @Nullable Throwable cause);
      public abstract void halfClose();
      public abstract void sendMessage(ReqT message);
    
      public boolean isReady() {
        return true;
      }
      ...
    }

    ClientCall<ReqT, RespT>는 Listener와 마찬가지로 추상 클래스이므로, 모든 메서드에 대한 오버라이딩이 필요합니다. 라이브러리에서는 delegate 패턴을 이용하여 다음과 같은 상속 관계를 가지는 클래스들을 제공합니다.

    • (abstract) ClientCall<ReqT, RespT>
      • (abstract) PartialForwardingClientCall<ReqT, RespT>
        • (abstract) ForwardingClientCall<ReqT, RespT>
          • (abstract) ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>

    따라서 ClientCall<ReqT, RespT> 추상 클래스 대신 ForwardingClientCall<ReqT, RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingClientCall 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingClientCall을 이용하여 간단하게 구현합니다.

    이제 ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT> 추상 클래스를 상속받는 UserClientCall을 다음과 같이 정의합니다. start와 sendMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 새로운 요청 메시지를 서버로 전달하기 전에 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserClientCall.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ClientCall;
    import io.grpc.ForwardingClientCall;
    import io.grpc.Metadata;
    
    public class UserClientCall<ReqT, RespT> extends ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT> {
        protected UserClientCall(ClientCall<ReqT, RespT> delegate) {
            super(delegate);
        }
    
        @Override
        public void start(Listener<RespT> responseListener, Metadata headers) {
            super.start(new UserClientCallListener<>(responseListener), headers);
        }
    
        @Override
        public void sendMessage(ReqT message) {
            System.out.println("[Client Call] Client Send Message : " + message);
            super.sendMessage(message);
        }
    }
    

    (3) ClientInterceptor 인터페이스의 구현 클래스를 생성합니다.

    마지막으로 ClientInterceptor 인터페이스를 살펴보겠습니다. ServerInterceptor 인터페이스와 마찬가지로 인터페이스의 구조는 단순합니다. 구현 클래스는 interceptCall이라는 하나의 메서드만 구현하면 됩니다. 세 개의 파라미터와 하나의 반환은 다음의 역할을 가집니다. 서버와는 그 구성이 다소 다릅니다.

    • MethodDescriptor<ReqT, RespT> method:
    • CallOptions callOptions:
    • Channel next:
    • ClientCall<ReqT, RespT> (return):

    package io.grpc;
    
    import javax.annotation.concurrent.ThreadSafe;
    
    @ThreadSafe
    public interface ClientInterceptor {
      <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
          MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next);
    }

    // UserClientInterceptor.java
    package com.lattechiffon.grpc;
    
    import io.grpc.*;
    
    public class UserClientInterceptor implements ClientInterceptor {
        @Override
        public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
            return new UserClientCall<>(next.newCall(method, callOptions));
        }
    }

    (4) gRPC 클라이언트 채널에 구현한 Interceptor 클래스를 인터셉터로 등록합니다.

    마지막으로 구현한 인터셉터가 정상적으로 동작하도록 다음과 같이 gRPC 클라이언트에 등록해줍니다. 이제 서버로부터 응답이 도달하면 인터셉터에 의해 앞서 구현한 일련의 과정들이 먼저 수행됩니다.

    // GrpcApplication.java
    ...
    public static void main(String[] args) {
        ...
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext()
                .intercept(new UserClientInterceptor()).build();
        ...
    }

    지금까지 서버와 클라이언트에서의 gRPC 인터셉터 동작 원리와 Java를 이용한 구현 과정에 대해 살펴보았습니다. 다음 글에서는 gRPC에서 제공하는 데드라인 기능에 대해 살펴보고 마찬가지로 Java로 구현하는 내용을 다루도록 하겠습니다.

    • (참고) JDK 9부터는 JAVA EE 모듈이 제거되었기 때문에 gRPC 컴파일 코드를 포함한 프로젝트 전체를 빌드하기 위해서는 다음과 같이 javax.annotation 의존성을 추가해야 합니다.
    // build.gradle
    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

  • gRPC 인터셉터 구현 – 서버 (Java)

    지금까지 gRPC의 기본적인 특징들을 살펴보고, 핵심적인 4가지의 통신 방법을 Protocol Buffer와 Java를 이용하여 간단한 예제로 구현해보았습니다. 분산된 프로세스 사이에서 간단한 통신을 하고자 한다면 이전에 다뤘던 구현 방법만으로도 충분하지만, MSA 기반 애플리케이션처럼 복잡한 형태의 구조를 가지는 경우에는 보다 세밀하고 유연하게 통신 과정을 제어할 수 있는 방법들이 요구됩니다. 이번 글과 다음 글에서는 gRPC에서 제공하는 확장 기능들 중에서 인터셉터(Interceptor)에 대해 살펴보고, 이를 Java로 구현하는 과정을 다룹니다.

    gRPC 인터셉터 – 서버

    프로젝트의 의존성으로 추가한 io.grpc 라이브러리의 구조를 분석하지 않으면 인터셉터의 동작 원리를 이해하기가 쉽지 않습니다. 라이브러리의 구조를 하나씩 살펴보면서 gRPC 인터셉터를 구현해보도록 하겠습니다.

    (1) ServerCall.Listener<ReqT> 정적 추상 클래스를 상속받는 클래스를 생성합니다.

    ServerCall.Listener<ReqT>는 다음 단계에서 이야기 할 ServerCall 추상 클래스 안에 선언된 정적 추상 이너 클래스입니다. Listener 클래스에 선언된 onMessage 메서드를 이용하여 클라이언트로부터 수신된 메시지를 전달받을 수 있으며, 이 메서드를 오버라이딩하여 메시지를 gRPC 서비스 로직으로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다. 라이브러리에 선언된 ServerCall.Listener<ReqT> 추상 클래스는 다음과 같으며, onMessage 메서드를 포함하여 5개의 메서드가 정의되어 있습니다. onMessage 메서드는 Stream RPC인 경우에도 클라이언트로부터 메시지를 수신받을 때마다 호출됩니다.

    public abstract class ServerCall<ReqT, RespT> {
    
      public abstract static class Listener<ReqT> {
    
        public void onMessage(ReqT message) {}
        public void onHalfClose() {}
        public void onCancel() {}
        public void onComplete() {}
        public void onReady() {}
      }
      ...
    }

    그러나 ServerCall.Listener<ReqT>는 추상 메서드만을 가진 추상 클래스입니다. 즉, 모든 메서드에 대한 오버라이딩이 있어야 동작을 보장할 수 있습니다. 라이브러리에서는 delegate 패턴을 이용하여 onMessage 메서드를 제외한 나머지 4개의 메서드를 구현하고 있는 PartialForwardingServerCallListener<ReqT>를 제공합니다. 그뿐만 아니라, 다시 PartialForwardingServerCallListener<ReqT> 추상 클래스를 상속받아 delegate 패턴을 이용하여 onMessage 메서드만을 정의하는 ForwardingServerCallListener<ReqT> 추상 클래스를 제공합니다.

    • (abstract) ServerCall.Listener<ReqT>
      • (abstract) PartialForwardingServerCallListener<ReqT>
        • (abstract) ForwardingServerCallListener<ReqT>
          • (abstract) ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>

    따라서 ServerCall.Listener<ReqT> 추상 클래스 대신 ForwardingServerCallListener<ReqT> 추상 클래스를 상속받아 구현하면 됩니다. ForwardingServerCallListener 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingServerCallListener는 onMessage 메서드를 오버라이딩하고 있지 않으므로 여기서는 ForwardingServerCallListener를 상속받아 구현합니다.

    이제 ForwardingServerCallListener<ReqT> 추상 클래스를 상속받는 UserServerCallListener를 다음과 같이 정의합니다. onMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 클라이언트로부터 메시지가 전달될 때마다 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserServerCallListener.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ForwardingServerCallListener;
    import io.grpc.ServerCall;
    
    public class UserServerCallListener<ReqT> extends ForwardingServerCallListener<ReqT> {
    
        private final ServerCall.Listener<ReqT> delegate;
    
        UserServerCallListener(ServerCall.Listener<ReqT> delegate) {
            this.delegate = delegate;
        }
    
        @Override
        protected ServerCall.Listener<ReqT> delegate() {
            return delegate;
        }
    
        @Override
        public void onMessage(ReqT message) {
            System.out.println("[Server Call Listener] Client Message : " + message);
            super.onMessage(message);
        }
    }

    (2) ServerCall<ReqT, RespT> 추상 클래스를 상속받는 클래스를 생성합니다.

    ServerCall<ReqT, RespT> 추상 클래스에는 gRPC 서버사이드에서의 메시지 송수신을 처리하는 메서드들이 정의되어 있습니다. 과정 (1)에서 살펴본 ServerCall.Listener<ReqT> 클래스도 ServerCall<ReqT, RespT> 추상 클래스의 이너 클래스로 선언되어 있습니다. ServerCall 클래스에 선언된 sendMessage 메서드를 이용하여 서비스 로직으로부터 생성된 응답 메시지를 클라이언트에 전송할 수 있으며, 이 메서드를 오버라이딩하여 서비스 로직의 응답 메시지를 클라이언트로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다.

    package io.grpc;
    
    import javax.annotation.Nullable;
    
    public abstract class ServerCall<ReqT, RespT> {
    
      public abstract static class Listener<ReqT> { ... }
    
      public abstract void request(int numMessages);
      public abstract void sendHeaders(Metadata headers);
      public abstract void sendMessage(RespT message);
      public boolean isReady() { return true; }
      public abstract void close(Status status, Metadata trailers);
      public abstract boolean isCancelled();
      ...
      public abstract MethodDescriptor<ReqT, RespT> getMethodDescriptor();
    }
    

    그러나 ServerCall<ReqT, RespT>는 Listener와 마찬가지로 추상 메서드만을 가진 추상 클래스입니다. 즉, 모든 메서드에 대한 오버라이딩이 있어야 동작을 보장할 수 있습니다. 라이브러리에서는 delegate 패턴을 이용하여 sendMessage 메서드를 제외한 나머지 메서드를 구현하고 있는 PartialForwardingServerCall<ReqT, RespT>를 제공합니다. 마찬가지로, 다시 PartialForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받아 delegate 패턴을 이용하여 sendMessage 메서드만을 정의하는 ForwardingServerCall<ReqT, RespT> 추상 클래스를 제공합니다.

    • (abstract) ServerCall<ReqT, RespT>
      • (abstract) PartialForwardingServerCall<ReqT, RespT>
        • (abstract) ForwardingServerCall<ReqT, RespT>
          • (abstract) ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>

    따라서 ServerCall<ReqT, RespT> 추상 클래스 대신 ForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingServerCall 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingServerCall을 이용하여 간단하게 구현합니다.

    이제 ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받는 UserServerCall을 다음과 같이 정의합니다. sendMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 서비스 로직으로부터 새로 생성된 응답 메시지가 전달될 때마다 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserServerCall.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ForwardingServerCall;
    import io.grpc.MethodDescriptor;
    import io.grpc.ServerCall;
    
    public class UserServerCall<ReqT, RespT> extends ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT> {
    
        protected UserServerCall(ServerCall<ReqT, RespT> delegate) {
            super(delegate);
        }
    
        @Override
        protected ServerCall<ReqT, RespT> delegate() {
            return super.delegate();
        }
    
        @Override
        public MethodDescriptor<ReqT, RespT> getMethodDescriptor() {
            return super.getMethodDescriptor();
        }
    
        @Override
        public void sendMessage(RespT message) {
            System.out.println("[Server Call] Service Return Message : " + message);
            super.sendMessage(message);
        }
    }

    (3) ServerInterceptor 인터페이스의 구현 클래스를 생성합니다.

    마지막으로 ServerInterceptor 인터페이스를 살펴보겠습니다. 다음과 같이 인터페이스의 구조는 굉장히 간단합니다. 구현 클래스는 interceptCall이라는 하나의 메서드만 구현하면 됩니다. 세 개의 파라미터와 하나의 반환은 다음의 역할을 가집니다.

    • ServerCall<ReqT, RespT> call: 서버가 반환한 응답(RespT) 메시지를 받을 객체입니다.
    • Metadata headers: 통신 메타데이터를 가지는 객체입니다. 새로운 사용자 정의를 추가할 수 있습니다.
    • ServerCallHandler<ReqT, RespT> next: 인터셉터 체인의 다음 처리를 수행할 객체입니다.
    • ServerCall.Listener<ReqT> (return): 클라이언트로부터 전송된 수신(ReqT) 메시지를 처리할 객체입니다.

    즉, incerceptCall 메서드를 오버라이딩함으로써 클라이언트로부터 전송된 수신 메시지를 처리할 리스너를 직접 정의할 수 있으며, 이를 인터셉터 체인에 등록할 수 있습니다. 구현한 인터셉터를 gRPC 서버의 인터셉터 체인에 등록하는 과정은 마지막 부분에서 다시 다룹니다.

    package io.grpc;
    
    import javax.annotation.concurrent.ThreadSafe;
    
    @ThreadSafe
    public interface ServerInterceptor {
      <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
          ServerCall<ReqT, RespT> call,
          Metadata headers,
          ServerCallHandler<ReqT, RespT> next);
    }

    이제 프로젝트에 ServerInterceptor 인터페이스를 구현할 UserServerInterceptor 클래스를 다음과 같이 정의합니다. 우선, 인터셉터가 동작하는지 확인하기 위해 표준 출력을 하도록 합니다. 그리고 과정 (2)에서 ServerCall를 커스텀한 UserServerCall 객체를 만듭니다. 마지막으로 과정 (1)에서 클라이언트의 메시지를 수신하도록 커스텀한 UserServerCallListener 객체를 생성하여 반환합니다. 즉, gRPC 인터셉터에 의한 메시지의 처리 과정을 정리하면 다음과 같습니다.

    1. 클라이언트로부터 요청 메시지가 전달되면 우선 UserServerCallListener에게 전달됩니다.
    2. UserServerCallListener는 메시지에 대한 전처리 작업을 수행하고, gRPC 서비스에게 이 메시지를 전달합니다.
    3. gRPC 서비스로부터 전달된 응답 메시지가 UserServerCall에게 전달됩니다.
    4. ServerCallHandler에 의해 클라이언트의 메시지 전송이 중단될 때까지 위의 과정을 반복합니다.
    package com.lattechiffon.grpc;
    
    import io.grpc.Metadata;
    import io.grpc.ServerCall;
    import io.grpc.ServerCallHandler;
    import io.grpc.ServerInterceptor;
    
    public class UserServerInterceptor implements ServerInterceptor {
        @Override
        public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
            System.out.println("[Server Interceptor] : Invoke RPC - " + call.getMethodDescriptor().getFullMethodName());
            ServerCall<ReqT, RespT> serverCall = new UserServerCall<>(call);
    
            return new UserServerCallListener<>(next.startCall(serverCall, headers));
        }
    }

    (4) gRPC 서버 빌더에 구현한 Interceptor 클래스를 인터셉터로 등록합니다.

    마지막으로 구현한 인터셉터가 정상적으로 동작하도록 다음과 같이 gRPC 서버에 등록해줍니다. 이제 UserServiceImpl 서비스가 수행되기 전에 인터셉터에 의해 앞서 구현한 일련의 과정들이 먼저 수행됩니다.

    // GrpcApplication.java
    ...
    public static void main(String[] args) {
            // Initialize gRPC Server
            int port = 8080;
            Server server = ServerBuilder.forPort(port)
                    .addService(ServerInterceptors.intercept(new UserServiceImpl(), new UserServerInterceptor())).build();
    
            try {
                server.start();
            } catch (IOException e) {
                e.printStackTrace();
                return;
            }
            ...
    }
    ...

    이제 서버 사이드에 대한 구현이 모두 끝났습니다. 다음 글에서 클라이언트에서의 인터셉터 구현을 살펴보겠습니다.

    이 예제의 전체 소스코드는 아래의 GitHub 저장소에서 확인할 수 있습니다.

    • (참고) JDK 9부터는 JAVA EE 모듈이 제거되었기 때문에 gRPC 컴파일 코드를 포함한 프로젝트 전체를 빌드하기 위해서는 다음과 같이 javax.annotation 의존성을 추가해야 합니다.
    // build.gradle
    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

  • gRPC 세 가지 Streaming RPC 구현 (Java)

    이전 글에서 gRPC를 이용하여 Unary RPC를 구현했던 것에 이어서, 이번에는 Server Streaming, Client Streaming 및 Bidirectional Streaming RPC를 Java로 구현해보도록 하겠습니다.

    Streaming 패턴 구현에 앞서 gRPC에서 제공하는 클라이언트 스텁 3가지에 대해서 먼저 살펴보겠습니다.

    gRPC 클라이언트 스텁: BlockingStub, AsyncStub, FutureStub

    gRPC의 클라이언트 스텁은 다음과 같이 3가지의 객체로 제공됩니다. 사용하는 스텁에 따라 클라이언트의 수신 처리 방식이 달라집니다.

    • BlockingStub: 동기적으로 통신하는 방법으로, 서버로부터 응답이 올 때까지 대기합니다. 이 스텁은 Unary RPC와 Server Streaming RPC에서만 사용할 수 있습니다.
    • AsyncStub (Stub): 비동기적으로 통신하는 방법으로, 서버로부터 오는 응답을 StreamObserver 객체가 대신 받아서 처리합니다. 이 스텁은 모든 방식의 RPC에서 사용할 수 있습니다.
    • FutureStub: 비동기적으로 통신하는 방법으로, 서버로부터의 응답 도달에 상관 없이 일단 ListenableFuture로 래핑된 객체를 반환합니다. 서버로부터 오는 응답이 오면 ListenableFuture 객체를 통해 전달받은 메시지를 언래핑할 수 있습니다. 이 스텁은 Unary RPC에서만 사용할 수 있습니다. (ListenableFuture와 Future 인터페이스에 대해서는 Guava 라이브러리를 참고하시기 바랍니다.)

    즉, 정리하면 gRPC는 4가지의 통신 방식을 지원하며, 클라이언트의 메시지 수신 처리 방식에 따라 총 7가지의 구현 방법이 존재한다고 할 수 있습니다.

    이전 글에서 구현한 Unary RPC는 BlockingStub을 사용했지만, AsyncStub이나 FutureStub을 이용하여 수신을 처리하도록 구성할 수도 있습니다. 여기서는 Server Streaming RPC를 BlockingStub으로 수신하고, Client Streaming과 Bidirectional Streaming RPC는 AsyncStub으로 수신하도록 클라이언트를 구현합니다.

    세 가지의 Streaming RPC 구현하기 (Java)

    우선 UserService.proto를 다음과 같이 수정합니다. 기존의 Unary RPC 메서드 아래에 각각 Client Streaming, Server Streaming, Bidirectional Streaming을 담당하는 3개의 메서드를 추가로 정의합니다. 서비스 메서드의 파라미터 혹은 반환 메시지에 stream을 추가하면 연속적으로 메시지를 전달받거나, 연속적으로 메시지를 전송할 수 있습니다. 또한, UserIdx 메서드의 idx 필드를 repeated로 변경합니다.

    • [Client Streaming RPC] setUsers: 클라이언트가 등록할 사용자 정보가 포함된 메시지(User)를 연속적으로 전달하면, 서버는 등록이 처리된 사용자 식별번호들이 포함된 메시지(UserIdx)를 한 번에 반환합니다.
    • [Server Streaming RPC] getUsers: 클라이언트가 조회할 사용자 식별번호들이 포함된 메시지(UserIdx)를 한 번에 전달하면, 서버는 해당 사용자 정보가 포함된 메시지(User)를 연속적으로 반환합니다.
    • [Bidirectional Streaming RPC] getUsersRealtime: 클라이언트가 조회할 사용자 식별번호들이 포함된 메시지(UserIdx)를 연속적으로 전달하면, 서버는 해당 사용자 정보가 포함된 메시지(User)를 연속적으로 반환합니다.
    // UserService.proto
    syntax = "proto3";
    package grpc;
    
    option java_multiple_files = true;
    option java_package = "com.lattechiffon.grpc";
    option java_outer_classname = "UserServiceOuterClass";
    
    service UserService {
      rpc setUser(User) returns (UserIdx);
      rpc getUser(UserIdx) returns (User);
      rpc setUsers(stream User) returns (UserIdx); // Client Streaming RPC
      rpc getUsers(UserIdx) returns (stream User); // Server Streaming RPC
      rpc getUsersRealtime(stream UserIdx) returns(stream User); // Bidirectional Streaming RPC
    }
    
    message User {
      int64 idx = 1;
      string username = 2;
      string email = 3;
      repeated string roles = 4;
    }
    
    message UserIdx {
      repeated int64 idx = 1;
    }

    Protocol Buffer 코드의 작성이 완료되면, Gradle 빌드 시스템을 이용하는 경우에는 ‘GenerateProto’를 수행하도록 합니다. Protocol Buffer Compiler를 이용한다면 Java 언어로 컴파일하여 소스코드를 생성합니다.

    스텁 코드들의 생성이 완료되면, 이제 UserServiceImpl.java를 수정할 순서입니다. 이 클래스는 서버에서 각 메서드마다 수행해야 할 비즈니스 로직을 가지고 있습니다. 다음과 같이 새로 추가한 3개의 메서드에 대해 구현을 추가합니다.

    // UserServiceImpl.java
    package com.lattechiffon.grpc;
    
    import io.grpc.Status;
    import io.grpc.StatusException;
    import io.grpc.stub.StreamObserver;
    
    import java.util.HashMap;
    import java.util.Map;
    
    public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
    
        private final Map<Long, User> userMap = new HashMap<>();
    
        private long idxCounter = 1;
    
        @Override
        public void setUser(User request, StreamObserver<UserIdx> responseObserver) {
            // Unary RPC 생략
        }
    
        @Override
        public void getUser(UserIdx request, StreamObserver<User> responseObserver) {
            // Unary RPC 생략
        }
    
        @Override
        public StreamObserver<User> setUsers(final StreamObserver<UserIdx> responseObserver) {
            return new StreamObserver<User>() {
    
                final UserIdx.Builder responseBuilder = UserIdx.newBuilder();
    
                @Override
                public void onNext(User user) {
                    user = user.toBuilder().setIdx(idxCounter++).build();
                    userMap.put(user.getIdx(), user);
                    responseBuilder.addIdx(user.getIdx());
                }
    
                @Override
                public void onError(Throwable t) {
                    responseObserver.onError(t);
                }
    
                @Override
                public void onCompleted() {
                    responseObserver.onNext(responseBuilder.build());
                    responseObserver.onCompleted();
                }
            };
        }
    
        @Override
        public void getUsers(UserIdx request, StreamObserver<User> responseObserver) {
    
            for (long idx : request.getIdxList()) {
                if (userMap.containsKey(idx)) {
                    responseObserver.onNext(userMap.get(idx));
                } else {
                    responseObserver.onError(new StatusException(Status.NOT_FOUND));
                }
            }
    
            responseObserver.onCompleted();
        }
    
        @Override
        public StreamObserver<UserIdx> getUsersRealtime(StreamObserver<User> responseObserver) {
            return new StreamObserver<UserIdx>() {
    
                @Override
                public void onNext(UserIdx userIdx) {
                    for (long idx : userIdx.getIdxList()) {
                        if (userMap.containsKey(idx)) {
                            responseObserver.onNext(userMap.get(idx));
                        } else {
                            responseObserver.onError(new StatusException(Status.NOT_FOUND));
                        }
                    }
                }
    
                @Override
                public void onError(Throwable t) {
                    responseObserver.onError(t);
                }
    
                @Override
                public void onCompleted() {
                    responseObserver.onCompleted();
                }
            };
        }
    }

    마지막으로 GrpcApplication.java의 메인 함수에 다음과 같이 클라이언트 요청을 구현합니다. Client Streaming RPC와 Bidirectional RPC는 Unary RPC, Server Streaming RPC에 비해 구현이 복잡합니다. 이는 AsyncStub을 이용한 메시지 수신 처리에 기인하며, 비동기 처리를 위해 서버로부터 응답을 받을 StreamObserver 객체를 생성하여 클라이언트 스텁 메서드의 파라미터로 전달합니다.

    // GrpcApplication.java (src/main/java/com.lattechiffon.com/)
    package com.lattechiffon.grpc;
    
    import io.grpc.*;
    import io.grpc.stub.StreamObserver;
    
    import java.io.IOException;
    import java.util.Iterator;
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.TimeUnit;
    
    public class GrpcApplication {
    
        public static void main(String[] args) {
            // Initialize gRPC Server
            ...
            
            // Initialize gRPC Client
            ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build();
            UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
            UserServiceGrpc.UserServiceStub asyncStub = UserServiceGrpc.newStub(channel);
            UserServiceGrpc.UserServiceFutureStub futureStub = UserServiceGrpc.newFutureStub(channel);
    
            // Client: Unary RPC, 3 times
            System.out.println("(1) Unary RPC");
            UserIdx setUserResult = stub.setUser(User.newBuilder().setUsername("GO YONGGUK")
                    .setEmail("lattechiffon@gmail.com").addRoles("USER").addRoles("ADMIN").build());
            System.out.println("Client: " + setUserResult.getIdx(0));
    
            setUserResult = stub.setUser(User.newBuilder().setUsername("KIM MINSU")
                    .setEmail("minsu@test.com").addRoles("USER").build());
            System.out.println("Client: " + setUserResult.getIdx(0));
    
            User getUserResult = stub.getUser(setUserResult);
            System.out.println(getUserResult.toString());
    
            // Client: Client-side Streaming RPC
            System.out.println("(2) Client-side Streaming RPC");
    
            final CountDownLatch finishLatch = new CountDownLatch(1);
            StreamObserver<UserIdx> responseObserver = new StreamObserver<UserIdx>() {
    
                @Override
                public void onNext(UserIdx userIdx) {
                    for (long idx : userIdx.getIdxList()) {
                        System.out.println("Client: " + idx);
                    }
                }
    
                @Override
                public void onError(Throwable t) {
                    finishLatch.countDown();
                }
    
                @Override
                public void onCompleted() {
                    finishLatch.countDown();
                }
            };
    
            StreamObserver<User> requestObserver = asyncStub.setUsers(responseObserver);
    
            try {
                for (int i = 0; i < 5; i++) {
                    requestObserver.onNext(User.newBuilder().setUsername("NEW USER - " + i)
                            .setEmail("test@test.com").addRoles("USER").build());
                    Thread.sleep(500);
                }
            } catch (StatusRuntimeException|InterruptedException ignored) { }
    
            requestObserver.onCompleted();
    
            try {
                finishLatch.await(1, TimeUnit.MINUTES);
            } catch (InterruptedException ignored) { }
    
            // Client: Server-side Streaming RPC
            System.out.println("(3) Server-side Streaming RPC");
    
            try {
                Iterator<User> getUsersResult = stub.getUsers(UserIdx.newBuilder().addIdx(1).addIdx(2).build());
    
                while (getUsersResult.hasNext()) {
                    System.out.println(getUsersResult.next().toString());
                }
            } catch (StatusRuntimeException ignored) { }
    
            // Client: Bidirectional Streaming RPC
            System.out.println("(4) Bidirectional Streaming RPC");
    
            final CountDownLatch finishLatch2 = new CountDownLatch(1);
            StreamObserver<User> responseObserver2 = new StreamObserver<User>() {
    
                @Override
                public void onNext(User user) {
                    System.out.println(user.toString());
                }
    
                @Override
                public void onError(Throwable t) {
                    finishLatch2.countDown();
                }
    
                @Override
                public void onCompleted() {
                    finishLatch2.countDown();
                }
            };
    
            StreamObserver<UserIdx> requestObserver2 = asyncStub.getUsersRealtime(responseObserver2);
    
            try {
                for (int i = 1; i <= 5; i++) {
                    requestObserver2.onNext(UserIdx.newBuilder().addIdx(i).build());
                    Thread.sleep(1000);
                }
    
                requestObserver2.onNext(UserIdx.newBuilder().addIdx(6).addIdx(7).build());
            } catch (StatusRuntimeException|InterruptedException ignored) { }
    
            requestObserver2.onCompleted();
    
            try {
                finishLatch2.await(1, TimeUnit.MINUTES);
            } catch (InterruptedException ignored) { }
    
            // Release
            channel.shutdown();
            Runtime.getRuntime().exit(0);
        }
    }

    이제 Gradle로 프로젝트 전체를 빌드하고 실행하면 정상적으로 gRPC가 동작하여 메시지가 출력되는 것을 확인할 수 있습니다.

    이 예제의 전체 소스코드는 아래의 GitHub 저장소에서 확인할 수 있습니다.

    • (참고) UserService.proto의 UserIdx 메시지를 수정했기 때문에 Unary RPC의 일부 코드를 수정해야 합니다. 글에서는 언급하지 않았으나 확인하려면 위의 GitHub 저장소를 참고하면 됩니다.
    • (참고) JDK 9부터는 JAVA EE 모듈이 제거되었기 때문에 gRPC 컴파일 코드를 포함한 프로젝트 전체를 빌드하기 위해서는 다음과 같이 javax.annotation 의존성을 추가해야 합니다.
    // build.gradle
    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

  • gRPC 프로젝트 구성 방법과 Unary RPC 구현 (Java)

    이전 글에서 gRPC에 대한 기본적인 특징과 통신 패턴들을 살펴보았습니다. gRPC는 Protocol Buffer라는 인터페이스 정의 언어(IDL)를 기반으로 서비스 정의를 작성하며, 다양한 프로그래밍 언어로 컴파일할 수 있으므로 특정 언어에 종속되지 않을 뿐만 아니라, HTTP/2 위에서 동작하기 때문에 빠른 속도의 양방향 메시지 전송을 지원합니다. 이러한 특성으로 인해 프로세스 및 컨테이너 사이의 빠른 데이터 전송이 요구되는 마이크로서비스 아키텍처(MSA; Microservice Architecture) 기반의 애플리케이션에 적극적으로 도입되고 있습니다. 여기서는 gRPC를 개발 프로젝트에 도입하기 위해 필요한 구성 방법을 살펴보고, 이를 이용하여 클라이언트가 단일 메시지를 요청하면 서버가 단일 메시지를 다시 반환하는 Unary RPC를 Java로 구현해보도록 하겠습니다.

    Protocol Buffer를 이용한 gRPC 서비스 정의

    개발할 애플리케이션에 gRPC를 활용하기 위해서는 먼저 Protocol Buffer를 이용하여 메시지 형식, 원격 호출할 메서드와 메서드의 파라미터 및 반환 등을 포함하는 인터페이스 정의가 필요합니다. 간단한 예제를 도입하기 위해 여기서는 새로운 사용자를 등록하고, 이미 등록된 사용자의 정보를 조회하는 애플리케이션을 만든다고 가정합니다. 먼저, 다음과 같이 사용자 정보를 담는 메시지와 사용자 식별번호만을 담는 메시지를 정의합니다.

    // UserService.proto
    syntax = "proto3";
    package grpc;
    
    option java_multiple_files = true;
    option java_package = "com.lattechiffon.grpc";
    option java_outer_classname = "UserServiceOuterClass";
    
    message User {
    	int64 idx = 1;
    	string username = 2;
    	string email = 3;
    	repeated string roles = 4;
    }
    
    message UserIdx {
    	int64 idx = 1;
    }

    메시지 형식에 관한 정의를 완료했습니다. 이제 이 메시지를 수신하고 반환하게 될 서비스 메서드를 정의해야 합니다. 다음과 같이 사용자를 새로 등록하고, 기존에 등록되어 있는 사용자를 조회하는 두 개의 Unary RPC 메서드를 서비스에 등록합니다.

    // UserService.proto
    service UserService {
    	rpc setUser(User) returns (UserIdx); // 새로운 사용자를 등록하는 Unary RPC 메서드
    	rpc getUser(UserIdx) returns (User); // 등록된 사용자를 조회하는 Unary RPC 메서드
    }

    이제 Protocol Buffer를 이용한 사용자 등록 및 조회를 위한 간단한 메시지 및 서비스 정의가 끝났습니다. 최종적으로 작성된 UserService.proto의 소스코드는 다음과 같습니다.

    // UserService.proto
    syntax = "proto3";
    package grpc;
    
    option java_multiple_files = true;
    option java_package = "com.lattechiffon.grpc";
    option java_outer_classname = "UserServiceOuterClass";
    
    service UserService {
    	rpc setUser(User) returns (UserIdx);
    	rpc getUser(UserIdx) returns (User);
    }
    
    message User {
    	int64 idx = 1;
    	string username = 2;
    	string email = 3;
    	repeated string roles = 4;
    }
    
    message UserIdx {
    	int64 idx = 1;
    }

    Protocol Buffer로 작성된 gRPC 서비스 정의를 이용하여 Protocol Buffer Compiler로 컴파일하면 개발자가 원하는 타겟 언어의 서버 스켈레톤 및 클라이언트 스텁 소스코드를 생성할 수 있습니다.

    .proto 파일과 빌드 관리 – MSA 관점에서

    Java, Kotlin 등과 같이 Gradle 빌드 시스템을 이용할 수 있는 경우에는 다음처럼 build.gradle을 구성하여 Protocol Buffer를 컴파일할 수 있습니다. 이때, 프로젝트의 src/main 위치에 proto라는 이름의 디렉토리를 생성하고, src/main/proto 위치에 앞서 작성한 .proto 파일을 두면 됩니다.

    // build.gradle (Gradle 6.8.0)
    buildscript {
        repositories {
            mavenCentral()
        }
    
        dependencies {
            classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.16'
        }
    }
    
    plugins {
        id 'java'
        id 'com.google.protobuf' version '0.8.16'
    }
    
    group 'com.lattechiffon'
    version '1.0-SNAPSHOT'
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'io.grpc:grpc-netty:1.38.0'
        implementation 'io.grpc:grpc-protobuf:1.38.0'
        implementation 'io.grpc:grpc-stub:1.38.0'
        implementation 'com.google.protobuf:protobuf-java:3.17.3'
    
        testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
        testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
    }
    
    protobuf {
        protoc {
            artifact = 'com.google.protobuf:protoc:3.17.3'
        }
        plugins {
            grpc {
                artifact = 'io.grpc:protoc-gen-grpc-java:1.38.0'
            }
        }
        generateProtoTasks {
            all()*.plugins {
                grpc {}
            }
        }
    }
    
    sourceSets {
        main {
            java {
                srcDirs 'build/generated/source/proto/main/grpc'
                srcDirs 'build/generated/source/proto/main/java'
            }
        }
    }
    
    test {
        useJUnitPlatform()
    }

    이제 Gradle 도구의 ‘Generate Proto’를 동작시키면 Profocol Buffer 빌드 플러그인이 프로젝트의 build/generated/source/proto/main 위치에 스텁 코드들을 생성합니다.

    이 방법은 Gradle 빌드 시스템에 종속되는 단점이 있으며, 다양한 언어로 구현된 모듈들로 구성되는 Polyglot MSA 애플리케이션의 CI 프로세스에는 적합하지 않을 수도 있습니다. (뱅크샐러드 블로그 참조: https://blog.banksalad.com/tech/production-ready-grpc-in-golang/)

    따라서 Protocol Buffer 파일들을 비롯하여 애플리케이션 개발에 필요한 각 언어로 빌드한 소스코드들을 하나의 Git 저장소에서 관리하고, 애플리케이션의 소스코드를 관리하는 각각의 Git 저장소에서는 해당 저장소를 서브모듈로 가지도록 구성하면 개발 과정에서 발생하는 사이드이펙트를 개선할 수 있을 가능성이 높습니다.

    (Protocol Buffer Compiler와 gRPC 플러그인을 활용한 단독 컴파일 방식 – 내용 추가 예정)

    그러나 Gradle, Maven 등을 비롯한 빌드 시스템만으로 애플리케이션 개발 구성이 가능하다면 처음 소개한 구성 방법이 유용하다는 점은 여전히 유효하다고 할 수 있습니다. 아래의 Unary RPC 구현 예제에서는 Gradle을 이용한 빌드를 계속 활용합니다.

    Unary RPC 구현하기 (Java)

    앞선 과정을 통해 gRPC의 상세 구현을 위한 스텁 코드들이 생성되었습니다. build.gradle의 sourceSets 설정에 의해 해당 소스코드들은 모두 프로젝트에 포함되며, 개발자는 이를 상속 및 호출하여 각 애플리케이션에서 수행해야 하는 세부적인 로직들을 구현할 수 있습니다.

    글의 서두에서 우리는 새로운 사용자를 등록하고, 이미 등록된 사용자의 정보를 조회하는 애플리케이션을 만드는 상황을 이미 가정했습니다. 또한, Protocol Buffer를 이용하여 다음과 같이 2개의 Unary RPC 메서드를 정의했습니다.

    • setUser: 클라이언트가 등록할 사용자 정보가 포함된 메시지(User)를 전달하면, 서버는 등록이 처리된 사용자 식별번호만 포함된 메시지(UserIdx)를 반환합니다.
    • getUser: 클라이언트가 조회할 사용자 식별변호만 포함된 메시지(UserIdx)를 전달하면, 서버는 해당 사용자 정보가 포함된 메시지(User)를 반환합니다.

    이 2개의 메서드를 이용하여 메시지를 주고 받는 서버와 클라이언트를 간단하게 하나의 프로젝트에서 구현해보도록 하겠습니다.

    src/main/java/package(예제에서는 com.lattechiffon.grpc)의 위치에 UserServiceImpl.java를 생성하고 다음과 같이 클래스를 구현합니다. 이 클래스는 UserServiceGrpc.UserServiceImplBase 클래스를 상속 받습니다. 오버라이딩된 메서드들은 모두 Protocol Buffer에서 선언한 RPC 메서드들과 이름이 동일합니다. 개발자는 각 메서드에서 수행해야 하는 비즈니스 로직을 작성하면 됩니다.

    // UserServiceImpl.java (src/main/java/com.lattechiffon.com/)
    package com.lattechiffon.grpc;
    
    import io.grpc.Status;
    import io.grpc.StatusException;
    
    import java.util.HashMap;
    import java.util.Map;
    
    public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
    
        private final Map<Long, User> userMap = new HashMap<>();
    
        private long idxCounter = 1;
    
        @Override
        public void setUser(User request, io.grpc.stub.StreamObserver<UserIdx> responseObserver) {
            request = request.toBuilder().setIdx(idxCounter++).build();
            userMap.put(request.getIdx(), request);
    
            UserIdx response = UserIdx.newBuilder().setIdx(request.getIdx()).build();
            responseObserver.onNext(response);
            responseObserver.onCompleted();
        }
    
        @Override
        public void getUser(UserIdx request, io.grpc.stub.StreamObserver<User> responseObserver) {
            long userIdx = request.getIdx();
    
            if (userMap.containsKey(userIdx)) {
                responseObserver.onNext(userMap.get(userIdx));
                responseObserver.onCompleted();
            } else {
                responseObserver.onError(new StatusException(Status.NOT_FOUND));
            }
        }
    }
    

    동일한 패키지 안에 GrpcApplication.java를 생성하고 다음과 같이 서버와 클라이언트를 모두 실행하는 메인 함수를 구현합니다. 일반적으로는 서버와 클라이언트를 서로 다른 프로젝트로 분리하지만, 여기서는 이해를 돕기 위해 하나의 프로젝트로 구성했습니다. 서버는 별도의 자바 쓰레드에서 동작하며 요청을 대기하며, 클라이언트는 서버와의 RPC 통신을 위한 채널을 생성한 후에 setUser와 getUser 메서드를 한 번씩 호출하고 서버로부터 결과 메시지를 받습니다. 그 이후 클라이언트 채널을 닫고, 서버는 Java Runtime Shutdown Hook에 의해 종료됩니다.

    // GrpcApplication.java (src/main/java/com.lattechiffon.com/)
    package com.lattechiffon.grpc;
    
    import io.grpc.ManagedChannel;
    import io.grpc.ManagedChannelBuilder;
    import io.grpc.Server;
    import io.grpc.ServerBuilder;
    
    import java.io.IOException;
    
    public class GrpcApplication {
    
        public static void main(String[] args) {
            // Initialize gRPC Server
            int port = 8080;
            Server server = ServerBuilder.forPort(port).addService(new UserServiceImpl()).build();
    
            try {
                server.start();
            } catch (IOException e) {
                e.printStackTrace();
                return;
            }
    
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                System.err.println("Server: Shutting down gRPC server");
                server.shutdown();
                System.err.println("Server: Server shut down");
            }));
    
            // gRPC Client (Unary RPC, 2 times)
            ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build();
            UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
            UserIdx setUserResult = stub.setUser(User.newBuilder().setUsername("GO YONGGUK")
                    .setEmail("lattechiffon@gmail.com").addRoles("USER").addRoles("ADMIN").build());
            System.out.println("Client: " + setUserResult.getIdx());
    
            User getUserResult = stub.getUser(setUserResult);
            System.out.println(getUserResult.toString());
    
            // Release
            channel.shutdown();
            Runtime.getRuntime().exit(0);
        }
    }
    

    이제 Gradle로 프로젝트 전체를 빌드하고 실행하면 다음과 같이 정상적으로 gRPC가 동작하여 메시지가 출력되는 것을 확인할 수 있습니다.

    이 예제의 전체 소스코드는 아래의 GitHub 저장소에서 확인할 수 있습니다.

    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

    나머지 세 가지 패턴의 통신에 대한 구현은 다음 글에서 다루도록 하겠습니다.

  • gRPC 시작하기

    gRPC란?

    gRPC는 구조화된 데이터 직렬화에 적합하도록 설계된 Protocol Buffer 기반의 인터페이스 정의 언어(IDL; Interface Definition Language)를 사용하여 물리적 혹은 논리적으로 분산되어 있는 다수의 프로세스 사이에서의 고속 통신을 위한 원격 프로시저 호출(RPC; Remote Procedure Call) 프레임워크입니다.

    gRPC의 특징

    확장된 형식의 Protocol Buffer를 이용하여 메시지의 구조와 통신 메서드의 파라미터 및 반환 타입 등을 정의하므로 gRPC는 기본적으로 아래에 기술된 Protocol Buffer의 고유한 장점과 단점을 모두 가지게 됩니다.

    • Protocol Buffer의 장점
      • 사전에 정의된 형식을 이용하므로 애플리케이션 간의 통신에서 활용되는 메시지의 엄격한 타입 정의가 가능합니다.
      • 바이너리로 직렬화된 메시지를 교환하므로 텍스트 기반의 JSON, XML 등에 비해 그 크기가 작습니다.
    • Protocol Buffer의 단점
      • 메시지의 구조를 별도로 정의하여 관리해야 하고, 명세를 미리 정의하고 소프트웨어를 개발하는 계약 우선(Contract-First) 방식이 권장되므로 개발 조직 특성에 따라서 도입이 쉽지 않을 수 있습니다.
      • 메시지의 구조가 급격히 변경되면 이를 활용하는 클라이언트와 서버 코드를 다시 생성해야 하며, 이는 지속적 통합(CI; Continuous Integration)에 포함되어야 하므로 전체 개발 프로세스의 복잡도를 가중시킬 수 있습니다.

    Protocol Buffer 기반의 IDL을 이용하여 명세된 서비스 정의를 통해 gRPC의 저수준 통신 구현이 추상화된 서버 스켈레톤과 클라이언트 스텁 코드를 자동으로 생성할 수 있으며, 개발자는 이를 이용하여 구현하는 소프트웨어에서 필요한 시점에 원격 프로시저를 간단하게 호출할 수 있습니다.

    특히, gRPC는 특정 프로그래밍 언어에 종속되지 않도록 설계되어 있을 뿐만 아니라, Protocol Buffer 기반의 서비스 정의를 제공하므로 다양한 언어에서 작동가능한 Polyglot한 특성을 가집니다.

    또한, gRPC는 고성능의 양방향 메시지 전송을 지원하는 HTTP/2 프로토콜을 사용하므로 서버와 클라이언트 사이의 메시지 교환을 위해 다양한 통신 방법을 활용할 수 있습니다.

    gRPC 통신 패턴

    gRPC는 다음의 네 가지 통신 방법을 제공합니다. Protocol Buffer로 정의된 메시지를 전달하므로 이를 발신하는 쪽(일반적으로 클라이언트 사이드)에서는 마샬링(Marshaling)을 수행하고, 수신하는 쪽(일반적으로 서버 사이드)에서는 언마샬링(Unmarshaling)을 수행하게 됩니다.

    • Unary RPC: 클라이언트에서 단일 요청이 발생하면 서버에서 단일 응답을 반환합니다. (일반적인 HTTP Request-Response 구조와 유사함.)
    • Server-Streaming RPC: 클라이언트에서 단일 요청이 발생하면 서버에서 연속적인 스트림 응답을 반환합니다. 클라이언트는 EOF에 도달할 때까지 스트림 데이터를 수신합니다.
    • Client-Streaming RPC: 클라이언트에서 연속적인 스트림 요청을 전송하면 서버는 단일 응답을 반환합니다. 서버는 EOF에 도달할 때까지 스트림 데이터를 수신하며, 수신 도중에 스트림을 취소하여 클라이언트의 메시지 전송을 조기에 중지할 수도 있습니다.
    • Bidirectional-Streaming RPC: 클라이언트와 서버 모두 독립적으로 동작하는 수신 스트림과 발신 스트림을 이용하여 서로에게 EOF에 도달할 때까지 연속적인 스트림을 전달할 수 있습니다.