스크래치에서 Java gRPC
자바(Java)에서 gRPC 구현하기
본 글에서는 자바 환경에서 gRPC를 어떻게 구현하는지 자세히 알아보겠습니다.
gRPC(Google Remote Procedure Call)는 구글에서 개발한 오픈 소스 RPC 아키텍처로, 마이크로서비스 간의 빠른 통신을 가능하게 합니다. gRPC를 활용하면 서로 다른 언어로 작성된 서비스들을 효율적으로 통합할 수 있습니다. gRPC는 데이터 직렬화를 위해 뛰어난 효율성과 압축률을 제공하는 Protobuf(프로토콜 버퍼) 메시징 형식을 사용합니다.
특정 사용 사례에서는 gRPC API가 REST API보다 더 효율적일 수 있습니다.
이제 gRPC 서버를 구축해 보겠습니다. 가장 먼저 서비스와 모델(DTO)을 정의하는 .proto 파일들을 만들어야 합니다. 간단한 서버 구현을 위해 ProfileService와 ProfileDescriptor를 사용합니다.
ProfileService는 다음과 같이 정의됩니다.
syntax = "proto3";
package com.deft.grpc;
import "google/protobuf/empty.proto";
import "profile_descriptor.proto";
service ProfileService {
rpc GetCurrentProfile (google.protobuf.Empty) returns (ProfileDescriptor) {}
rpc clientStream (stream ProfileDescriptor) returns (google.protobuf.Empty) {}
rpc serverStream (google.protobuf.Empty) returns (stream ProfileDescriptor) {}
rpc biDirectionalStream (stream ProfileDescriptor) returns (stream ProfileDescriptor) {}
}
gRPC는 다양한 클라이언트-서버 통신 방식을 지원하며, 이 모든 방식을 자세히 살펴보겠습니다:
- 기본 서버 호출(요청/응답)
- 클라이언트에서 서버로의 스트리밍
- 서버에서 클라이언트로의 스트리밍
- 양방향 스트리밍
ProfileService 서비스는 import 섹션에 명시된 ProfileDescriptor를 이용합니다.
syntax = "proto3";
package com.deft.grpc;
message ProfileDescriptor {
int64 profile_id = 1;
string name = 2;
}
- int64는 자바(Java)의 Long 타입에 해당하며, 프로필 ID를 나타냅니다.
- string은 자바와 마찬가지로 문자열 변수를 의미합니다.
프로젝트 빌드 시 Gradle 또는 Maven을 사용할 수 있습니다. Maven을 사용하는 것이 더 편리하며, 따라서 Maven 기반으로 설명합니다. Gradle을 사용할 경우 .proto 파일 생성 과정과 빌드 설정이 다소 달라집니다. 간단한 gRPC 서버를 구축하는 데는 하나의 의존성만 있으면 충분합니다.
<dependency>
<groupId>io.github.lognet</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>4.5.4</version>
</dependency>
이 스타터는 필요한 많은 작업을 자동으로 처리해 줍니다.
우리가 만들 프로젝트 구조는 다음과 같습니다.
Spring Boot 애플리케이션을 시작하기 위해 GrpcServerApplication 클래스가 필요하며, .proto 파일에 정의된 서비스 메서드를 구현하는 GrpcProfileService 클래스도 필요합니다. protoc을 사용하여 .proto 파일에서 클래스를 생성하려면 pom.xml 파일에 protobuf-maven-plugin을 추가해야 합니다. 빌드 섹션 구성은 다음과 같습니다.
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.2</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot>
<outputDirectory>${basedir}/target/generated-sources/grpc-java</outputDirectory>
<protocArtifact>com.google.protobuf:protoc:3.12.0:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.38.0:exe:${os.detected.classifier}</pluginArtifact>
<clearOutputDirectory>false</clearOutputDirectory>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
- protoSourceRoot: .proto 파일들이 위치한 디렉토리를 지정합니다.
- outputDirectory: 생성된 파일들이 저장될 디렉토리를 설정합니다.
- clearOutputDirectory: 생성된 파일들을 삭제하지 않도록 설정합니다.
이제 프로젝트를 빌드할 수 있습니다. 빌드 후 outputDirectory에 지정한 폴더로 이동하면 생성된 파일들을 확인할 수 있습니다. 이제 GrpcProfileService를 단계적으로 구현할 수 있습니다.
클래스 선언부는 다음과 같습니다.
@GRpcService public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase
@GRpcService 어노테이션은 해당 클래스를 gRPC 서비스 빈으로 표시합니다.
ProfileServiceGrpc.ProfileServiceImplBase로부터 서비스를 상속받았기 때문에 부모 클래스의 메서드들을 오버라이드할 수 있습니다. 가장 먼저 오버라이드할 메서드는 getCurrentProfile입니다.
@Override
public void getCurrentProfile(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {
System.out.println("getCurrentProfile");
responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
.newBuilder()
.setProfileId(1)
.setName("test")
.build());
responseObserver.onCompleted();
}
클라이언트에게 응답을 전송하려면 전달받은 StreamObserver의 onNext 메서드를 호출해야 합니다. 응답 전송 후에는 서버가 작업을 완료했음을 알리는 onCompleted 메서드를 호출합니다. getCurrentProfile 서버에 요청을 보내면 다음 형식의 응답을 받게 됩니다.
{
"profile_id": "1",
"name": "test"
}
다음으로 서버 스트리밍을 살펴보겠습니다. 이 방식에서는 클라이언트가 서버에 한 번의 요청을 보내면 서버는 메시지 스트림 형태로 클라이언트에게 응답합니다. 예를 들어 루프를 통해 5개의 응답을 보낼 수 있습니다. 모든 전송이 완료되면 서버는 스트림이 성공적으로 완료되었다는 메시지를 클라이언트에게 보냅니다.
오버라이드된 서버 스트림 메서드는 다음과 같습니다.
@Override
public void serverStream(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {
for (int i = 0; i < 5; i++) {
responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
.newBuilder()
.setProfileId(i)
.build());
}
responseObserver.onCompleted();
}
결과적으로 클라이언트는 profileId가 0부터 4까지 순차적으로 증가하는 5개의 메시지를 수신하게 됩니다.
{
"profile_id": "0",
"name": ""
}
{
"profile_id": "1",
"name": ""
}
…
{
"profile_id": "4",
"name": ""
}
클라이언트 스트림은 서버 스트림과 거의 유사합니다. 이번에는 클라이언트가 메시지 스트림을 서버로 전송하고 서버가 이 스트림을 처리합니다. 서버는 메시지를 즉시 처리하거나, 클라이언트의 모든 요청을 기다린 후 일괄 처리할 수 있습니다.
@Override
public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> clientStream(StreamObserver<Empty> responseObserver) {
return new StreamObserver<>() {
@Override
public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) {
log.info("ProfileDescriptor from client. Profile id: {}", profileDescriptor.getProfileId());
}
@Override
public void onError(Throwable throwable) {
}
@Override
public void onCompleted() {
responseObserver.onCompleted();
}
};
}
클라이언트 스트림에서 서버는 메시지를 수신할 StreamObserver를 반환해야 합니다. 스트림 처리 중 오류가 발생하면 onError 메서드가 호출됩니다(예: 비정상적인 종료).
양방향 스트림을 구현하려면 서버와 클라이언트 모두에서 스트림 생성을 결합해야 합니다.
@Override
public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> biDirectionalStream(
StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) {
return new StreamObserver<>() {
int pointCount = 0;
@Override
public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) {
log.info("biDirectionalStream, pointCount {}", pointCount);
responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor
.newBuilder()
.setProfileId(pointCount++)
.build());
}
@Override
public void onError(Throwable throwable) {
}
@Override
public void onCompleted() {
responseObserver.onCompleted();
}
};
}
이 예제에서는 클라이언트가 메시지를 보낼 때마다 서버는 pointCount가 증가된 프로필을 응답으로 반환합니다.
결론
본 글에서는 gRPC를 사용하여 클라이언트와 서버 간의 메시징을 위한 기본적인 옵션들(서버 스트림, 클라이언트 스트림, 양방향 스트림)을 구현하는 방법을 살펴보았습니다.
이 글은 Sergey Golitsyn이 작성했습니다.