본문으로 바로가기
반응형

Photo by unsplash

gRPC가 무엇인지에 대한 간략한 소개와 android에서 사용하는 방법에 대하여 소개 합니다. 

gRPC란?

2016에 google에서 만든 빠른 RPC 입니다. protocol buffer라는 IDL을 사용하고, 여러 언어를 지원하며, HTTP/2를 사용합니다. 이 한문장에 gRPC에 대한 모든것이 담겨 있습니다만 좀더 자세히 알아보도록 합니다. 

Proto buffer

proto buffer는 strongly-typed 한 구조로 되어 있습니다. 네트워크에서 사용할 데이터 구조는 proto 파일에 작성하여, server와 client가 같이 사용합니다. 따라서 proto 파일을 협의하여 구성하고, 필요시 변경하면, 변경된 proto 파일에 따라 server와 client에서 각각 통신할 api와 데이터 객체들을 각자 사용하는 언어로 얻을수 있습니다. 즉 proto buffer는 정의된 구조를 여러 언어의 데이터 클래스로 변경해 주는 변환도구를 제공합니다. 대표적인 언어만으로도 Go, python, java, C#, C++, Kotlin, Ruby, Dart, Objective-C가 있으며 점점 생태계를 넓혀 가고 있습니다.

사용할 RPC 함수역시 함수와 더불와 매개변수와 반환타입을 proto file안에 정의하고, 함수 역시 변환툴로 각 언어에 맞는 형태로 자동 생성됩니다. 클라이언드에는 gRPC Stub이 생성되고, 서버에는 gRPC Server가 생성됩니다.

장점

Java, kotlin, Go, Swift,python 등등 다양한 언어를 지원합니다. (proto file 전환툴을 제공합니다) 또한 Binary로 encoding하여 최적화 하여 보내므로 String 기반의 JSON보다 훨씬 빠릅니다. (약 5배 정도 빠르다고 하네요.)
또한 HTTP/2의 stream을 사용하여 수명이 긴 단일 TCP를 연결하고 여러 메시지 stream을 허용합니다. 즉 채널은 하나만 열고, 해당 채널로 여러 rpc 통신을 할 수 있습니다. 따라서 server/client간 적은수의 TCP 연결만으로도 동시에 많은 RPC를 호출하여 처리할 수 있습니다.

Server와 Client가 실제 동작 Flow

#Client쪽
1. 빌드타임에 gRPC tool에 의해서 생성된 코드를(Client Stub) 클라이언트가 호출
2. 클라이언트가 Client stub에 넘겨진 데이터를 gRPC가 encoding하여 protocol buffer로 넘김
3. 넘겨진 데이터는 low-level의 transport layer로 내려보냄.
4. HTTP/2의 data stream network을 통하여 서버로 전달

* binary encoding과 network optimization에 인하여 JSON보다 5배 빠름

#Server쪽
5. Transport layer를 통하여 packet을 수신
6. decode stub을 이용하여 데이터를 decoding
7. 해당 server application을 invoke
8. 결과값이 return 되면 다시 encoding stub을 이용하여 encoding
9. Transport layer를 통해 결과 데이터를 전송

단점

현재 Browser에서는 지원하지 않기 때문에  gRPC-Web을(Proxy를 이용하는 방법) 이용하여야 하며 이는 gRPC의 기능을 완벽하게 지원하지 않는다고 합니다만, Browser 관련된 개발이 관심사가 아니니, 넘어가도록 합니다.
사실 위에서 언급된 모든 내용들은 https://www.youtube.com/watch?v=gnchfOojMk4 에 언급된 내용을 바탕으로 간략하게 정리한 부분 입니다. 해당 youtube를 보시면 gRPC가 무엇인지에 대해서 아주 또렷하게 개념을 잡을실 수 있을듯 합니다. (youtube를 너무 잘 만들어 놨습니다~)[1]

Android에서의 사용.

사실 위에 정리된 부분들은 개념에 가깝고, 뜬구름 잡는듯한 내용이라 본격적으로 Android에서 적용하는 방법과 사용법, 그리고 Android 관점에서 바라본 장/단점을 설명해 볼까 합니다. 다만 시작하기 전에 Android 공식 Developer site에서도 gRPC를 언급하고 있습니다.[2] 따라서 해당 페이지의 내용을 요약하며 아래와 같습니다.

Procedure call makes it simple

HTTP resource method인 (e.g. GET,PUT,POST,DELETE)의 제약을 받지 않는다. (메타데이터를 처리할 필요가 없어 구현이 더 자연스럽다.

Efficient network transmission with HTTP/2

HTTP/2의 bi-directional streaming, flow control, header compression, 단일 TCP/IP connection에서의 multiplex request를 이용하여 앱과 클라우드 서비스와 단말간 응답시간을 단축하고, 네트워크 사용량 감소 및 이로 인한 배터리 소모량 감소 효과를 가져옵니다.

Built-in streaming data exchange support

full-duplex bidriectional streaming을 이용하여 큰 사이즈의 업로드나 다운로드의 request and response을 진행할 수 있고, 동시에 메시지를 주고 받을수 있습니다.

Seamless integration with Protocol Buffer

gRPCProtobuf Java Lite plugin을 이용하여 안드로이드에 최적화된 serialization/deserialization 작업의 수행이 가능합니다. Text 기반의 JSON 같은 format의 경우 빠른 marshaling과 code size로 인하여 더 효율적을 동작하여 모바일 환경에 적합합니다.

전송 Client 규격 OkHttp vs Cornet 

최신 SDK라면 강력한 네트워크 스택을 제공하는 Cornet을 사용하는게 좋고, 범용성을 위한다면 OkHttp를 선택하여 사용 하도록 합니다. (용량 면에서 OkHttp는 ~100KB, Cornet은 1MB의 앱크기가 증가된다고 합니다.

build.gradle 작업

gRPC페이지에 가면[3] gradle을 세팅하는 방법에 대해서 잘 나와 있습니다. iOS나, swift에 대한 설명도 있으니 해당 페이지에 방문하여 사용법을 숙지하시면 됩니다.[4]

다만, android에서 사용하는 경우 일반적인 kotlin 관련된 버전을 사용해서는 안됩니다. android developer 공식 사이트에서 언급한것 처럼 lite 버전을 사용해야 하며, 해당 세팅은 아래와 같습니다.[5]

Project level build.gradle

buildscript {
    //gPRC 사용시 필요.
    dependencies {
        classpath ("com.google.protobuf:protobuf-gradle-plugin:0.8.18")
    }
}

plugins {
  ...
}
...

App level build.gradle

세곳에 필요한 코드들을 삽입해야 합니다.

  • plugins
  • protobuf
  • dependency
// 플러그 인을 추가합니다.
plugins {
    ...
    id("com.google.protobuf") //gRPC
}

...
...


//gRPC 세팅를 세팅 합니다.
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:${Versions.grpc_protobuf_version}"
    }
    plugins {
        id("java") {
            artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc_version}"
        }
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:${Versions.grpc_version}"
        }
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:${Versions.grpc_kotlin_version}:jdk8@jar"
        }
    }

    generateProtoTasks {
        all().forEach {
            it.plugins {
                id("java") {
                    option("lite")
                }
                id("grpc") {
                    option("lite")
                }
                id("grpckt") {
                    option("lite")
                }
            }
            it.builtins {
                id("kotlin") {
                    option("lite")
                }
            }
        }
    }
}

...
...

dependencies {
...
    // ------------------------------------------------------------
    // gRPC: 필요한 dependency를 추가합니다.
    // ------------------------------------------------------------
    implementation ("io.grpc:grpc-okhttp:${Versions.grpc_version}")
    implementation("io.grpc:grpc-stub:${Versions.grpc_version}")
    implementation("io.grpc:grpc-protobuf-lite:${Versions.grpc_version}")
    implementation("io.grpc:grpc-kotlin-stub:${Versions.grpc_kotlin_version}")
    implementation("com.google.protobuf:protobuf-kotlin-lite:${Versions.grpc_protobuf_version}")

...
}

현시점 (2022.02.07)에 사용하는 버전은 아래와 같습니다.

// gRPC
const val grpc_version = "1.52.1"
const val grpc_kotlin_version = "1.3.0"
const val grpc_protobuf_version = "3.21.12"

proto file의 작성

proto file의 작성은 매우 쉬운 편입니다. 모든 문법을 다루지는 않지만 간단하게 필요한 부분만 다뤄 보겠습니다.[6]

먼저 기본적인 setting 부분을 만들어 넣습니다.

syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.mytest.grpc";
option java_outer_classname = "MySampleProto";

package grpcsample;
...
...
  • syntax: proto2 버전도 존재하지만 추가기능이 더 있는 proto3을 사용하도록 맨 처음 선언해야 합니다.
  • option java_multiple_files: 기본값은 false로 하나의 java 파일을 만들고 그 안에 모든 (enum 포함) class를 중첩하여 만듭니다. 자동 생성되는 class들을 개별적으로 존재시키기 위해 여기서는 true로 명시합니다. 
  • option java_package: protocol buffer에 의해서 자동 생성되는 class들이 위치시킬 package를 지정합니다.
  • option java_outer_classname: Wrapper로 사용할 자바 이름을 설정합니다.
  • package: 서로 다른 프로젝트간의 이름 충돌 방지

사실 java_outer_classname 역시 여기서는 크게 의미가 있지는 않습니다. 자바 java_multiple_file을 true로 했기 때문에 실제로는 껍데기 파일 이름 정도만 적용됩니다. 

실제 빌드했을때 자동생성되는 class들은 아래와 같습니다.

사용할 api 구성

...

import "google/protobuf/Empty.proto";

service MyInfo {
  rpc GetProfile(NameInfo) returns (ProfileInfo);
  rpc SetProfile(ProfileInfo) returns (google.protobuf.Empty);
  rpc GetFriendNameList(FriendGroup) returns (stream NameInfo);
  rpc SetFriendList(stream NameInfo) returns (Result);
  rpc Chat(stream Comment) returns (stream Comment);
}

...

생성 양식은 아래와 같습니다. 

service 클래스명 {
    rpc 함수명(입력 Param) returns (출력 Param);
    rpc 함수명(입력 Param) returns (stream 출력 Param);
    ...
}

service는 클래스명을 나타내는 keyword 이고, 서버와 통신할 함수에는 rpc를 붙입니다. 또한 returns 뒤에는 반환값으로 사용할 param(class)를 붙입니다. param에 stream을 붙인다면, 계속하여 값이 넘겨진다는것을 의미합니다. 만약 입출력 param에 모두 stream이 붙는다면 채팅할때 처럼 양쪽으로 데이터를 주고 받을 수 있습니다. 채팅할때 web socket을 열어 사용하는것을 한줄로 표현할 수 있습니다.

api를 정의한 이후에는 통신에서 주고받을 param 객체를 선언합니다. 위 rpc에서 사용한 NameInfo나 ProfileInfo등은 데이터 클래스로 따로 선언이 필요합니다. 그에 앞서 많이 사용되는 Empty(리턴값이 없거나) Timestamp, Duration 등은 이미 제공되는 객체가 있으므로 import하여 사용 가능합니다.

데이터 객체의 선언은 message로 시작하며, 속성은 선언하고 차례대로 인덱스를 부여합니다.

message 객체명 {
    데이터타입 변수명 = 1; // 인덱스로 차례대로 부여
    데이터타입 변수명 = 2; // 인덱스로 차례대로 부여
    데이터타입 변수명 = 3; // 인덱스로 차례대로 부여
    ...
}
message NameInfo {
  string name = 1;
}

message ProfileInfo {
  int32 age = 1;
  double tall = 2;
  int32 sex = 3;
  string name = 4;
}

필드에 적용하는 인덱스는 1부터 시작해야 하며 2^29-1 번까지 사용할수 있습니다. 다만, protobuf가 예약해서 사용하는 19000~19999까지는 사용할수 없습니다. 사실, 여러 조건을 따지기 보다는 무조건 1번부터 시작하는게 편하겠죠?

이 인덱스는 순서를 나타내는 고유값으로 메시지가 byte 형태로 변환될때(encoding/decoding시에) string key를 대신하여 사용됩니다. server와 client가 동일한 proto file을 가지고 있기 때문에 굳이 key값 자체를 전송하기 보다는 index만 알고 있더라도 서로 ecode/decode 할 수 있습니다. 자세한 encoding 관련 내용은 링크를 확인하시면 됩니다.[7]

데이터 타입역시 여러가지를 지원하지만 kotlin 대비 proto3의 데이터 타입의 매칭되는 범위는 아래정도 입니다.

kotlin proto3
Int int32
Long int64
Float / Double double
Boolean bool
String string

RPC로 서버와 주고 받으면서 JSON에서도 enum같은 타입조차 주고 받는 일은 매우 드믑니다. (보통 위에 선언한 기본 데이터 타입정도를 사용하게 된다고 믿습니다?) 하지만 좀더 다채롭고 다양하게 쓰고 싶다면 아래 FriendGroup처럼 좀더 세분화된 데이터 타입을 확인할 수 있습니다.[8]

예제로 돌아가서 남은 데이터 클래스들은 아래와 같이 정의 합니다.

...

message FriendGroup {
  int32 total_count = 1;
  FriendType friend_type = 2;
  enum FriendType {
    MIDDLE_SCHOOL = 0;
    HIGH_SCHOOL = 1;
    UNIVERSITY = 2;
    COWORKER = 3;
    RELATIVES = 4;
  }
  bool is_favorite = 3;
}

message Result {
  bool is_success = 1;
}

message Comment {
  string text = 1;
}

위치

작성을 완료한 proto3 파일은 아래와 같이 main 아래에 java 폴더와 동일한 레벨에서 proto라는 폴더를 하나 만들고 그안에 위치시킵니다.

 그리고 나서 빌드를 돌리면 아래와 같이 데이터 클래스와 rpc 함수를 담는 클래스들이 자동으로 생성됩니다.

Proto file to Kotlin (coroutine)

위에서 생성한 rpc 함수들은 아래와 같이 변환됩니다.

//proto 3
rpc GetProfile(NameInfo) returns (ProfileInfo);
//Java
public ProfileInfo getProfile(NameInfo request)
//Kotlin
public suspend fun getProfile(request: NameInfo, 
                              headers: Metadata = Metadata()): ProfileInfo

//proto 3
rpc SetProfile(ProfileInfo) returns (google.protobuf.Empty);
//Java
public com.google.protobuf.Empty setProfile(ProfileInfo request)
//Kotlin
public suspend fun setProfile(request: ProfileInfo,
                               headers: Metadata = Metadata()): Empty

//proto 3
rpc GetFriendNameList(FriendGroup) returns (stream NameInfo);
//Java
public io.grpc.stub.StreamObserver<NameInfo> getFriendNameList(
        FriendGroup request)
//Kotlin
public fun getFriendNameList(request: FriendGroup,
        headers: Metadata = Metadata()): Flow<NameInfo>

//proto 3
rpc SetFriendList(stream NameInfo) returns (Result);
//Java
public Reulst setFriendList(
        io.grpc.stub.StreamObserver<Result> responseObserver)
//Kotlin
public suspend fun setFriendList(requests: Flow<NameInfo>,
                                 headers: Metadata = Metadata()): Result

//proto 3
rpc Chat(stream Comment) returns (stream Comment);
//Java
public io.grpc.stub.StreamObserver<Comment> chat(
        io.grpc.stub.StreamObserver<Comment> responseObserver)
//Kotlin
public fun chat(requests: Flow<Comment>,
                headers: Metadata = Metadata()): Flow<Comment>

정리하면 java에서 stream의 경우 StreamObserver로 변경되는 반면 kotlin은 suspend function으로 변경되고 flow로 대체 됩니다.

따라서 Flow로 바로 입출력을 받으므로써, 좀더 간편한 코드를 작성할수 있습니다. 해당 함수의 호출은 이제 각 domain 언어로 작성하면 됩니다. 호출하기에 앞서 통신에 사용할 채널을 생성합니다.

companion object { 
        private const val ADDRESS = "0.0.0.0"
        private const val PORT = 8080        
}
 
private var managedChannel: ManagedChannel? = null

@Synchronized
private fun getManagedChannel(): ManagedChannel? {
    if (managedChannel == null) {
        managedChannel = ManagedChannelBuilder
            .forAddress("146.56.146.184", 8080)
//          .useTransportSecurity() //HTTPS인 경우 사용
            .usePlaintext()
            .build()
    }
    return managedChannel
}

rpc 함수중에 가장 간단한 getProfile(NameInfo): ProfileInfo 를 아래와 같이 JAVA 형태로 호출할 수 있습니다.

//java 호출 방식
private fun getProfileTest() {
    getManagedChannel()?.let { channel ->
        val stub = MyInfoGrpc.newBlockingStub(channel)
        val requestNameInfo = NameInfo.newBuilder().setName("TestName").build()
        val profileResult = stub.getProfile(requestNameInfo)
        LogDebug(TAG) { "getProfileTest() - name: ${profileResult.name} | age:${profileResult.age}" }
    }
}
  1. 생성된 MyInfoGrpc 클래스에서 stub을 얻습니다.
  2. request에 param으로 넘겨줄 NameInfo 객체를 만듭니다. builder를 제공하므로 builder를 이용해서만 생성이 가능합니다.
  3. 생성된 stub을 이용하여 서버에 요청후 결과를 (ProfileInfo 객체) 받습니다.

실제 생성된 NameInfo class는 다음과 같은 형태를 갖습니다. 즉, private constructor이기 때문에 바로 생성할수 없고 반드시 builder를 이용해야 합니다.

좀더 복잡한 api인 getFriendNameList(FriendGroup): Flow<NameInfo> 함수를 Kotlin으로 호출해 보면 아래와 같습니다.

//kotlin에서의 호출
private suspend fun getFriendNameListTest() {
    getManagedChannel()?.let { channel ->
        val stub = MyInfoGrpcKt.MyInfoCoroutineStub(channel)
        val requestGroupInfo = friendGroup {
            totalCount = 10
            friendType = FriendGroup.FriendType.RELATIVES
            isFavorite = false
        }
        stub.getFriendNameList(requestGroupInfo).collect {
            LogDebug(TAG) { "getFriendNameList() - name: ${it.name}" }
        }
    }
}
  1. 생성된 MyInfoGrpcKt에서 CoroutineStub을 얻습니다.
  2. input param으로 넘겨줄 FriendGroup을 dsl로 생성합니다. (builder를 사용하지 않아도 좀더 간편하게 객체 생성이 가능합니다.)
  3. return값이 flow이므로 collect로 데이터를 처리합니다.

마지막으로 input/output param이 streamChat(Comment): Comment api의 사용 예제는 아래와 같습니다.

val _chatRequestFlow = MutableSharedFlow<Comment>()

private suspend fun chatTest(chatRequest: Flow<Comment>) = coroutineScope {
    launch { sendMessage() }

    getManagedChannel()?.let { channel ->
        val stub = MyInfoGrpcKt.MyInfoCoroutineStub(channel)
        stub.chat(chatRequest).collect {
            LogDebug(TAG) { "chatTest() - response ${it.text}" }
        }
    }
}

private suspend fun sendMessage() {
    (0..9).forEach {
        delay(500)
        val comment = comment {
            text = "hello $it"
        }
        _chatRequestFlow.emit(comment)
        LogDebug(TAG) {"sendMessage() - ${comment.text}"}
    }
}

이 함수는 입력과 출력을 모두 flow로 전달 받습니다. 저는 input 형태를 SharedFlow를 사용했습니다만 coroutine의 channel을 만들어 consumeAsFlow()로 변환하여 넘겨주는 형태의 예제들도 있습니다.

Summary

gRPC는 protobuf라는 IDL을 이용하여 Server와 Client가 동일한 정의 파일(definition file)을 가지고 생성되는 코드를 사용합니다. 따라서 통신 규격이 변경될 경우 한쪽만 변경되는 우를 범하지 않을수 있습니다. 또한 HTTP/2가 가지는 장점을 활용할 수 있고, 여러 언어 platform의 지원하는 code generator plugin을 제공하여 범용성이 높습니다. 마지막으로 (byte code를 전송하여 최적화하는) 기존 JSON 대비 몇배 빠른 전송을 지원한다는점도 큰 장점입니다.

이번 포스팅에서는 소개 및 간단한 사용법에 대한 내용을 위주로 다뤘습니다만, 다음 포스팅에 API 변경에 따른 호환성 문제, 빌드 문제, 자동생성된 코드의 문제점들에 대해서 다뤄 보도록 하겠습니다.

References

[1] https://www.youtube.com/watch?v=gnchfOojMk4

[2] https://developer.android.com/guide/topics/connectivity/grpc?hl=ko

[3] https://grpc.io/docs/platforms/android/kotlin/quickstart/

[4] https://github.com/orgs/grpc/repositories?type=all

[5]  https://github.com/grpc/grpc-kotlin/blob/master/examples/stub-android/build.gradle.kts

[6] https://developers.google.com/protocol-buffers/docs/kotlintutorial?hl=ko

[7] https://developers.google.com/protocol-buffers/docs/encoding?hl=ko

[8] https://developers.google.com/protocol-buffers/docs/proto3?hl=ko#json

[9] https://medium.com/@debduttapanda/grpc-in-android-kotlin-jetpack-compose-emulator-and-physical-device-with-local-and-remote-server-in-75934beddf02

반응형