본문 바로가기

grpc

[grpc] 4. grpc - Java

[grpc] 4. grpc

목차

1. https://dlwnsdud205.tistory.com/326 [grpc] 1. grpc의 기반기술 - RPC

2. https://dlwnsdud205.tistory.com/327 [grpc] 2. grpc의 기반기술 - HTTP/2.0 

3. https://dlwnsdud205.tistory.com/328 [grpc] 3. grpc의 기반기술 - Protocol Buffers와 성능 테스트 

4. https://dlwnsdud205.tistory.com/329 [grpc] 4. grpc - Java

 

만일, RPC, HTTP/2.0, protocol buffers에 대해 모른다면 위 목차들을 순서대로 읽고 오도록 하자. 특히, protocol buffers 사용법은 grpc를 사용하기위해 반드시 알고있어야 한다.

 

grpc는 rpc의 아키텍쳐를 사용하며, IDL로 protocol buffers, 전송 프로토콜로 HTTP/2.0을 사용하는 rpc구현체이다. 이 와 같은 특성때문에, grpc는 다음과 같은 장점을 갖는다.

1. rpc의 아키텍쳐를 사용하므로 원격 프로시저를 로컬 프로시저처럼 호출 한다.

2. protocol buffers를 IDL로 사용함으로써 언어와 플랫폼 중립적, 높은 데이터 압축률 등 protocol buffers의 장점을 갖는다.

3. HTTP/2.0을 사용함으로써 다중통신, 양방향 스트림, 효율적인 헤더 사용등 HTTP/2.0 의 장점을 갖는다.


grpc 서비스 정의

grpc는 protocol buffer에 서비스를 명시하는것으로 사용을 시작할 수 있다.

이 포스팅에서 사용할 간단한 .proto 파일을 만들고 rpc서비스를 정의해주겠다.

* 참고로 package 경로가 달라지면 rpc호출에서 메소드를 찾을 수 없다는 오류가 발생하니 주의하도록 하자.

syntax = "proto3";

package xb.note.grpc;
option java_outer_classname = "GreetingService";
option java_multiple_files = true;

service HelloService{
	rpc SayHello(HelloRequest) returns (HelloResponse);
}

message HelloRequest{
	string request = 1;
}

message HelloResponse{
	string response = 1;
}

 

위 .proto 파일을 보면, 이전 Protocol buffers글에서는 못보던 service 블록이 추가된 것을 볼 수 있는데, grpc는 위 와 같이 service블록의 rpc필드에 전송에 사용할 메소드 이름, 전송에 사용할 파라미터와 리턴타입을 명시함으로 사용할 수 있다.

또한, grpc는 HTTP/2.0의 특성을 살려 클라이언트와 서버 양측에서 스트림을 열 수 있는데, 각 스트림은 독립적으로 열린다. 아래 예시들을 보자.

 

1. 기본적인 rpc

service HelloService{
	rpc SayHello(HelloRequest) returns (HelloResponse);
}

클라이언트는 하나의 HelloRequest를 전송하고, 하나의 HelloResponse를 응답받는다.

기본적인 rpc호출에서의 life cycle은 다음과 같다.

1-1. 클라이언트가 로컬의 stub메소드를 호출한다.

1-2. 서버측의 stub이 클라이언트의 stub에게서 알림을 받고, 서버는 rpc가 호출되었음을 공지 받는다.

1-3. 서버는 초기 메타데이터를 보내거나 클라이언트의 메시지가 도착할때까지 기다린다.

1-4. 클라이언트의 메시지가 도착하면, 서버는 응답을 만들기 위해 어떠 작업이 필요한지 확인하고, 응답메시지를 상태코드와 함께 클라이언트에게 전송한다.

1-5. 응답 상태가 OK라면, 클라이언트는 응답을 받고 완료된다.

 

2. 클라이언트 스트림 rpc

service HelloService{
	rpc SayLotsOfHello(stream HelloRequest) returns (HelloResponse);
}

클라이언트 측에서 스트림을 열면 서버는 더 이상 들어오는 메시지가 없을때 까지 요청을 읽는다. 

클라이언트 스트림의 life cycle

서버가 클라이언트의 모든 메시지를 받은 후 단일 응답을 보내고 완료된다는 점을 제외하면 기본적인 rpc의 life cycle과 같다.

 

3. 서버 스트림 rpc

service HelloService{
	rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse);
}

서버측에서 스트림을 열면 클라이언트는 더 이상 반환되는 메시지가 없을때까지 스트림에서 메시지를 읽을 수 있다.

서버 스트림의 life cycle

클라이언트가 서버의 모든 메시지를 받은 후 완료된다는 점을 제외하면 기본 스트림 rpc의 life cycle과 같다.

 

4. 양방향 스트림 rpc

service HelloService{
	rpc BidSayHello(stream HelloRequest) returns (stream HelloResponse)
}

서버와 클라이언트에서 스트림을 열 경우 스트림은 독립적인 스트림 2개(서버, 클라이언트)가 열리게 된다. 따라서, 서버와 클라이언트는 원하는 순서대로 읽고 쓸수가 있다. 예를 들어, 서버는 클라이언트의 메시지를 모두 읽고 응답을 처리할 수 도 있고, 클라이언트의 메시지와 응답을 교대로 처리할 수 도 있다. 


Java에서 grpc 사용하기

테스트 환경

jdk 11

gradle 7.4

intellij

 

Java에서 grpc를 사용하기 위해선 몇가지 라이브러리를 추가해줘야 하기 때문에, 간단한 테스트를 위해 gradle을 사용했다.

기존에는 .proto파일을 컴파일 하기위해 터미널에서 protoc 컴파일러를 이용했는데, grpc를 사용하기 위해선 protoc컴파일러에 플러그인을 설정하고 빌드해줘야한다. 수동으로 이 작업을 하기엔 복잡하고, 공식문서에서 권장하지 않는다고 하니 gradle에 plugin을 설정하고 gradle task로 grpc파일을 만들어 줄것 이다.

 

아래 예시들을 글 작성시점에서의 최신 버전이며, 원문은 다음 링크에서 확인할 수 있다.

https://github.com/grpc/grpc-java/blob/master/README.md

https://github.com/google/protobuf-gradle-plugin

 

또한, 이 글에 있는 코드는 다음 링크에서 모두 확인할 수 있다.

https://github.com/devxb/javaNote/tree/main/grpc

 

우선, gradle에 다음 블록들을 추가해야한다.

plugins {
    ...
    id 'com.google.protobuf' version '0.8.19'
}
dependencies {
    runtimeOnly 'io.grpc:grpc-netty-shaded:1.49.0'
    implementation 'io.grpc:grpc-protobuf:1.49.0'
    implementation 'io.grpc:grpc-stub:1.49.0'
    compileOnly 'org.apache.tomcat:annotations-api:6.0.53' // necessary for Java 9+
    implementation 'com.google.protobuf:protobuf-java-util:3.21.5'
    ...
}
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.21.5"
    }
    plugins {
        grpc {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.49.0'
        }
    }
    // 생성된 파일이 위치할 경로를 지정해준다. 지정하지않으면, project root의 build디렉토리 하단에 위치하게됨.
    // generatedFilesBaseDir = "$projectDir/src/grpc"
    generateProtoTasks {
        all()*.plugins{
            grpc{}
        }
        ofSourceSet('main')
    }
}

grpc플러그인은 src/main/proto/경로 아래의 .proto파일들을 인식한다. 따라서, build전에 .proto파일을 해당 경로에 위치 시켜야한다.

(src/test/proto/ 경로도 가능하다.)

 

이제, 터미널에서 다음 명령어를 입력하고 generateProto가 존재하는지 확인하자.

./gradlew task --all

generateProto가 있다면, 성공적으로 설정된 것 이므로 다음 명령어를 통해 grpc 파일을 생성해주자.

./gradlew generateProto

protobuf블록에 generatedFileBaseDir을 설정해주지 않았다면,

build / generated / proto / ... / ~~Grpc.java

와 같이 build디렉토리 아래에 파일이 생성되었을것이다. 하지만, 여기까지만 진행할 경우 gradle은 기본적으로 src/main아래에 위치한 클래스들만 인식하기 때문에, IDE에서 클래스를 인식하지 못하는 상황이 발생한다. (IDE에서 인식하지 못할뿐이지 javac 컴파일러를 이용하는 컴파일은 잘 된다.)

IDE에서 인식하지 못하면 빨간글씨가 보기도 안좋고 개발하는데 불편하니 인식하도록 gradle에 sourceSets를 추가해줘야한다. build.gradle에 다음 블록을 추가해주자.

sourceSets {
    main {
        proto {
            //srcDir '파일이 위치한 경로'
            srcDir 'build/generated/source/proto/main/grpc/xb/note/grpc'
            srcDir 'build/generated/source/proto/main/java/xb/note/grpc'
            // 여기에 새로운 proto파일이 위치한 경로를 추가할 수 있음.
        }
    }
    // test코드에서도 인식하도록 하려면 다음과 같이 작성하면 된다.
    /*
    test {
    	proto {
            srcDir 'build/generated/source/proto/main/grpc/xb/note/grpc'
            srcDir 'build/generated/source/proto/main/java/xb/note/grpc'
        }
    }
    */
}

다시 확인해보면 IDE에서 인식을 잘 하는 모습을 볼 수 있다.

지금까지 한 build.gradle 설정은 grpc client와 grpc server모두 동일하다.


Java로 grpc server 만들기

이제, 클라이언트의 요청을 받을 grpc server를 만들어 줄 것 이다.

rpc 스트림에 따라 구현이 달라지는데 이 포스팅에서는 가장 간단한 기본적인 rpc호출을 기준으로 테스트 할 것 이다. 다른 스트림에서의 구현이 궁금하다면 다음 링크를 참조하도록 하자. 다만, 기본적인 원리(.proto파일의 rpc필드에 정의되어있는 메소드 오버라이딩 -> 서버에 등록)는 동일 하므로 이 글만 읽어도 나머지는 크게 어렵지 않게 할 수 있을것 이다.

https://grpc.io/docs/languages/java/basics/#server 

 

우선, .proto파일에 정의되어있는 SayHello 메소드를 구현해줘야한다.

grpc 플러그인에 의해 생성된 HelloServiceGrpc.HelloServiceBaseImpl을 상속받은 클래스를 만들자.

public class SayHelloSkeleton extends HelloServiceGrpc.HelloServiceImplBase {
    /***/
}

이제, HelloServiceImplBase안의 sayHello 메소드를 재정의 하면 된다. (grpc 플러그인이 알아서 camel케이스로 메소드를 만들어 주므로 앞 단어는 소문자로 변경된다. 또한, 메소드 이름은 각자 .proto파일에 정의한 메소드 이름을 따른다.)

 

나는 request를 받으면 answer : request 형태로 응답하는 간단한 메소드를 만들었다.

package xb.note.skeletons;

import xb.note.grpc.HelloServiceGrpc;
import xb.note.grpc.HelloRequest;
import xb.note.grpc.HelloResponse;

import io.grpc.stub.StreamObserver;

public class SayHelloSkeleton extends HelloServiceGrpc.HelloServiceImplBase {

    @Override
    public void sayHello(HelloRequest request,
                         StreamObserver<HelloResponse> responseObserver) {
        String requestString = request.getRequest();
        HelloResponse helloResponse = HelloResponse.newBuilder()
                .setResponse("answer : " + requestString)
                .build();
        responseObserver.onNext(helloResponse);
        responseObserver.onCompleted();
    }

}

응답 혹은 요청객체를 만드는방법은 내가 작성한 protocol buffers 글을 읽으면 알 수 있다.

 

이제, 클라이언트의 요청을 받을 서버를 만들어 줘야 하는데, 서버는 ServerBuilder를 통해 생성한다.

    public HelloServiceServer(int port){
        helloServer = ServerBuilder.forPort(port).addService(new SayHelloSkeleton()).build();
    }

서버에 앞서 만든 SayHelloSkeleton객체를 서버가 제공하는 서비스로 포함시키는것을 볼 수 있다.

서버 시작은 

helloServer.start();

와 같이 할 수 있는데, 이 번 테스팅의 경우 서버를 실행상태로 유지시켜주는 기능? 이 없으므로 서버가 실행되자마자 종료될것이다. 따라서, 서버가 종료되지 않게 하기 위해 다음 메소드를 추가 해줘야한다.

helloServer.awaitTermination();

최종적인 서버 등록과 실행 코드는 다음과 같다.

package xb.note.servers;

import io.grpc.Server;
import io.grpc.ServerBuilder;

import xb.note.skeletons.SayHelloSkeleton;

import java.io.IOException;

public class HelloServiceServer {

    private Server helloServer;

    public HelloServiceServer(int port){
        helloServer = ServerBuilder.forPort(port).addService(new SayHelloSkeleton()).build();
    }

    public void start(){
        try {
            helloServer.start();
            System.out.println("Server started on : " + helloServer.getPort());
            helloServer.awaitTermination();
        }catch(IOException IOE){IOE.printStackTrace();}
        catch(InterruptedException IE){IE.printStackTrace();}
    }

}

이제, Main함수로 들어가 서버를 실행해 보자.

package xb.note;

import xb.note.servers.HelloServiceServer;

public class Main {

    public static void main(String[] args) {
        HelloServiceServer helloServiceServer = new HelloServiceServer(4321);
        helloServiceServer.start();
    }
}

서버가 성공적으로 실행된다면 클라이언트를 만드는 코드로 넘어가자.


Java로 grpc client 만들기

마찬가지로 기본적인 rpc호출을 기준으로 코드를 작성했다. 다른 구현이 궁금하다면 다음 링크를 참조하자.

https://grpc.io/docs/languages/java/basics/#client

grpc client도 (스텁등록 -> 호출) 원리는 같고, 스트림에 따라서 호출 파라미터, 반환 부분만 조금씩 달라지므로 다른 스트림에서의 구현도 크게 어렵지 않게 할 수 있을 것 이다.

 

우선, server때와 같이 build.gradle파일을 작성 한 후, .proto 파일을 src/main/proto 경로에 위치한다음 파일을 생성하자 

grpc 클라이언트는 스텁을 인스턴트화 시켜줘야하는데, 우선 스텁에 연결될 원격 grpc 서버의 정보를 등록해줘야한다. grpc 서버의 정보는 ManagedChannel 클래스를 통해서 등록 가능하다. 다음 코드를 보자.

        managedChannel = ManagedChannelBuilder
                .forAddress(host, port)
                .usePlaintext()
                .build();

host에는 grpc서버의 host (http:// 가 들어가면 안된다.) 와 grpc서버의 out-bound port를 적고 build를 해주면 된다. 

주의할점이, TLS인증을 사용하지 않는다면 위와같이 usePlaintext()옵션을 추가해 빌드해야 한다. default가 TLS를 사용하는것 이니 주의하도록 하자.

이제, 생성된 managedChannel을 생성된 stub에 등록해주면된다. stub은 다음 두 종류가 있다.

1. blocking stub

blockingStub = HelloServiceGrpc.newBlockingStub(managedChannel);

2. async stub

asyncStub = HelloServiceGrpc.newStub(managedChannel);

이 두종류 외에 FutureStub도 존재하는데, 공식문서에는 asyncStub을 기본 Stub으로 만들고있다. (나는 Future가 자바진영에서 async를 사용하는 방법으로 알고있는데 이 부분은 좀 더 공부가 필요해보인다.)

최종적으로 생성 코드는 다음과 같다.

    public HelloServiceClient(String host, int port){
        managedChannel = ManagedChannelBuilder
                .forAddress(host, port)
                .usePlaintext()
                .build();
        blockingStub = HelloServiceGrpc.newBlockingStub(managedChannel);
        asyncStub = HelloServiceGrpc.newStub(managedChannel);
    }

이제, 생성된 stub의 메소드(.proto파일에 각자 정의한 메소드)를 호출하면서 원격 통신을 할 수 있다.

    public HelloResponse sayHelloWithBlocking(String message){
        HelloRequest request = buildRequest(message);
        return blockingStub.sayHello(request);
    }

최종적으로 완성되는 전체 코드는 다음과 같다.

package xb.note.client;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import xb.note.grpc.HelloServiceGrpc;
import xb.note.grpc.HelloResponse;
import xb.note.grpc.HelloRequest;

import java.util.concurrent.ExecutionException;

public class HelloServiceClient {

    private ManagedChannel managedChannel;
    private HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
    private HelloServiceGrpc.HelloServiceStub asyncStub;

    public HelloServiceClient(String host, int port){
        managedChannel = ManagedChannelBuilder
                .forAddress(host, port)
                .usePlaintext()
                .build();
        blockingStub = HelloServiceGrpc.newBlockingStub(managedChannel);
        asyncStub = HelloServiceGrpc.newStub(managedChannel);
    }

    public HelloResponse sayHelloWithBlocking(String message){
        HelloRequest request = buildRequest(message);
        return blockingStub.sayHello(request);
    }

    private HelloRequest buildRequest(String message){
        return HelloRequest.newBuilder()
                .setRequest(message)
                .build();
    }

}

최종적으로 Main클래스로 돌아가 클라이언트에서 원격 프로시저를 호출해보자.

package xb.note;

import xb.note.client.HelloServiceClient;

public class Main {
    public static void main(String[] args) {
        HelloServiceClient helloServiceClient = new HelloServiceClient("localhost", 4321);
        System.out.println(helloServiceClient.sayHelloWithBlocking("hello world").getResponse());
    }

}

테스트

우선, grpc server가 실행중인지 확인하고

grpc client에서 메시지를 보낸다.

성공적으로 테스트가 완료되었다. 로컬 프로시저를 호출하듯이 원격 프로시저를 호출하는게 인상적이다

 

이 포스팅에서 나온 모든 코드는 다음 레포지토리에서 확인할 수 있다.

https://github.com/devxb/javaNote/tree/main/grpc

 

GitHub - devxb/javaNote: grpc, springboot, kafka 등등 공부장 with Java

grpc, springboot, kafka 등등 공부장 with Java. Contribute to devxb/javaNote development by creating an account on GitHub.

github.com