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'
    ...
} 

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다