난세의 영웅 gRPC

gRPC의 특징을 알아보고 이미지 검색 API 서비스에 적용한 경험을 기록해 보았습니다.

이희용
2022.07.11

“천하는 오랫동안 합쳐져 있었다면 반드시 나뉘어지게 되고, 오랫동안 나뉘어져 있었다면 반드시 합쳐진다”

*소설 <삼국지연의>의 첫 문장*


데이터 통신의 유구한 역사

이야기하기 전에 앞서 프로그램의 탄생 후 프로그램간 통신 기술들을 간략히 되짚어 보면, 단일 머신에서는 File, Memory, UNIX PIPE, RPC, Socket 등의 기술들로 행해졌고, 물리적으로 분리된 머신에서는 HTTP, CORBA, RMI, SOAP 등의 웹서비스 춘추전국 시대를 거쳐 현대에 이르러 웹서비스의 통신방식의 대부분은 json 기반의 REST 방식이 천하 통일을 이룬 상태라고 봐도 무방합니다.

그러나…

msa1.png

다들 아시는 아마존과 넷플릭스의 서비스 생태계입니다. 시간이 지날수록 어떤 프로그램이든 덩치가 비대해지게 되면서 분리되고, 프로그램의 종류와 개수가 늘어나면서 동시에 통신의 횟수도 늘어나고 통신 데이터의 크기와 종류도 다양해짐으로써, 결국엔 위와 같은 미래로 향하게 되는 운명을 가지고 있습니다. 위에 보이는 복잡한 선들을 전부 REST 방식으로 통신한다고 가정했을때, 하나의 요청이 여러 단계의 서비스를 거치다보면 REST 통신이 중첩되며, 이 때의 비효율적인 리소스 처리가 누적되면서 퍼포먼스 손실이 발행합니다.

하지만 REST를 대신하기 위한 기술적 걸림돌이 몇 가지 존재합니다.

  • 전송데이터를 json으로 변환하고 다시 바이너리로 직렬화하여 통신하는 과정의 비효율적인 데이터 변환작업의 리소스 점유
  • 문자열 통신을 바이너리 통신으로 바꾸려면 이기종 간의 데이터를 처리하는 방식이 상이한 문제
    • 실행환경의 CPU가 다를 경우 발생 가능한 big/little endian 문제, 개발 언어가 다를 경우 byte를 처리하는 단위가 언어마다 다른 문제 등
  • 하나의 통신 과정을 하나의 스레드가 처리하는 리소스의 낭비 (스레드 내에서는 다른 서비스와의 IO 통신이 블로킹 되죠)
  • 클라이언트들이 알고 있어야 하는 서비스들의 REST 정보 (API URL, METHOD, parameter 정보 등)
  • (개발자가 REST 통신을 위해 기본적인 구현에 할애하는 시간) * (총 개발자의 수)에 해당하는 시간적 낭비

이에 대해 구글(신)은 열심히 고민을 해서 결국 방안을 마련하게 됩니다.

  • 데이터 처리는 다양한 환경이 문제가 되는것이니 다양한 언어 및 환경에 맞게 처리되도록 라이브러리를 만들자. (프로토콜 버퍼라고 합니다)
  • 스레드 기반 방식이 아닌 이벤트 기반 방식으로 동작시켜 스레드가 죽기 전까지 계속 일을 시키자.
  • HTTP2 기반으로 중복되는 데이터는 최소화하고 커넥션 과정도 최소화하여 스트리밍 처리를 효율적으로 하자.
  • 개발자들이 비즈니스 로직에 집중할 수 있게 반복 작업들에 해당하는 소스 코드들을 자동으로 생성하자.

난세의 영웅 gRPC

그럼 실질적인 gRPC 이야기로 넘어가보도록 하겠습니다.

gRPC는 구글이 내부적으로 서비스 간 통신에 오랫동안 사용하던 Stubby라는 프로젝트를 오픈소스로 2015년 경에 출시한 라이브러리입니다. 현재는 점점 많은 서비스들이 마이크로서비스 아키텍쳐를 도입하면서 마이크로서비스 내부 통신에 도입하는 기술입니다. 이벤트 기반 동작 방식에 HTTP2.0 프로토콜을 사용함으로써 높은 압축률과 성능으로 인해 빠르고 안정적인 통신이 가능하고, 보안은 물론 스트리밍 처리까지 쉽게 구현이 가능합니다. 또한 프로토콜 버퍼라는 직렬화 라이브러리를 포함하고 있어, 다양한 언어와 환경 간의 바이너리 통신의 제약에서 벗어날수 있습니다.

gRPC는 개발자가 정의하는 프로토콜 버퍼 IDL 파일을 기반으로 실제 구현할 서비스들의 인터페이스, Dto 클래스들의 생성과 바이너리 직렬화 등의 통신 과정에 해당하는 소스 코드들을 자동생성해 줌으로써, 서버 개발자들은 통신 과정등에 대해서 신경 쓸 필요없고 gRPC가 자동생성하는 인터페이스 구현만 하면 되기 때문에 비즈니스 로직 구현에 좀더 집중할 수 있습니다. 또한 클라이언트 개발자들은 gRPC가 빌드해 준 인터페이스들을 호출하기만 하면 됩니다. 마치 프로젝트 라이브러리에 포함되어있는 메서드를 호출하듯 간단하게 말이죠.

gRPC 통신 흐름은 아래와 같습니다.

gprc-process.png

gRPC 구성

저희 정보검색파트에서는 IntellijJ IDE와 주언어로 Java와 Kotlin를 사용 중이기 때문에 Spring 생태계와 매우 강한 디펜던시를 맺고 있습니다. 그래서 Spring의 기본 기능들을 사용하기 위해 Spring Boot에서 gRPC를 사용하고 있습니다.

그럼 이제 Spring과 gRPC를 어떻게 구성하는지 천천히 따라가 보시죠.

1. IntelliJ의 프로토콜 버퍼 플러그인을 설치 합니다

Intellij Ultimate 최신 버전이라면 이미 번들로 설치가 되어 있을 수 있습니다.

플러그인.png

2. gRPC의 핵심, 프로토콜 버퍼 정의인 proto 파일을 생성합니다

언어별 상세한 가이드는 가이드문서에서 보실수 있습니다

해당파일을 수정하면 gradle 빌드를 필히 진행해야 합니다.

// 프로토콜 버퍼의 버전과 옵션을 명시합니다
syntax = "proto3";
option java_outer_classname = "GrpcModel";
package com.dealicious.grpc;

// 통신 request/response에 사용될 객체들을 정의합니다
// 각라인의 마지막 숫자는 order를 나타내는 필수값입니다
message Request {
  string stringValue = 1;         // Java String
  uint64 longValue = 2;           // Java Long
  CustomObject customObject = 3;  // 다른 message 객체의 포함 형식입니다
}
message CustomObject {
  int32 customValue = 1;          // Java Integer
}
message Response {
  repeated double value = 1;      // Java List<Double>
}

// 서비스인터페이스를 정의합니다 , GRPC는 아래의 네가지의 통신방식을 제공합니다
service GrpcService {
  rpc getOne(Request) returns (Response) {}                   // 하나의 요청, 하나의 응답
  rpc serverStream(Request) returns (stream Response) {}      // 하나의 요청, n개의 스트리밍 응답
  rpc clientStream(stream Request) returns (Response) {}      // n개의 스트리밍 요청, 하나의 응답
  rpc biStream(stream Request) returns (stream Response) {}   // 1:n의 스트리밍 형태
}

// stream: n개의 요청/응답 키워드
// repeated: array 키워드

3. gRPC 빌드 자동화를 위해 gradle 파일을 설정합니다

해당 설정을 안 해주면 수동 빌드라는 지옥의 늪에 빠지게 됩니다.

plugins {
    id 'org.springframework.boot' version '2.3.1.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'

    // protobuf 플러그인
    id 'com.google.protobuf' version '0.8.15' 
}

// 프로젝트 빌드 실행시 자바클래스들을 build/generated/source/proto에 생성
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.12.0"
    }
    plugins {
        grpc {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.36.0'
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

dependencies {
    
    // 기존의 다른 라이브러리들
    // spring boot 등등등

    // grpc
    // grpc-java는 netty서버를 embedded하여 사용합니다
    implementation 'io.grpc:grpc-netty-shaded:1.36.0' 
    implementation 'io.grpc:grpc-protobuf:1.36.0'
    implementation 'io.grpc:grpc-stub:1.36.0'

    // protobuf
    implementation "com.google.protobuf:protobuf-java-util:3.8.0"
    compile group: 'com.google.protobuf', name: 'protobuf-java', version: '3.8.0'

    // grpc test
    testImplementation group: 'io.grpc', name: 'grpc-testing', version: '1.36.0'
}

sourceSets {
    main {
        // 빌드된 폴더를 인텔리제이가 인식할수 있도록 소스폴더로 포함
        java {
            srcDirs += [ './build/generated/source/proto/main/grpc', './build/generated/source/proto/main/java' ]
        }
        // 프로토콜버퍼 파일의 위치를 지정
        proto {
            srcDir 'src/main/resources/proto'
        }
    }
}

4. 프로토콜 버퍼에서 정의한 서비스들의 인터페이스 구현을 합니다

소스 코드 만으로는 이해가 힘드실 것 같아서 어렵게 공수한 gRPC에서 제공하는 4가지 방식의 간략한 그림입니다.

grpc type.png

그리고 인터페이스 구현시 gRPC는 이벤트 기반으로 동작하기 때문에 구현방법은 Spring WebFlux나 Rx라이브러리와 유사합니다.

@Service
// GrpcServiceImplBase -> 프로토콜버퍼파일에 명시된 service의 추상클래스입니다
public class GrpcService extends GrpcServiceGrpc.GrpcServiceImplBase { 

    Logger log = LoggerFactory.getLogger(this.getClass());

    // Unary : 1요청, 1응답 방식
    @Override
    public void getOne(GrpcModel.Request request, StreamObserver<GrpcModel.Response> responseObserver) {

        try {
            log.info("getOne: request: {}", request);
            // 기본타입객체의 경우 validation을 위한 has메서드를 제공합니다
            if (request.hasCustomObject()) {
                // TODO: exception
            }

            // 서비스의 비즈니스로직을 수행합니다
            // ...

            // grpc가 구성해준 모델클래스입니다
            GrpcModel.Response response = GrpcModel.Response
                    .newBuilder()
                    .addAllValue(Lists.newArrayList(1.0,2.0,3.0,4.0,5.0))
                    .build();

            // 하나의 요청에 대한 하나의 응답을 처리합니다
            responseObserver.onNext(response);

            // 응답에 대한 완료이벤트를 처리합니다
            responseObserver.onCompleted();

        } catch (Exception e) {

            // 로직수행중 에러가 발생할 경우 클라이언트에 Error 반환합니다
            responseObserver.onError(
                    Status.INTERNAL
                            .withDescription(e.getMessage())
                            .withCause(e) // cause는 클라이언트로는 전송되지 않습니다. 인터셉터를 구성할경우 참조용도입니다.
                            .asRuntimeException()
            );
        }
    }

    // 클라이언트 스트리밍 - 클라이언트에서 완료이벤트가 전송되지 않으면, 서버는 계속 응답을 대기합니다
    // n요청: 1응답
    @Override
    public StreamObserver<GrpcModel.Request> clientStream(StreamObserver<GrpcModel.Response> responseObserver) {
        return new StreamObserver<GrpcModel.Request>() {

            @Override public void onNext(GrpcModel.Request value) {
                // 클라이언트로부터 완료이벤트가 전송될때까지 계속 요청이
                log.info("clientStream onNext request: {}", value);

                // 비즈니스 로직 처리
                // ...
            }

            @Override
            public void onError(Throwable t) {
                //스트리밍중 에러 처리
                log.info("clientStream onError: {}", t.getMessage());
            }

            @Override public void onCompleted() {

                log.info("clientStream onCompleted");
								
                // 비즈니스 로직 처리
                // ...

                // 클라이언트로부터 스트림완료이벤트가 오면 클라이언트로 1번의 응답을 보낼수 있습니다
                GrpcModel.Response response = GrpcModel.Response
                        .newBuilder()
                        .addAllValue(Lists.newArrayList(1.0,2.0,3.0,4.0,5.0))
                        .build();
                responseObserver.onNext(response);
                responseObserver.onCompleted();
            }
        };
    }

    // 서버 스트리밍 방식 - 클라이언트의 스트리밍과 반대개념 입니다 ( 개념적으로 모바일의 알림의 그것과 같습니다 )
	// 클라이언트의 요청이 시작되면 서버가 완료이벤트를 전송하기 전까지 클라이언트는 서버로부터 데이터를 받을수 있습니다
    // 1요청: n응답
    @Override
    public void serverStream(GrpcModel.Request request, StreamObserver<GrpcModel.Response> responseObserver) {

        log.info("serverStream: request: {}", request);

        GrpcModel.Response response = GrpcModel.Response
                .newBuilder()
                .addAllValue(Lists.newArrayList(1.0,2.0,3.0,4.0,5.0))
                .build();

        // 클라이언트에의 요청은 1번이지만 서버는 아래처럼 여러번의 데이터를 스트리밍 할수 있습니다
        responseObserver.onNext(response);
        responseObserver.onNext(response);
        responseObserver.onNext(response);
        responseObserver.onNext(response);
        responseObserver.onNext(response);

        responseObserver.onCompleted();
    }

    // 양방향 스트리밍방식 (Bi-directional) - 클라이언트에서 완료이벤트가 전송하기 전까지, 서로간의 스트리밍을 진행합니다
    // n요청: n응답
    @Override
    public StreamObserver<GrpcModel.Request> biStream(StreamObserver<GrpcModel.Response> responseObserver) {
        return new StreamObserver<GrpcModel.Request>() {
            @Override public void onNext(GrpcModel.Request value) {

                log.info("biStream onNext request: {}", value);

                // 클라이언트로부터 데이터가 올 때마다 onNext가 호출된다
                // 1개의 요청이 올 때마다 n번의 응답을 스트리밍 전송한다
                GrpcModel.Response response = GrpcModel.Response
                        .newBuilder()
                        .addAllValue(Lists.newArrayList(1.0,2.0,3.0,4.0,5.0))
                        .build();

                responseObserver.onNext(response);
                responseObserver.onNext(response);
                responseObserver.onNext(response);
            }
            @Override public void onError(Throwable t) {
                log.info("biStream onError request: {}", t.getMessage());
            }
            @Override public void onCompleted() {
                log.info("biStream onComplete");
                // 클라이언트의 완료이벤트가 오면 서버도 완료이벤트를 진행한다
                responseObserver.onCompleted();
            }
        };
    }
}

5. Config Bean을 구성합니다

사실 5번 6번을 한번에 구성해도 되지만 저도 모르는 사이 제가 리팩토링이란걸 해버렸습니다.

@Configuration
@RequiredArgsConstructor
public class GrpcServerConfig {

    @Value("${grpc.port:8888}")
    Integer grpcPort;

    // service 구현체 bean을 주입받습니다
    private final GrpcService rpcService;

    // Grpc Java는 Netty를 사용합니다
    @Bean
    public Server grpcServer() {
        return ServerBuilder
                .forPort(grpcPort)
                .addService(rpcService)
                .build();
    }
}

6. 서비스의 시작과 종료를 gRPC도 함께합니다

@Component
@RequiredArgsConstructor
public class ServerStartRunner implements ApplicationRunner, DisposableBean {
		
    // config의 grpc bean을 주입받습니다
    private final Server grpcServer;

    // grpc는 스프링과는 별도로 구동시켜야 하므로 runner 사용합니다
    @Override
    public void run(ApplicationArguments args) throws Exception {
        grpcServer.start();
        grpcServer.awaitTermination();
    }

    @Override
    public void destroy() throws Exception {
        if (!ObjectUtils.isEmpty(grpcServer)) {
            grpcServer.shutdown();
        }
    }
}

정보검색파트 내에서는 위와 같은 설정으로 이미지 검색 서비스에 gRPC를 적용해 보았습니다.

grpc.jpg

적용 대상인 이미지 벡터 조회 서비스는 요청 횟수가 그리 많은 서비스가 아니기 때문에 성능적인 관점에서는 큰 이득을 보지는 못했으며, Kubernetes 환경이 아닌 EC2 환경에서 사용 시 로드 밸런서에 gRPC 프로토콜에 대한 처리를 수동으로 추가해줘야 하는 번거로움은 분명 존재했습니다. 하지만 Spring Web에서 진행하던 controller, parameter dto 구성 등의 비즈니스 로직 처리 전까지의 구현 반복 작업들을 gRPC 빌드 시 자동 생성해 주기 때문에 비즈니스 로직에 더 집중할 수 있었다는 점에서 서비스 간 통신량이 많은 환경에서 더욱 큰 가치를 가질 수 있으리라 생각합니다.

이희용

딜리셔스 검색 개발자

"머리는 차갑게 가슴은 뜨겁게"