본문으로 바로가기
반응형

Photo by unsplash

이전 포스팅에서 gRPC에 대한 아주 심플한 사용법에 대해서 설명했습니다. 사실 해당 내용만 가지도고 사용하는데 문제는 없습니다. 다만 실사용시 발생할 수 있는 하위 호환성에 대한 문제라던가, 기본 data type이외의 추가적인 내용과, kotlin 적용시 발생하는 문제점?? 등에 대해서 좀더 얘기해 보려 합니다.

그전에 먼저 알아두면 유용한 부분들에 대한 내용을 먼저 소개합니다. 기존에 소개했던 기본 데이터 keyword들 (e.g. rpc, returns, message) 이외에도 좀더 유연한 데이터 클래스를 만드는 keyword들을 제공 합니다.

singluar, optional, map, repeated

message의 filed 앞에 추가적인 option을 줄수 있습니다.[1]

  • singluar: proto3 문법에서는 아무런 타입이 붙지 않는 경우 singluar로 인식 합니다. 이는 field에 값을 넣을수도 있고, 넣지 않을수도 있습니다. 넣지 않으면 기본값이 사용되고, 실제 통신시에 serialize 되지 않습니다.
  • optional: singluar와 동일한 속성을 같지만 실제로 필드가 존재 하는지 아닌지를 확인할수 있는 함수를 추가적으로 제공합니다. 
  • map: map 형태의 key,value 의 데이터를 추가할수 있 습니다.
  • repeated: 반복되는 데이터의 경우(ref. JSON의 list) 정의하여 사용합니다.
//proto3 file
...
message NameInfoList {
  optional int64 option_fileld = 1;
  map<string, int32> nameWithAge = 2;
  repeated int32 name_id_list = 3;
}
...

위와 같이 다양한 타입을 갖는 NameInfoList 객체를 생성합니다. 이때 자동으로 생성된 파일은 다음과 같은 형태로 표기 됩니다.

    private suspend fun sampleTest() {
     ...
            val nameInfoList = nameInfoList {
                optionFileld = 32
                
                nameWithAge.put("둘리", 10)
                nameWithAge.put("도우너", 11)
                nameWithAge.put("또치", 11)
                
                nameIdList.add(1)
                nameIdList.add(2)
                nameIdList.add(3)
            }
            
            //optional filed이기 때문에 필드 유무 검사 함수 제공
            val isExistOptionField = nameInfoList.hasOptionFileld()        
    }

oneof

여러개중에 하나의 값만 들어온다고 할때 oneof를 사용할 수 있습니다. 만약 여러개의 값이 전부 세팅된다해도 마지막 filed 값만 전송되며, repeatedoptional 값은 사용할수 없습니다.

//proto3 file
message Comment {
  oneof text_oneof {
    string text = 1;
    string name_with_text = 2;
  }
}
// kotlin
val comment = comment {
    text = "test"
}

comment.hasNameWithText()
comment.hasText()

또한 존재하는 컬럼을 체크하는 함수역시 같이 제공됩니다.

하위 호환성 처리

Server와 Client 모두 동일한 profo file (IDL)을 사용하여 코드를 생성하므로, 규격이 틀어지거나 벗어나지 않습니다. 다만 시간이 지남에 따라 api가 삭제되거나 수정되는 경우가 발생합니다. 예를 들어 새로운 규격이 릴리즈 되었지만 아직 업데이트를 하지 않는 단말들은 이전 api 규격을 가지고 통신을 요청하게 되므로 하위 호환성에 문제가 발생할수 있습니다. 

따라서 이에 대하여 공식문서에 가이드를 제공하고 있습니다.[2] 간략하게 몇개만 정리하면 아래와 같습니다.

  • 기존 filed 번호를 변경하지 않도록 합니다.
  • 기존 message에 새로운 필드를 추가할수 있습니다. 다만 하위호환성을 위해 기본값에 신경을 써야 합니다.
  • Filed 삭제도 가능합니다. 다만 삭제된 field 번호는 재사용되지 않아야 하며, 재사용을 막으려면 reserved 키워드를 사용하여 명시적으로 막을수 있습니다.
  • int32, int64, uint32, uint64, bool 등은 서로 호환됩니다. 이처럼 호환되는 타입을 확인후 사용해야 합니다.[2]

실제 변경 case에 따른 예제를 아래와 같습니다.[3]

message field 삭제

삭제의 경우 해당 필드를 삭제하면 됩니다. 다만 message 업데이트 규칙에 따라 삭제된 filed의 index(번호)를 재사용해서는 안됩니다. 따라서 reserved 키워드를 이용하여 추후 재사용을 compile 시점에 명시적으로 막을 수 있습니다. (IDE에서 빨간줄로 에러를 나타내 줍니다.)

예약된 번호인 100번을 재사용시 에러밑줄 표시

message field 변경

//proto3 file
message Comment { 
    string text = 1;
}

위와 같은 message 객체가 있을때 "text"라는 filed 이름을 "msg"로 바꾸고 싶다면 별다른 처리없이 변경이 가능합니다. (어차피 filed의 index로 serialize되기 때문에 하위 호환성에 영향을 주지 않습니다.)

//proto3 file
message Comment { 
    string msg = 1; //text -> msg로 이름 변경
}

만약 더이상 msg라는 field를 사용하지 않고, msgId만 받기로 했다고 가정 합니다. 이때 oneof를 사용하여 코드를 변경하면 기존 버전과 최신버전을 모두 지원할 수 있습니다.

//proto3 file
message Comment { 
    oneof msg_oneof {
        string msg = 1;
        int64 msgId = 2;
    }
}

message filed의 추가

위에서 언급했던것처럼 컬럼의 추가는 아래와 같이 필드를 하나 추가하여 선언할 수 있습니다.

//proto3 file
message Comment { 
    oneof msg_oneof {
        string msg = 1;
        int64 msgId = 2;
    }
    
    //Add new filed
    int32 added_new_filed = 3;
}

추가된 필드는 신규 단말에서는 반영하여 올리겠지만 업데이트가 아직 되지 않은 구 단말의 경우, 해당 filed를 올리지 않으므로 해당 값을 읽었을때 기본값이 반환됩니다.[3]

Type 숫자 유형 문자 유형 Bool type repeated enums byte
기본값 0 빈 문자열 e.g. "" false 빈값 e.g. emptyList() 첫번째 값이며 index 값이 0인값 empty byte

따라서 위 값의 경우 실제 값이 0이 었는지, filed가 올라오지 않아 0이었는지를 구분할수 없기에 아래와 같이 기본 type을 사용하도록 합니다.

//proto3 file

import "google/protobuf/wrappers.proto";

message Comment { 
    oneof msg_oneof {
        string msg = 1;
        int64 msgId = 2;
    }
    
    //Add new filed
    google.protobuf.Int32Value added_new_filed = 3;
}

wrapper를 사용하면 실제 filed가 없이 전송된 경우 null값이 반환됩니다. 따라서 filed가 비어서 올라온건지, 아니면 0이 전송된것인지를 명확하게 구분할 수 있습니다.[5]

wrapper filed는 BoolValue, BytesValue, DoubleValue, EnumValue, FloatValue, Int32Value, Int64Value, ListValue...등이 존재하며 자세한 내용은 공식 페이지를 참조하시면 됩니다.[5]

Proto buffer의 문제점

지금까지는 proto buffer를 사용하는 장점에 대해서만 언급했습니다. 하지만 장점이 존재하면 반드시 단점이 존재하기 마련입니다. 따라서 실제 사용시 발생하는 발견된 문제점은 아래와 같습니다.

Kotlin의 부족한 지원

kotlin으로 코드 생성을 진행하지만 사실 알맹이는 Java class로 생성됩니다. xxxKt 파일이 각 데이터 클래스별로 추가되어 생성되긴 하지만 java class의 객체 생성을 위한 builder를 대체하는 dsl 관련 코드가 추가 되었을뿐 실제로는 java로 생성된 class를 return 합니다.  

실제 생성된 코드

kotlin 답게 data class 형태로 나와주기를 기대했지만 실제 자동 생성된 클래스들은 그렇지 못합니다. 이런 이유로 kotlin을 사용하기 위해서는 반드시 java 빌드 관련된 내용이 build.gradle에 포함되어야 합니다.

빌드시간의 증가

proto buffer는 proto file을 각 언어에 맞게 빌드하여 자동으로 코드를 생성해 줍니다. 따라서 코드 생성시간이 빌드속도에 영향을 미칠수 밖에 없습니다. 

Build analyzer를 이용하여 측정된 평균 빌드 시간은 아래와 같습니다. (거의 내용이 없는 샘플 프로젝트이기 때문에 전체 빌드 시간이 빠릅니다.) proto buffer file을 처리하는 경우  빌드 항목중에 generateDebugProto같은  XXXproto라는 이름의 task들이 추가됩니다. 

  • 기본 sample proto file 한개일 경우 clean build 시간 평균
  Macbook pro M1 max  / RAM: 64GB Window i5-9600K / RAM: 32GB
proto file 적용 빌드시 23.4s 48s
proto file 미적용 빌드시 22.4s 41s
증감률 4.3% 14.6%

빌드 시간이 약 4~14% 증가 함을 보입니다. 다만 기본적인 빌드시간 매우 적은 테스트 프로젝트에 적용했기에 모수가 적어 증가 시간에 대한 percentage는 더 크게 보일수는 있습니다. 또한 빌드시점마다 계속 수치는 변동되기 때문에, "build 시간에 영향을 미친다" 정도로만 가늠해 볼수 있습니다. 

만약 프로젝트의 규모가 크면 여러개의 proto file이 존재 할수 있습니다. 아래 테스트는 여러개의 proto 파일이 존재할때 빌드속도 입니다.

  • 기본 sample proto file 다섯개일 경우 clean build 시간 평균
  Macbook pro M1 max  / RAM: 64GB Window i5-9600K / RAM: 32GB
빌드 속도 26.4s 60s
증감률 13% 20% 증가

여기서의 수치 역시 오차범위에 들어갈수 있는 수준으로 정확한 percentage 보다는 "proto file이 방대해 질수록 빌드속도도 비례하여 느려진다" 정도로만 판단하는 근거로 삼으면 됩니다.

library 추가에 따른 apk 용량 변경

gRPC를 사용하기 위해서는 build.gradle에 최소 아래와 같은 라이브러리들이 import 되어야 합니다. 이에 따른 APK 용량 변화는 아래와 같습니다.[6] (minify 적용시 apk 빌드 사이즈 비교)

  • library 포함: 3,710,203KB
  • libaray 미포함: 3,449,591KB

약 254KB가 추가 됩니다. 

결론

gRPC는 byte로 데이터를 마샬링하여 전송합니다. 또한 사용하지 않는 field에 대해서는 생략하여 보내는등의 최적화 작업을 통해 데이터량을 축소 시키고 따라서 좀더 빠른 속도를 (Restful API에 비해서) 낼수 있습니다. 앞서 언급했던 JSON에 비해 5배 빠르다고는 하지만, 또 다른 test에서는 7~10배 빠르다고도 언급되어 있습니다. 환경에 따라 빠름의 정도가 있겠지만, 결론적으로 빠르다는 어느정도 보장되어 있다고 보여집니다. 실제 사용시에는 websocket없이 서버와의 connection을 유지할수 있어, 대표적으로는 채팅기능에 유리할수 있고, 대용량 데이터를 전송할때 전송하는 데이터를 페이징 해서 보내기에도 유리할것으로 보입니다. 

2016년부터 나왔던 기술이니 많큼 자료를 조사하다보니, 이미 많은곳에서 쓰이고 있고 공식석상에서도 간간히 소개되고 있는듯 합니다. 좋다 나쁘다를 떠나서 새로운 기술이고 더 빠른 기술이라고 하여 무조건적으로 적용하기 보다는 내 프로젝트에서 얼마나 효율적일지에 대해서도 고민하고 적용해야 해야 할것 같습니다.[7] kotlin conference 2019의 마지막에서 언급한것 처럼 하루에 내 서버를 이용하는 사람이 서너명이라면 gRPC의 도입이 필요하지 않습니다. 하지만 heavy한 트래픽이 발생하는 서비스라면 좀더 빛을 볼수 있을것 같네요.

Referneces

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

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

[3] https://medium.com/riiid-teamblog-kr/grpc%EB%A1%9C-api-%ED%95%98%EC%9C%84-%ED%98%B8%ED%99%98%EC%84%B1-%EB%B3%B4%EC%9E%A5%ED%95%98%EA%B8%B0-c01aa72549f5

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

[5] https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/bool-value?hl=ko 

[6] 2022.04.14 - [개발이야기/Android] - Dependency에 따른 앱용량 변경 측정 방법

[7] https://blog.dreamfactory.com/grpc-vs-rest-how-does-grpc-compare-with-traditional-rest-apis/

[8] https://www.youtube.com/watch?v=pCTLu4awGVk

 

 

 

반응형