개발/Android

안드로이드 gRPC 개념 및 사용법

귀염둥이 팡무 2022. 10. 12. 16:04

gRPC란?

gRPC는 Google에서 개발한 RPC(Remote Procedure Call) 시스템으로 TCP/IP + HTTP 2.0 프로토콜을 사용하며 IDL(Interface Definition Language)을 통해 protocol buffer로 통신합니다.

 

gRPC에서는 클라이언트 애플리케이션이 마치 로컬 객체인 것 처럼 다른 서버의 함수를 직접 호출할 수 있습니다.


많은 RPC 시스템처럼 gRPC는 서비스 정의에 기반을 두고 있으며, 직접 파라이터와 리턴 타입을 사용하여 원격으로 호출할 함수를 지정합니다. 클라이언트 측에서는 서버와 동일한 함수를 제공하는 stub(일부 언어에서는 클라이언트)을 지니고 있습니다.

프로토콜 버퍼(Protocol Buffers)

기본적으로 gRPC는 구글에서 오픈 소스로 공개한 구조화된 데이터를 직렬화 하기위한 프로토콜 버퍼를 사용합니다.

안드로이드에서 proto datastore 사용 시 사용하는 그 프로토 입니다 🙂

프로토콜 버퍼는 직렬할 데이터의 구조를 proto file에 정의하며 보통 .proto 확장자를 사용합니다.
프로토콜 버퍼 데이터는 메시지로 구성되며, 각 메시지는 name-value 쌍을 포함합니다.

message Person {
  string name = 1;
  int32 id = 2;
  bool has_ponycopter = 3;
}

그런 다음 데이터 구조를 지정하면, 프로토콜 버퍼 컴파일러인 protoc 로 데이터 접근 클래스를 proto에서 정의에서 원하는 언어로 생성합니다.

 

이 클래스는 name()set_name() 같은 간단한 필드에 대한 간단한 접근자뿐만 아니라 raw bytes에서 전체 구조를 직렬화/파싱을 제공합니다.

예를 들어 언어를 C++을 선택 하였다면, 위의 예시 코드를 컴파일러로 실행하면 Person이라는 클래스가 생성됩니다.
그런 다음 애플리케이션에서 이 클래스를 사용하여 Person 프로토콜 버퍼 메시지를 채우고, 직렬화하고 검색할 수 있습니다.

 

프로토콜 버퍼 메시지로 지정된 RPC 함수 파라미터와 리턴 타입을 사용하여 일반 proto 파일에 gRPC 서비스를 정의합니다.

// 인사 서비스 정의
service Greeter {
  // 인사 보내기
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// 사용자 이름이 포함된 요청 메시지
message HelloRequest {
  string name = 1;
}

// 인사가 포함된 응답 메시지
message HelloReply {
  string message = 1;
}

gRPC는 protoc와 함께 gRPC 플러그인을 사용하여 프로토 파일에서 코드를 생성합니다. 생성된 gRPC 클라이언트와 서버 코드뿐만 아니라 메시지 타입을 채우고 직렬화하고 검색하기 위한 정규 프로토콜 버퍼를 제공합니다.

 

이제 본격적으로 시작해봅시다.

서버

gRPC 관련 서버 부분도 함께 강의하려 하였으나, 기존 gRPC example 소스 코드에서 서버 분리에 실패하여(독립적인 프로젝트) 해당 내용은 스킵하였습니다.

 

다만 기본적인 안드로이드와 유사한 사용법을 지니고 있기에 안드로이드 내용을 이해하셨다면 문제 없이 서버 구축이 가능할 것 입니다.

 

공식 gRPC 소스 코드는 다음과 같습니다.

https://github.com/grpc/grpc-kotlin

안드로이드 클라이언트

gradle 설정

proto와 gRPC를 사용할 수 있도록 gradle 설정을 진행하겠습니다.

project 수준 build.gradle

우선 project 수준의 build.gradle에 아래의 ext를 추가합니다.
이는 버전 관리를 통일성 있게 관리하기 위함이며, 스킵 후 implementation 에서 수동으로 기입하셔도 문제 없습니다.

ext {
    grpcVersion = "1.47.0"
    grpcKotlinVersion = "1.3.0"
    protobufVersion = "3.21.2"
    coroutinesVersion = "1.6.1"
}

이후 플러그인에 protobuf를 추가합니다.

plugins {
  id "com.google.protobuf"  version "0.8.17" apply false
}

app 수준 build.gradle

이후 app 수준의 build.gradle에서 아래의 설정들을 진행합니다.

먼저 동일하게 플러그인에 protobuf를 지정합니다.

plugins {
  id "com.google.protobuf"
}

그리고 gRPC와 protobuf dependency를 추가합니다.

dependencies {
  runtimeOnly "io.grpc:grpc-okhttp:$grpcVersion"
  implementation "io.grpc:grpc-api:$grpcVersion"
  implementation "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion"
  implementation "io.grpc:grpc-kotlin-stub:$grpcKotlinVersion"
  implementation "io.grpc:grpc-stub:$grpcVersion"
  implementation "io.grpc:grpc-protobuf-lite:$grpcVersion"
  implementation "io.grpc:grpc-kotlin-stub:$grpcKotlinVersion"
  implementation "com.google.protobuf:protobuf-kotlin-lite:$protobufVersion"
}

마지막으로 proto를 생성할 수 있도록 protobuf 로직을 작성하면 설정이 완료됩니다.

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:$protobufVersion"
    }

    plugins {
        java {
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
        }

        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
        }

        grpckt {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk8@jar"
        }
    }

    generateProtoTasks {
        all().each { task ->
            task.plugins {
                java {
                    option 'lite'
                }

                grpc {
                    option 'lite'
                }

                grpckt {
                    option 'lite'
                }
            }
            task.builtins {
                kotlin {
                    option 'lite'
                }
            }
        }
    }
}

이때 중요한 사실 한가지가 있습니다. 아직 gRPC 패키지에 ARM64용 바이너리가 릴리즈되지 않아 M1 맥에서는 해당 protoc를 찾을 수 없다는 오류가 발생합니다.

 

그렇기에 ARM64용 바이너리가 아닌 AMD64용 바이너리를 받아올 수 있도록 각 artifact에 osx-x86_64를 추가해야합니다. 그러나 M1에 맞추면 인텔이, 인텔에 맞추면 M1이 지원되지 않기에 M1에서만 다른 바이너리를 적용할 수 있도록 M1인 경우에만 전역 Gradle 설정인 gradle.properties에서 분기를 할 수 있도록 아래의 property를 추가합니다.

Intel 맥에서는 작업할 필요가 없으며 M1 맥에만 해당 property를 추가하면 됩니다.

protoc_platform = osx-x86_64

이후 다시 app build.gradle에 돌아와 artifact 부분을 분기 처리하면 gradle 설정 완료입니다.

...
protoc {
    if (project.hasProperty('protoc_platform'))
        artifact = "com.google.protobuf:protoc:$protobufVersion:$protoc_platform"
    else
        artifact = "com.google.protobuf:protoc:$protobufVersion"
}

plugins {
    java {
        if (project.hasProperty('protoc_platform'))
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion:$protoc_platform"
        else
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
    }

    grpc {
        if (project.hasProperty('protoc_platform'))
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion:$protoc_platform"
        else
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
    }

    grpckt {
        artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk8@jar"
    }
}
...

proto 파일 생성

gRPC와 proto 의존성까지 모두 연결하셨다면 이제 프로토 파일을 만들 차례입니다.

 

기존 패키지에 proto 패키지를 새로 생성합니다. 기본 경로는 app/src/main/proto 입니다.
그리고 hello_world.proto 파일을 만들고 아래의 코드를 추가합니다.

syntax = "proto3"; // proto 버전

option java_multiple_files = true; // 여러 파일을 만들지 여부
option java_package = "com.autocrypt.helloworld"; // 패키지 이름
option java_outer_classname = "HelloWorldProto"; // 클래스 이름

package helloworld;

// 인사 서비스 정의
service Greeter {
  // 인사 보내기
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  // HelloRequest를 전송하면 HelloReply를 응답하겠다!
}

// 사용자 이름이 포함된 요청 메시지
message HelloRequest {
  string name = 1;
}

// 인사가 포함된 응답 메시지
message HelloReply {
  string message = 1;
}

그리고 Build를 한번 해주게 되면 지정한 java_package 경로에 proto 파일이 생성됩니다.

gRPC 연동하기

위의 작업들은 모두 완료하셨다면 마지막으로 gRPC를 통해 통신하는 방법에 대해 설명하겠습니다.

우선 GreeterRCP 클래스를 만들고 Closeable를 상속받습니다. 여기서 uri를 받도록 설계하였는데, uri가 아닌 host: String, port: Int를 받도록 하여도 상관없습니다.

class GreeterRCP(uri: Uri) : Closeable {
    override fun close() {
        TODO("Not yet implemented")
    }
}

그 다음 gRPC에 연결할 채널을 만듭니다.

private val channel = let {
    val builder = ManagedChannelBuilder.forAddress(uri.host, uri.port)
    if (uri.scheme == "https") { // https인 경우 보안 적용
        builder.useTransportSecurity()
    } else { // 아니면 평문 사용
        builder.usePlaintext()
    }
    builder.executor(Dispatchers.IO.asExecutor()).build()
}

이후 채널을 사용할 수 있도록 Stub를 만들어 줍니다.

GreeterCoroutineStub 클래스가 존재하지 않는다면 proto 빌드가 되지 않은 것 입니다.
Gradle 설정이 잘되었는지, proto를 빌드하였는지 확인해주세요.

private val greeter = GreeterGrpcKt.GreeterCoroutineStub(channel)

Stub까지 만들었다면 이제 실제로 서버에 요청하도록 연결합니다.

suspend fun sayHello(name: String) {
    kotlin.runCatching {
        val request = helloRequest { this.name = name }
        greeter.sayHello(request)
    }.onSuccess { response -> 
        print(response.message)
    }.onFailure { e ->
        e.printStackTrace()
    }
}

마지막으로 close함수에 channel을 shutdown 할 수 있도록 추가해주면 완료입니다.

override fun close() {
    channel.shutdownNow()
}

최종 코드는 다음과 같습니다.

class GreeterRCP(uri: Uri) : Closeable {
    private val channel = let {
        val builder = ManagedChannelBuilder.forAddress(uri.host, uri.port)
        if (uri.scheme == "https") {
            builder.useTransportSecurity()
        } else {
            builder.usePlaintext()
        }
        builder.executor(Dispatchers.IO.asExecutor()).build()
    }

    private val greeter = GreeterGrpcKt.GreeterCoroutineStub(channel)

    suspend fun sayHello(name: String) {
        kotlin.runCatching {
            val request = helloRequest { this.name = name }
            greeter.sayHello(request)
        }.onSuccess { response ->
            print(response.message)
        }.onFailure { e ->
            e.printStackTrace()
        }
    }

    override fun close() {
        channel.shutdownNow()
    }
}

이제 사용할 곳에서 GreeterRCP를 만들고 sayHello()를 호출하시면 응답받는 것을 확인하실 수 있습니다.

서버의 로그는 다음과 같이 기록하게 해두었습니다.