[태그:] Protocol Buffer

  • 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에 도달할 때까지 연속적인 스트림을 전달할 수 있습니다.