20. gRPC를 알아보다

gRPC 그게 뭔데?

작년 부트캠프 지인들과 오랜만에 만났을 때 gRPC 얘기를 들은 적이 있다.
당시만 해도 뭐 그런게 있구나 싶었고 이후에도 한번 듣기는 했지만,
알아보니 이제껏 해왔던 Retrofit으로는 할 수 없는 부분이었고, 당장에 급하지 않은 부분이라 크게 신경쓰지 않았었다.

다만 회사에 오고나서 Wear Device 앱을 개발하기 위한 준비를 하던 중 양방향 통신도 필요했고, 어떻게든 조금이라도 쥐어짜내서 배터리를 아끼고 싶어졌고, gRPC가 문득 떠올라 팀원들에게 제안하게 되었다.

예시 동작 코드를 작성해서 사내 레포에 올려두는 등의 작업을 했지만, 결론적으로 MQTT로 작업하게 되었다.
그럼에도 관련해서 알게된 내용을 정리하고자 글을 남긴다.

gRPC

Google Remote Procedure Calls의 약자로 간단하게 구글이 개발한 RPC이다.

RPC는 원격 프로시저 호출이라고 하는데, 통신 세부사항을 숨기고 로컬 함수를 호출하듯이 통신하는 방법이다.

RPC가 조금 막연하게 느껴질 수 있는데 Rest API를 연결해보았다면 아마 빠르게 이해가 될 것이다.
Android환경 기준으로 API 통신을 하려면 우선 Retrofit 같은 라이브러리 등을 세팅하고,
GET, POST, PUT, DELETE, PATCH 등 메서드와 API 경로를 입력하게 될 것이다.

gRPC의 경우 최종적으로 아래와 같이 호출을 하게 된다

private val stub: GreeterServiceGrpcKt.GreeterServiceCoroutineStub =
        GreeterServiceGrpcKt.GreeterServiceCoroutineStub(channel).withInterceptors()
            .withDeadlineAfter(100, TimeUnit.SECONDS)


suspend fun fetchGreeting(userName: String): String {
    val request = HelloRequest.newBuilder()
        .setName(userName)
        .build()

    val response = stub.sayHello(request)

    return response.message
}

즉 코드만 보았을 때는 API 통신 코드라기보다 그냥 일반적인 함수들처럼 parameter가 있고, return 값이 있는 일반 함수를 호출하는 것처럼 보인다.
결론적으로 이러한 방식으로 gRPC 통신을 진행하게 된다.

왜 gRPC여야 하는가?

gRPC가 무엇인지, RPC가 무엇인지에 대해서 대략적으로 알아보았다.
다만 그래서 결론적으로 gRPC를 멀쩡하게 잘 돌아가던 것들을 두고서 왜 써야 하는거냐? 라고 한다면,
gRPCHTTP/2Protobuf를 이용하여 통신하는 RPC 시스템이라는 특징 때문이다.

HTTP

학부 수업 이후로 오랜만에 다시 간단하게 네트워크 쪽을 공부했다

간단하게 HTTP 1.0부터 HTTP/2까지의 내용을 정리해보았다.

  • HTTP 1.0

    TCP 통신을 사용한다.

    1.1, 2도 마찬가지다.

    그렇기에 흔히 얘기하는 3way-handshake 방식으로 통신을 하게 된다.

    한 번의 연결에 하나의 요청만 처리한다.

    TCP 연결 후 요청에 대한 응답을 하고 나면 해당 TCP의 연결을 종료한다.

    만약 받아야 할 데이터가 5개가 있다면 TCP 연결을 5번을 해야 한다는 이야기이다.
    매 요청마다 HandShake를 해야 하니 답답한 면이 있다.

  • HTTP 1.1

    HTTP 1.0에서 아쉬운 점을 개선 되었다.
    Keep-Alive가 생겨 한번의 TCP 연결에서 여러 번의 요청을 진행할 수 있다.

    또한 Pipelining이 도입되었다.
    Keep-Alive로 한번의 TCP 연결에 여러 요청을 보낼 수는 있었지만, 요청할 것이 A, B, C가 있을 때, A를 요청하고 응답이 오기 전까지는 BC를 요청하지 못하고 기다려야 했다
    이를 개선한 것이 Pipelining으로 요청할 것이 여러 개일 때 앞선 요청에 대한 응답이 올 때까지 기다렸다가 요청을 던지는 것이 아니라 응답과 상관없이 요청을 미리 보내두는 것이다.

    다만 이 또한 아쉬웠던 점은 결국 응답 자체는 요청받은 순서대로 전해주어야 하는 구조의 한계였다.
    이로 인해 A작업이 100초, B 작업이 0.1초가 걸리는 상황에서도 B에 대한 응답은 A가 응답 온 뒤에서야 돌아오기 때문에, 쓸데없이 응답이 지연되는 문제가 있었다.

    이를 Head of Line Blocking 이하 HOL이라고 한다.

  • HTTP 2

    HTTP 1.0에서 아쉬운 점이 개선 되었다.
    Multiplexing으로 하나의 TCP 연결에서도 여러 스트림을 통해 앞선 요청의 응답이 올 때까지 기다릴 필요 없이 응답을 보낼 수 있다.
    HPACK 압축 알고리즘으로 클라이언트와 서버가 각각 헤더 테이블을 유지하며, 중복된 값은 인덱스 번호만을 전달하는 방식을 통해서 헤더의 크기를 줄였다.
    또 다른 큰 점으로는 Server Push가 가능해졌다.

    대표적인 예시로는 웹소켓이 있다.
    웹소켓이 HTTP2라는 것 X

결론적으로 경량화된 헤더양방향 통신, 멀티 플렉싱을 통한 응답 속도 개선의 장점을 가지게 된다.

Protobuf

Protobuf는 구글에서 개발한 데이터를 직렬화하기 위한 메커니즘이다
데이터 구조를 표현할 때 XML로도 할 수 있고, 흔히 Rest API를 쓸 때처럼 Json으로 표현할 수도 있을텐에 그것들의 한 예이다.

그렇기에 Protobuf는 비단 gRPC만을 위한 것은 아니며, 언어, 플랫폼에 종속되지 않는다.
실제로 Android에서 DataStore를 만들 때 Protobuf로 구조를 정의하여 생성할 수 있다.

이를 통해서 어떤 데이터를 주고 받을 것인지 서비스와 메세지를 정의하고 이를 빌드함으로써 직렬화하는 코드가 생성된다.

여러모로 장점이 있는데 우선 Json처럼 텍스트가 아니라 Binary로 변환하기 때문에 텍스트를 parsing 하는 과정이 필요가 없으며, key 값을 그냥 미리 약속된 태그 번호로 대체하거나, 특히 보통 Int가 4byte를 차지하지만, 예를 들어 1같은 경우는 1byte로도 표현되기 때문에 1byte만 차지하는 등 용량을 줄일 수 있는 등의 장점이 있다.

만들어보기

  • 실습 코드

    • Android 코드

    • BackEnd 코드

    • Library 코드

  • 구조

    결론적으로 ProtoGrpc를 통신을 처음 구현 및 공부해보았을 때 생기는 고민거리는 Proto 파일이었다.

    기본적으로 구조가 Proto 파일 작성 -> compiler로 빌드하여 Stub 등 코드 생성하기 -> 이걸 이용하기 정도로 나눌 수 있다.

    문제는 이 구조에서 나온다

    파일을 도대체 어떻게 관리할 것인가? 라는 의문이 안떠오를 수가 없다.
    클라이언트 사이드도, 서버 사이드도 Proto파일에서 비롯된 코드를 가져와야 한다.
    같은 프로토 파일을 각자 복붙하면서 사용하는 것은 당연히 두 곳 중 한 곳이라도 변경되면 다른 한쪽에 해당 내용이 전달되어야 하고, 설상가상으로 만약 두 곳에서 동시에 내용을 수정하기 시작했을 떄는 정말 답이 없어진다.

    이를 고민하다 과거에 살짝 공부해보았던 submodule을 사용해볼까도 싶었지만, 모두가 서브모듈 관련 이해도가 어느정도 있어야 하며 최종적으로 우리는 API를 호출하기 위한 Compile된 코드만 필요하지 Proto 파일 같은 것이 소스 코드 내까지 들어올 필요는 없다는 점이 있었기에 살짝 아쉽던 와중 패키지로 라이브러리 추가하듯이 추가하여서 사용하는 것은 기타 진입 장벽이 없으며, 명확한 버전관리가 가능하다는 점 등의 장점이 있었기에 이게 딱이다라고 생각하여 이 방식을 채택했다.

  • 순수 Kotlin

    Protobuf를 작성하고 이를 바탕으로 코드를 생성하기 위해 compiler가 필요한데 보통 io.grpc 쪽의 protoc을 이용하게 된다.
    하지만 아쉬운 점은 gRPC, Protobuf 관련해서 Android 관련 문서를 보다보니 lite한 버전 등 옵션을 줄 수 있는 것을 보았고, 결론적으로 Android의 경우 Lite한 옵션으로 사용하여 가벼운 버전을 쓰는 것이 권장되는 것으로 기억한다.

    lite가 적용된 코드는 찾았는데, 관련 글을 못찾은 점 양해바라며 잘못된 부분 정정은 본 내용 포함 어떤 글, 부분에서든지 환영합니다.

    그리고 Kotlin파일이 생성되긴하지만, Java 클래스가 바탕이 되는 것 같아 그런 부분이 아쉬웠었는데, Square사의 Wire의 경우 이러한 부분이 없는 것 같았기에 이번 글을 쓰면서 시도하게 되었다 grpc grpc repository Readme

  • 라이브러리

    먼저 앞서 말한대로 각 사이드에서 사용하기 위한 빌드 파일을 만드는 Proto Library를 만든다.

    grpc

    build.gradle에 아래와 wire 플러그인을 적용한 뒤 아래와 같은 설정을 적기만 하면 세팅은 끝난다.

    alt text

    백엔드 쪽에서 사용할 standard, 안드로이드에서 사용할 android로 나누었는데, 각각의 모듈에서 비동기 처리를 위해서 필요한 coroutine이라던지, 관련된 의존성을 api로 처리하여 본 패키지를 implementation 했을 때 빌드된 코드들이 정작 본 프로젝트에서 coroutine 관련 의존성이 없다던지 등의 이유로 문제가 생기지 않도록 처리했다.

    • proto

      GreetChat(양방향)
      alt textalt text

      이런 식으로 proto 문법에 맞게 작성했고, 양방향 통신을 위해 chat의 경우 stream 키워드를 붙혔다.

  • 백엔드

    alt text

    우선 위와 같이 배포된 라이브러리를 추가하면, Proto를 빌드한 코드들이 나온 것을 확인할 수 있다.

    alt text

    sever의 경우 service의 이름 뒤에 Server라는 접미사가 붙은 클래스들이 생성되는데 이를 상속받아 로직을 구현한다.

    alt text 본 StreamChat의 경우 메세지가 올 경우 그대로 돌려주는 것과, 2초마다 랜덤값을 넣어서 보내주는 것으로 구현해보았다.

    구글에서 제공하는 Proto, gRPC 관련 라이브러리를 쓰면 괜찮았던 거 같은데, Wire의 경우 BindableServiceServer 클래스 자체에는 들어가 있지 않아 그대로 돌리면 에러가 난다.
    BindableAdapter로 만들어주어야 한다

  • 안드로이드

    alt text

    서버 쪽에서는 service 이름 뒤에 Server가 붙었던 것처럼 Client라는 접미사가 붙는데, 이를 구현하기 위해서 위처럼 기본적인 Client가 붙는다.

    이를 바탕으로 구현을 하면 아래와 같이 메서드를 호출할 수 있는데, Stream 통신의 경우 이런 식으로 executeIn을 사용하여, 송 수신 채널을 Pair로 받을 수 있다.

    다만 아쉬운 점은 Wire에서 생성된 건 componentN을 생성하지 않아 구조분해가 되지 않는다. ProtoBuf 특성상 속성의 이름이 아니라, 속성에 매겨진 번호로 구분하게 되는데, 구조 분해를 지원했을 때 Scheme가 수정되어도 구조 분해의 경우 이를 파악하기 어렵기 때문에 의도적으로 구현하지 않았다는 것 같다.

    alt text

    다만 위의 코드에서처럼 executeIn의 경우 Pair로 송수신 채널을 return하는데

    file:"ChatService.kt"
    val (sendChannel, receiveChannel) = chatServiceClient.StreamChat().executeIn(coroutineScope)
    

    이런 식으로 받을 수가 없다는 점이다.

    명시적으로 받는 것이 좋아보이지만, 본 코드에서 변수를 만들기도 애매한 거 같고, 고민하다가 그냥 let 스코프 함수로 명시적으로 이름을 설정해서 사용했는데, 이 역시 ide에서 redundant let call 관련 warning을 띄우니 이 부분이 어떻게 해야 좋은 코드일지 조금 궁금하게 되는 것 같다.

    아무튼 이를 통해 받고 구현하면 끝이다.

    wire 설정에서 android=true로 설정할 수 있는데
    26-02-10 기준으로 현재 stable한 버전이 5.5.0인데 이 버전이 AGP 9.0에 대응이 되지 않는다.
    AGP9.0이라면 6.0.0-alpha02 버전으로 사용하자
    사내 프로젝트 진행하면서 datastore-proto 만들다가 발견했다..

시연 영상

아쉬운 점

역시 gRPC하면 먼저 이야기가 나오는 부분은 디버깅의 어려움일 것 같다.
json으로 전달하는 방식이 아니기에 파악이 어려운 부분이 있다.

wire를 쓰면서 okhttp를 쓰게 되어 HttpLoggingInterceptor도 붙혀 보았는데 결국 아래와 같이 나오고, 흔히 RestAPI에서 쓸 때처럼 RequestBody, ResponseBody 같은 부분에 대한 내용을 알 수가 없었다.

Logging

두번째는 API 문서화 및 테스트다
기존의 Rest API라면 이부분은 Swagger로 전부 처리가 되는 부분인데, gRPC의 경우 Swagger를 쓸 수가 없다.
알아보았을 때 크게 3가지의 방법 정도가 있는 것으로 파악된다.

  1. gRPC UI를 설치하여 로컬에서 직접 돌려 아래와 같이 확인한다.
    grpcUI

    이 방법은 개인이 직접 gRPC UI를 돌려서 확인해야 하는 번거로움이 있다

  2. 노션 등에 명세
    이 방법은 API의 수정 등이 일어나기 시작했을 때 정보가 일관성 있게 잘 관리될 지 의문이며 테스트 불가능

  3. Postman
    Postman으로도 gRPC의 테스트가 가능하나, 문서화 측면에서는 한계 존재

  4. Armeria
    라인에서 공개한 라이브러리로, 문서화, 테스트 기능 전부 존재
    백엔드 쪽에서 Armeria 관련 지식이 있어야 하며, 오직 이것을 위해서 Armeria를 사용해도 괜찮은가? 정도의 문제

결론적으로 백엔드에서 가능만 하다면 Armeria를 쓰는 방법이 가장 좋아보였다.
다음에 gRPC를 쓸 일이 있다면 Armeria를 쓰는 방법을 제안드리지 않을까 싶다.


© 2025. Na2te All rights reserved.