Spring 7.X (Spring Boot 4.X) gRPC 1.0.0 정식 릴리즈 그리고 gRPC 사용법

Spring gRPC 1.0.0 버전이 정식 릴리즈 됐다. 이를 통해 이제 Spring 공식 지원을 통해 안정적인 고성능 RPC(Remote Procedure Call) 환경을 쉽게 구축할 수 있다. 이번 포스팅에서는 gRCP 1.0.0 정식 릴리즈가 되면서 기존의 gRPC 0.X 버전을 사용하던 기존 프로젝트에서 gRPC 1.0.0 버전을 적용하기 위해서 고려해야 할 사항과 적용 방법, 그리고 gRPC 사용법에 대해서 간단히 정리해 보고자 한다.

gRPC 통신의 이점은 대표적으로 다음과 같다.

  • 저지연 통신: REST/JSON 대비 훨씬 작은 Protobuf 페이로드를 사용하여 네트워크 대역폭을 절약하고 지연 시간을 줄일 수 있다.
  • 타입 안전성: Protobuf 스키마를 통해 컴파일 시점에 통신 계약을 검증할 수 있어 런타임 오류 가능성이 크게 줄어든다. 이를 통해 개발 생산성 및 시스템 안정성 향상을 기대할 수 있다.

하지만 아쉽게도 Spring Framework 7 (Spring Boot 4) 버전에 포함시킬 것을 염두에 두고 프로젝트가 시작되어 Spring Boot 3버전에서는 사용할 수 없지만 만약 추후에 Spring Boot 3에서 gRPC 0.12(실험버전)을 사용하는 경우 Spring Boot 4로 버전을 업그레이드 하더라도 마이그레이션 해야 할 사항은 크게 없을 것 같다.

이유는 다음과 같은 호환성을 고려했기 때문이다.

  • 어노테이션: @GrpcService, @GrpcClient 유지
  • 패키지 경로: org.springframework.grpc.* 유지
  • 설정 파일: application.yml의 spring.grpc.* 속성 구조 유지
  • 비즈니스 로직: StreamObserver를 사용하는 로직은 gRPC 자체 표준이므로 변경 없음

Spring gRPC 사용을 위한 권장 메커니즘인 BOM(Bill of Materials), 자동 구성 및 starter는 현재 위치(groupId가 org.springframework.grpc인 상태)를 유지한다. 따라서 Spring gRPC 0.12.0을 사용 중이었다면 의존성 관리에서 버전만 변경하면 된다. 자동 구성 클래스 자체는 패키지 이름이 변경되므로 해당 클래스를 명시적으로 사용 중인 경우(아마도 소수 사례일 것) import도 변경해야 할 수 있다.
Spring Boot 4.1에 포함될 예정인 자동 구성 기능이 병합되면 별도의 BOM이 필요하지 않으며 starter에 대한 종속성 관리에서 코디네이트만 변경하면 된다.
https://spring.io/blog/2025/11/05/spring-grpc-next-steps 참조

간단한 예로 기존 gRPC 0.12.0버전을 사용하고 있는 경우 maven 설정은 아래와 같을 것이다.

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.grpc</groupId>
			<artifactId>spring-grpc-dependencies</artifactId>
			<version>0.12.0</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>
<dependencies>
	<dependency>
		<groupId>org.springframework.grpc</groupId>
		<artifactId>spring-grpc-spring-boot-starter</artifactId>
	</dependency>
...
XML

gRPC 1.0.0으로 업그레이드 하기 위해서는 <version> 필드의 정보만 변경하면 된다.
gradle의 경우도 마찬가지다.

dependencyManagement {
    imports {
        mavenBom 'org.springframework.grpc:spring-grpc-dependencies:0.12.0'
    }
}
dependencies {
    implementation 'org.springframework.grpc:spring-grpc-spring-boot-starter'
...
Groovy

dependencyManagement 섹션의 0.12.0을 1.0.0으로 변경만 하면 된다.

Spring gRPC 사용법

백문이 불여일견이라고 gRPC 사용법에 대해서 샘플 프로젝트를 통해서 알아보자. 빌드 도구는 gradle을 사용했다.
샘플 프로젝트는 총 3개의 submodule로 구성되어 있다.

  • grpc-client
  • grpc-interface
  • grpc-server

grpc-client 모듈은 클라이언트 역할을 할 모듈이다.
grpc-interface 모듈은 클라이언트와 서버간의 gRPC 통신을 위한 protobuf 명세를 정의하기 위한 모듈이다.
grpc-server 모듈은 서버 역할을 할 모듈이다.

빌드 및 디펜던시 정의

기본 빌드 설정은 grpc-client, grpc-server는 root의 build.gradle 설정을 이어받아 사용하도록 한다.

root settings.gradle

pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
    }

    // ✨ 여기서 버전을 정의하면, 프로젝트 내 모든 build.gradle에서 버전 생략 가능!
    plugins {
        id 'com.google.protobuf' version '0.9.5'
        id 'io.spring.dependency-management' version '1.1.7'
        id 'org.springframework.boot' version '4.0.0'
        id 'java-library' // 코어 플러그인은 버전 불필요
        id 'java'
    }
}

rootProject.name = 'spring-boot-grpc'

include 'grpc-server'
include 'grpc-client'
include 'grpc-interface'
Groovy

settings.gradle에 pluginManagement를 정의하여 모든 submodule에서 공통으로 사용하도록 정의한다.
org.springframework.boot 버전은 4.0.0 이상 버전을 지정해야 한다.

root build.gradle

plugins {
    id 'java'
    id 'java-library'
    id 'org.springframework.boot' apply false
    id 'io.spring.dependency-management' apply false
    // Protobuf 컴파일 플러그인
    id 'com.google.protobuf' apply false
}

// 모든 submodule에서 사용하기 위한 설정
subprojects {
    apply plugin: 'java'
    group = 'org.example'
    version = '0.0.1-SNAPSHOT'
    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(17)
        }
    }

    repositories {
        mavenCentral()
    }
}
description = 'spring-boot-grpc-1.0.0'

// [조건부 적용] grpc-server나 grpc-client 모듈에만 적용 (interface 제외!)
configure(subprojects.findAll { it.name.contains('server') || it.name.contains('client') }) {

    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    ext {
        set('springGrpcVersion', "1.0.0")
    }

    dependencyManagement {
        imports {
            mavenBom "org.springframework.grpc:spring-grpc-dependencies:${springGrpcVersion}"
            mavenBom "org.springframework.boot:spring-boot-dependencies:4.0.0"
        }
    }

    dependencies {
        // grpc-server와 grpc-client모두 grpc-interface를 공통으로 사용해야 하므로 반드시 포함해야 한다.
        implementation project(':grpc-interface')
        developmentOnly 'org.springframework.boot:spring-boot-devtools'
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
        annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        implementation 'org.springframework.grpc:spring-grpc-spring-boot-starter'
        testImplementation 'org.springframework.grpc:spring-grpc-test'
    }

    tasks.named('test') {
        useJUnitPlatform()
    }
}
Groovy

grpc-server, grpc-client 의존성은 거의 동일하므로 root 프로젝트 build.gradle에 정의하였다.
grpc-server, grpc-client 모듈의 build.gradle은 root의 build.gradle에서 정의했으므로 description 정보만 추가했다.

# grpc-server의 build.gradle
description = 'grpc-server'

# grpc-client의 build.gradle
description = 'grpc-client'
Groovy

grpc-interface

이번 포스팅에서 가장 핵심 역할을 할 모듈이다. build.gradle과 proto 파일 정의를 살펴보자.

build.gradle

grpc client와 grpc server가 공통으로 사용하는 메시지를 정의해야 하므로 protobuf 명세는 별도의 라이브러리로 작업하여 client와 server에 모두 배포하는 것이 일반적일 것 같다.

plugins {
    id 'java-library'
    id 'io.spring.dependency-management'
    // Protobuf 컴파일 플러그인
    id 'com.google.protobuf'
}

description = 'grpc-interface'

// 앞서 언급했듯이 Spring gRPC 0.12.0을 사용하고 있었다면 1.0.0으로 버전만 변경해 주면 된다.
ext {
    set('springGrpcVersion', "1.0.0")
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.grpc:spring-grpc-dependencies:${springGrpcVersion}"
    }
}

dependencies {
    // Protobuf 메시지 (Message) 지원
    // (.proto의 message가 변환된 자바 클래스가 상속받는 기본 클래스들)
    api 'com.google.protobuf:protobuf-java'

    // gRPC 스텁 (Stub) 지원
    // (Service가 변환된 Stub 코드가 의존하는 gRPC 핵심 기능)
    api 'io.grpc:grpc-stub'

    // Protobuf와 gRPC 연결
    // (Protobuf 객체를 gRPC 통신으로 직렬화/역직렬화하는 기능)
    api 'io.grpc:grpc-protobuf'

    // 어노테이션 지원 (@Generated 등 생성된 코드에 붙음)
    implementation 'javax.annotation:javax.annotation-api:1.3.2'
}

// .proto 파일을 자바 코드로 변환해 주기 위한 설정
protobuf {
    // protobuf 컴파일러(protoc) 다운로드
    protoc {
        artifact = "com.google.protobuf:protoc"
    }
    // service/stub 클래스 파일 생성을 위한 플러그인 추가
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java"
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {
                option '@generated=omit'
            }
        }
    }
}
Groovy
proto 파일 정의

protobuf 명세서를 정의해야 하는데 이 역할을 하는 것이 proto 파일이다.
src/main/proto 디렉토리에 다음 두 개의 proto 파일을 정의할 것이다.


src/main/proto/simple.proto (helloworld와 같은 단순한 요청 응답 정의)

syntax = "proto3";

// Protobuf 스펙상 패키지 경로
package org.example.grpcinterface;

// [Java 옵션 설정]
// 1. 파일을 각각 분리해서 생성 (필수 권장)
option java_multiple_files = true;
// 2. 생성될 Java 파일의 패키지 경로 (위의 package 정의 보다 이 설정이 우선한다!!)
option java_package = "org.example.grpcinterface";
// 3. (선택) Outer Class 이름 지정 (java_multiple_files=false일 때 주로 씀)
option java_outer_classname = "SimpleProto";

// 서비스 정의 (Interface)
service SimpleService {
  // 요청(HelloRequest)을 받아서 응답(HelloReply)을 반환하는 메소드
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// 요청 메시지 (DTO)
message HelloRequest {
  string name = 1; // 1번 필드
}

// 응답 메시지 (DTO)
message HelloReply {
  string message = 1; // 1번 필드
}
Groovy

서버에서 사용할 SimpleService와 주고 받을 메시지 DTO는 HelloRequest, HelloReply다.
proto3 버전을 사용하였는데 해당 proto 작성 관련 가이드는 https://protobuf.dev/programming-guides/proto3/ 링크를 참고하면 도움이 될 것이다.

src/main/proto/fileupload.proto

syntax = "proto3";

package org.example.grpcinterface;

option java_multiple_files = true;
option java_package = "org.example.grpcinterface";
option java_outer_classname = "FileUploadProto";

service FileUploadService {
  // stream이 Request 쪽에 붙어 있다. (클라이언트가 계속 보낸다는 의미)
  rpc UploadFile( stream FileUploadRequest ) returns ( FileUploadResponse );
}

message FileUploadRequest {
  //oneof: 둘 중 하나만 담아서 보낸다.
  oneof request {
    FileInfo info = 1;          // 첫 번째 메시지 용 (파일 메타 정보)
    bytes chunk_data = 2;       // chunked file data
  }
}

message FileInfo {
  string file_name = 1;
  string file_type = 2;
}

message FileUploadResponse {
  string file_name = 1;
  string status = 2;
  int64 total_size = 3;
}
Groovy

gRPC를 통해 파일 업로드를 위해서 정의하였다. FileUploadRequest 정의에서 oneof는 FileInfo와 chunk_data 둘 중에 하나만 보낼 수 있다. 처음 통신에서는 FileInfo를 통해 파일에 대한 메타 정보를 전송 하고 이후 파일 데이터를 chunked 방식으로 전송할 것이다.

이제 grpc-interface 모듈을 build 하면 grpc-interface 모듈 하위에
build/generated/proto/main/grpc, build/generated/proto/main/java 디렉토리가 생성될 것이다.

  • build/generated/sources/proto/main/grpc은 통신을 위한 도구 역할을 하는 클래스 파일이 생성되는데 이는 proc-gen-grpc-java 플러그인에서 생성한다. (보통은 파일명 뒤에 Grpc가 붙는다.)
  • build/generated/sources/proto/main/java는 Message/DTO 역할을 하는 클래스가 생성된다. proto 파일에서 message 키워드로 정의한 내용들이 자바 클래스로 변환되어 생성되는데 생성 주체는 protoc 컴파일러가 생성한다.
grpc proto 정의 빌드 결과
grpc-interface 빌드 결과

grpc-client / grpc-server 구현

grpc-interface가 성공적으로 빌드 되었다면 이제 gRPC를 사용할 준비는 끝났다.

simple service

우선 simple proto에 대한 client와 server 구현 샘플 코드다.

grpc-client

GrpcClientConfig

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.example.grpcinterface.FileUploadServiceGrpc;
import org.example.grpcinterface.SimpleServiceGrpc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class GrpcClientConfig {
    // 채널(Connection) 빈 생성
    @Bean
    public ManagedChannel simpleChannel() {
        // application.yml의 설정을 가져와서 쓸 수도 있지만,
        // 1.0.0 초기 설정에서는 명시적으로 빌더를 쓰는 것이 확실하다.
        return ManagedChannelBuilder.forAddress("localhost", 9090)
                .usePlaintext() // 개발용: 평문 통신
                .build();
    }

    // Simple 스텁(Stub) 빈 생성
    @Bean
    public SimpleServiceGrpc.SimpleServiceStub simpleStub( ManagedChannel channel) {
        return SimpleServiceGrpc.newStub(channel);
    }
}
Java

grpc-client에서 simple service와 통신을 위한 Stub 클래스를 빈으로 정의해서 사용할 수 있다. Stub은 마치 로컬에 있는 메서드를 호출하듯이 호출할 수 있도록 한다. 이 의미는 Stub 내부적으로 서버의 서비스와 통신하는 역할을 다 수행한다는 의미다.

SimpleRunner

import io.grpc.stub.StreamObserver;
import org.example.grpcinterface.HelloReply;
import org.example.grpcinterface.HelloRequest;
import org.example.grpcinterface.SimpleServiceGrpc;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.concurrent.CompletableFuture;

@Component
public class SimpleRunner implements CommandLineRunner {
    private final SimpleServiceGrpc.SimpleServiceStub simpleStub;

    public SimpleRunner( SimpleServiceGrpc.SimpleServiceStub simpleStub ) {
        this.simpleStub = simpleStub;
    }

    @Override
    public void run( String... args ) {
        System.out.println( "start simple runner" );
        CompletableFuture<String> future = new CompletableFuture<>();

        // 1. 요청 객체 생성 (Builder 패턴)
        HelloRequest request = HelloRequest.newBuilder()
                .setName("Grpc Developer")
                .build();

        // 2. gRPC 호출 (마치 로컬 메소드 부르듯이!)
        // 여기서 네트워크 통신이 일어난다.
        simpleStub.sayHello( request, new StreamObserver<>() {
            @Override
            public void onNext( HelloReply value ) {
                future.complete( value.getMessage() );
            }

            @Override
            public void onError( Throwable t ) {
                future.completeExceptionally( t );
            }

            @Override
            public void onCompleted() {
            }
        } );

        future.whenComplete( ( resp, err ) -> {
            if ( err != null ) {
                System.out.println("simple service error: " + err.getMessage());
            }
            else {
                System.out.println("simple service response: " + resp);
            }
        });

        future.join();
    }
}
Java

gRPC는 기본적으로 비동기로 동작을 하기 때문에 StreamObserver를 통해서 이벤트를 핸들링 해줘야 한다. 물론 동기 방식으로도 사용할 수 있다. 샘플에서는 비동기 방식으로 구현을 했고 main 스레드 종료를 막기 위해서 CompletableFuture를 사용하여 통신이 완료될 때까지 대기하도록 한다.

grpc-server

client에서 sayHello를 호출했을 때 서버에서는 이를 처리하기 위한 서비스를 만들어야 한다.

import io.grpc.stub.StreamObserver;
import org.example.grpcinterface.HelloReply;
import org.example.grpcinterface.HelloRequest;
import org.example.grpcinterface.SimpleServiceGrpc;
import org.springframework.stereotype.Service;

@Service
public class GrpcSimpleService extends SimpleServiceGrpc.SimpleServiceImplBase {
    @Override
    public void sayHello( HelloRequest request, StreamObserver<HelloReply> responseObserver) {
        // 1. 요청 값(request)에서 이름 꺼내기
        String name = request.getName();
        String message = "Hello, " + name + "! Welcome to Spring gRPC 1.0 world.";

        // 2. 응답 객체(Reply) 빌더 패턴으로 생성
        HelloReply reply = HelloReply.newBuilder()
                .setMessage(message)
                .build();

        // 3. 클라이언트에게 응답 보내기
        responseObserver.onNext(reply);

        // 4. 응답 끝났음을 알림 (필수!)
        responseObserver.onCompleted();
    }
}
Java

FileUpload service

gRPC를 통해서 클라이언트에서 서버로 파일을 업로드 하는 샘플을 구현해 봤다.

grpc-client

GrpcClientConfig

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.example.grpcinterface.FileUploadServiceGrpc;
import org.example.grpcinterface.SimpleServiceGrpc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class GrpcClientConfig {
    // 채널(Connection) 빈 생성
    @Bean
    public ManagedChannel simpleChannel() {
        // application.yml의 설정을 가져와서 쓸 수도 있지만,
        // 1.0.0 초기 설정에서는 명시적으로 빌더를 쓰는 것이 확실하다.
        return ManagedChannelBuilder.forAddress("localhost", 9090)
                .usePlaintext() // 개발용: 평문 통신
                .build();
    }

    // FileUpload 스텁(Stub) 빈 생성
    @Bean
    public FileUploadServiceGrpc.FileUploadServiceStub fileUploadStub( ManagedChannel channel) {
        return FileUploadServiceGrpc.newStub(channel);
    }
}
Java

FileUploadServiceStub를 빈으로 정의하였다. 서버와 통신하는 클라이언트 코드는 다음과 같다.

import com.google.protobuf.ByteString;
import io.grpc.stub.StreamObserver;
import org.example.grpcinterface.FileInfo;
import org.example.grpcinterface.FileUploadRequest;
import org.example.grpcinterface.FileUploadResponse;
import org.example.grpcinterface.FileUploadServiceGrpc;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.CompletableFuture;

@Component
public class FileUploadRunner implements CommandLineRunner {
    private final FileUploadServiceGrpc.FileUploadServiceStub fileUploadServiceStub;

    public FileUploadRunner( FileUploadServiceGrpc.FileUploadServiceStub fileUploadServiceStub ) {
        this.fileUploadServiceStub = fileUploadServiceStub;
    }

    @Override
    public void run( String... args ) throws Exception {
        System.out.println( "start file upload runner");
        CompletableFuture<String> future = new CompletableFuture<>();

        // 응답을 받을 Observer 정의
        StreamObserver<FileUploadResponse> responseObserver = new StreamObserver<>() {
            // 서버 응답 처리
            @Override
            public void onNext( FileUploadResponse fileUploadResponse ) {
                System.out.println( "---- 서버 응답 시작 ----" );
                System.out.println( "파일명: " + fileUploadResponse.getFileName() );
                System.out.println( "상태: " + fileUploadResponse.getStatus() );
                System.out.println( "파일크기: " + fileUploadResponse.getTotalSize() + " bytes" );
                System.out.println( "---- 서버 응답 끝 ----" );
            }

            //서버에서 에러 발생시 처리
            @Override
            public void onError( Throwable t ) {
                future.completeExceptionally( t );
            }

            //통신 완료 처리
            @Override
            public void onCompleted() {
                future.complete( "파일 업로드 완료!!" );
            }
        };

        // 서버의 리모컨을 전달 받고 서버에게 나의 리모컨을 전달하기
        StreamObserver<FileUploadRequest> requestObserver = fileUploadServiceStub.uploadFile( responseObserver );

        // 업로드 파일에 대한 메타데이터 전송
        ClassPathResource resource = new ClassPathResource( "test-image.jpg" );
        Path path = Paths.get( "src/main/resources/test-image.jpg");
        FileInfo fileInfo = FileInfo.newBuilder()
                .setFileName( resource.getFilePath().getFileName().toString() )
                .setFileType( "image/jpg" )
                .build();

        FileUploadRequest fileUploadRequest = FileUploadRequest.newBuilder()
                .setInfo( fileInfo )
                .build();

        requestObserver.onNext( fileUploadRequest );


        // file chunked data 전송
        try ( InputStream fis = resource.getInputStream() ) {
            //64kb씩 전송
            byte[] buffer = new byte[ 1024 * 64 ];
            int byteRead;
            while ( ( byteRead = fis.read( buffer ) ) != -1 ) {
                // ByteString으로 변환하여 전송
                FileUploadRequest chunkRequest = FileUploadRequest.newBuilder()
                        .setChunkData( ByteString.copyFrom( buffer, 0, byteRead) )
                        .build();

                requestObserver.onNext( chunkRequest );
            }

            //전송 끝 알림
            requestObserver.onCompleted();
        }
        catch (  Exception e ) {
            //예외 발생시 서버로 에러 전달
            requestObserver.onError( e );
            future.completeExceptionally( e );
        }

        future.whenComplete( (result, throwable) -> {
            if ( throwable != null ) {
                System.out.println("에러 발생: " +  throwable.getMessage());
            }
            else {
                System.out.println(result);
            }
        } );

        future.join();
    }
}
Java

위 코드는 src/main/resources/test-image.jpg 파일을 gRPC를 이용하여 서버로 전송하는 코드다.
보통 우리가 알고 있는 호출 방식은
StreamObserver responseObserver = fileUploadServiceStub.uploadFile( requestObserver );
과 같은 형식일 것이다. 하지만 아래와 같이 우리가 생각하는 순서와는 뭔가 좀 다르다.
// 서버의 리모컨을 전달 받고 서버에게 나의 리모컨을 전달하기
StreamObserver requestObserver = fileUploadServiceStub.uploadFile( responseObserver );
위 코드는 서버에게 나(client)의 리모컨(responseObserver)을 전달하고 서버는 나(client)에게 서버 자신의 리모컨(requestObserver) 을 전달한다.
즉 client는 requestObserver를 통해서 서버에게 요청을 전달하고 서버는 responseObserver를 통해서 client에게 응답을 전달한다는 의미다.
이 역시 비동기로 동작하기 때문에 메인 스레드의 우선 종료를 막기 위해 CompletableFuture를 사용했다.

grpc-server

파일 업로드 처리를 위한 서버 코드는 다음과 같다.

import io.grpc.stub.StreamObserver;
import org.example.grpcinterface.FileInfo;
import org.example.grpcinterface.FileUploadRequest;
import org.example.grpcinterface.FileUploadResponse;
import org.example.grpcinterface.FileUploadServiceGrpc;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

@Service
public class GrpcFileUploadService extends FileUploadServiceGrpc.FileUploadServiceImplBase {
    private static final String UPLOAD_DIR = "uploads/";

    /**
     * 파일 업로드 처리를 위한 서비스
     *
     * @param responseObserver 서버가 클라이언트에게 응답을 주는 리모컨.
     * @return StreamObserver<FileUploadRequest> 클라이언트가 서버에게 요청을 하는 리모컨.
     */
    @Override
    public StreamObserver<FileUploadRequest> uploadFile( StreamObserver<FileUploadResponse> responseObserver ) {
        // 요청을 처리할 익명 클래스 반환
        return new StreamObserver<FileUploadRequest>() {
            // 상태값 유지 (파일 스트림, 파일명, 수신 파일 사이즈)
            private OutputStream writer;
            private String fileName;
            private long totalSize = 0L;

            @Override
            public void onNext( FileUploadRequest fileUploadRequest ) {
                try {
                    // 처음 업로드 대상 파일 메타 정보 수신
                    if ( fileUploadRequest.hasInfo() ) {
                        FileInfo fileInfo = fileUploadRequest.getInfo();
                        this.fileName = fileInfo.getFileName();

                        //저장 경로 생성
                        Path uploadPath = Paths.get( UPLOAD_DIR );
                        if ( !Files.exists( uploadPath ) ) {
                            Files.createDirectories( uploadPath );
                        }

                        uploadPath = uploadPath.resolve( fileName );
                        this.writer = Files.newOutputStream( uploadPath );
                        System.out.println( "[Server] 파일 생성 시작: " + uploadPath );
                    }
                    //메타 정보 수신 이후 파일 데이터가 전송된다.
                    else if ( fileUploadRequest.hasChunkData() ) {
                        if ( writer == null ) {
                            throw new IOException( "file writer is not created." );
                        }

                        byte[] bytes = fileUploadRequest.getChunkData().toByteArray();
                        writer.write( bytes );
                        writer.flush();
                        totalSize += bytes.length;
                    }
                }
                catch ( IOException e ) {
                    this.onError( e );
                }
            }

            @Override
            public void onError( Throwable throwable ) {
                System.err.println( "에러 발생: " + throwable.getMessage() );
                closeFile();
            }

            @Override
            //클라이언트가 다 보내면 호출됨
            public void onCompleted() {
                closeFile();
                System.out.println( "[Server] 업로드 완료. 파일 크기: " + this.totalSize );

                //응답 보내기
                FileUploadResponse respons = FileUploadResponse.newBuilder()
                        .setFileName( this.fileName )
                        .setTotalSize( this.totalSize )
                        .setStatus( "SUCCESS" )
                        .build();

                responseObserver.onNext( respons );
                responseObserver.onCompleted();
            }

            private void closeFile() {
                try {
                    if ( writer != null ) writer.close();
                }
                catch ( IOException e ) {
                    e.printStackTrace();
                }
            }
        };
    }
}
Java

서버와 클라이언트간의 파일 업로드 통신 흐름은 다음과 같다.

클라이언트                    서버
         ------------------>
              connection
         <----------------->
            서로간의 리모컨 교환  //클라이언트는 서버 리모컨으로 서버에게 요청 신호 전달.
                              //서버는 클라이언트 리모컨으로 클라이언트에게 응답 신호 전달
                              //역할을 하기 위해 서로의 리모컨을 교환한다.
         ------------------>
            FileInfo 전송 (서버가 준 리모컨으로 onNext)
         ------------------>
         파일 chunked data 전송 (서버가 준 리모컨으로 onNext) (파일 데이터를 모두 읽을 때까지)
         ------------------>
         파일 전송 끝 이벤트 전송 (서버가 준 리모컨으로 onCompleted)
         <-----------------
             응답 전송 (클라이언트가 준 리모컨으로 onNext)
         <-----------------
             완료 전송 (클라이언트가 준 리모컨으로 onCompleted)
            
Plaintext

전체 코드는
https://gitlab.com/blog4031530/spring-boot-grpc 에서 확인 할 수 있다.