Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

디지안의 개발일지

[실습] Kotlin + Armeria + gRPC 사용기 - ProcolBuffer 편 본문

Kotlin

[실습] Kotlin + Armeria + gRPC 사용기 - ProcolBuffer 편

안덕기 2021. 8. 14. 02:02

들어가기 전에

이번 글의 목적은 프로토콜 버퍼와 JSON 그리고 gRPC와 HTTP를 비교하는 것이다. 

프로토콜 버퍼가 메시지를 어떻게 경량화하는지는 프로토콜 버퍼 원리을 읽기 바란다.

gRPC와 HTTP가 어떻게 다른지 알고 싶다면 gRPC vs HTTP를 확인하기 바란다.

 

테스트로 사용할 메시지 정의

사용자 목록을 조회하는 API로 비교를 해보자. 새로운 rpc와 message를 정의한다.

syntax = "proto3";

package me.dgahn.account.v1;

import "me/dgahn/account/v1/SignUpV1.proto";
import "me/dgahn/account/v1/GetProfileStreamV1.proto";
import "me/dgahn/account/v1/GetAccountAllV1.proto";
import "google/protobuf/empty.proto";

service AccountRouter {
  // 생략
  rpc getAccountAll(google.protobuf.Empty) returns (GetAccountAllResponseV1) {}
}
syntax = "proto3";

package me.dgahn.account.v1;

option java_multiple_files = true;
option java_outer_classname = "GetAccountAllV1Proto";

message GetAccountAllResponseV1 {
  repeated Account accounts = 1;
}

message Account {
  string id = 1;
  string name = 2;
  AccountRole role = 3;
}

enum AccountRole {
  ADMIN = 0;
  MEMBER = 1;
}

protubuf 모듈을 빌드하고 새로 정의한 rpc를 구현한다.

class AccountGrpcService : AccountRouterCoroutineImplBase() {

    // 생략
    
    override suspend fun getAccountAll(request: Empty): GetAccountAllResponseV1 = try {
        AccountService.getAccountAll().toProto()
    } catch (e: Exception) {
        logger.error { e.stackTraceToString() }
        throw StatusException(Status.UNKNOWN.withDescription(e.stackTraceToString()))
    }

}

유스케이스는 매우 대충 작성...

object AccountService {
    // 생략

    fun getAccountAll(): List<Account> = (1..100).map { Account(id = "test_$it", password = "123", name = "test_$it") }

}

테스트 코드를 작성하고 결과를 확인한다.

테스트 케이스 1) 프로토콜 : gRPC, Accept: ProtocolBuffer(?)

class LauncherKtTest: FunSpec({
    var start = 0L

    beforeSpec {
        newServerAndStart()
    }

    beforeEach {
        start = System.currentTimeMillis()
    }

    afterEach {
        val end = System.currentTimeMillis()
        logger.info { "메시지 받는데 걸린 시간 : ${end - start}ms"}
    }

    test("gRPC로 데이터를 받을 수 있다.") {
        val channel = NettyChannelBuilder.forAddress("localhost", 8080)
            .usePlaintext()
            .build()
        val stub = AccountRouterGrpcKt.AccountRouterCoroutineStub(channel)
        val response = stub.getAccountAll(Empty.getDefaultInstance())

        response.accountsList.size shouldBe 100
    }

})

채널을 만들고 스텁을 만들고 등등 작업을 포함해서 다음과 같은 시간이 걸린다.

00:58:34.264 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - 메시지 받는데 걸린 시간 : 749ms

테스트 케이스 2) 프로토콜: HTTP, Accept: application/json, Armeria gPRC Proxy 사용

Armeria가 제공해주는 gRPC Proxy 기능을 통해서 gRPC를 HTTP로 요청해보도록 하겠다.

test("protobuf Json 메시지로 데이터를 받을 수 있다.") {
        val url = "http://localhost:8080"
        val client = WebClient.builder(url)
            .build()
        val header = RequestHeaders.builder()
            .method(HttpMethod.POST)
            .add(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8; protocol=gRPC")
            .path("/me.dgahn.account.v1.AccountRouter/getAccountAll")
            .build()
        val actual = client
            .execute(header, Empty.getDefaultInstance().toJson())
            .aggregate()
            .join()

        logger.info { actual.contentUtf8() }

        actual.content().length() shouldBe 4198
    }

여기서 주목해야되는 점은

- http body의 크기 : 4198 byte

- Protocol Buffer 메시지를 사용했기 때문에 디폴트 값은 보내지 않는다

(프로토콜 버퍼 메시지는 변수 타입별로 디폴트 값을 가지고 있으며 해당 값은 메시지에 포함하지 않는다. ex) enum은 .proto 파일에 0번으로 정의내린 값, int는 0, String은 공백문자다)

01:02:25.724 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - {"accounts":[{"id":"test_1","name":"test_1","role":"MEMBER"},{"id":"test_2","name":"test_2"},{"id":"test_3","name":"test_3","role":"MEMBER"},{"id":"test_4","name":"test_4"},{"id":"test_5","name":"test_5","role":"MEMBER"},{"id":"test_6","name":"test_6"},{"id":"test_7","name":"test_7","role":"MEMBER"},{"id":"test_8","name":"test_8"},{"id":"test_9","name":"test_9","role":"MEMBER"},{"id":"test_10","name":"test_10"},{"id":"test_11","name":"test_11","role":"MEMBER"},{"id":"test_12","name":"test_12"},{"id":"test_13","name":"test_13","role":"MEMBER"},{"id":"test_14","name":"test_14"},{"id":"test_15","name":"test_15","role":"MEMBER"},{"id":"test_16","name":"test_16"},{"id":"test_17","name":"test_17","role":"MEMBER"},{"id":"test_18","name":"test_18"},{"id":"test_19","name":"test_19","role":"MEMBER"},{"id":"test_20","name":"test_20"},{"id":"test_21","name":"test_21","role":"MEMBER"},{"id":"test_22","name":"test_22"},{"id":"test_23","name":"test_23","role":"MEMBER"},{"id":"test_24","name":"test_24"},{"id":"test_25","name":"test_25","role":"MEMBER"},{"id":"test_26","name":"test_26"},{"id":"test_27","name":"test_27","role":"MEMBER"},{"id":"test_28","name":"test_28"},{"id":"test_29","name":"test_29","role":"MEMBER"},{"id":"test_30","name":"test_30"},{"id":"test_31","name":"test_31","role":"MEMBER"},{"id":"test_32","name":"test_32"},{"id":"test_33","name":"test_33","role":"MEMBER"},{"id":"test_34","name":"test_34"},{"id":"test_35","name":"test_35","role":"MEMBER"},{"id":"test_36","name":"test_36"},{"id":"test_37","name":"test_37","role":"MEMBER"},{"id":"test_38","name":"test_38"},{"id":"test_39","name":"test_39","role":"MEMBER"},{"id":"test_40","name":"test_40"},{"id":"test_41","name":"test_41","role":"MEMBER"},{"id":"test_42","name":"test_42"},{"id":"test_43","name":"test_43","role":"MEMBER"},{"id":"test_44","name":"test_44"},{"id":"test_45","name":"test_45","role":"MEMBER"},{"id":"test_46","name":"test_46"},{"id":"test_47","name":"test_47","role":"MEMBER"},{"id":"test_48","name":"test_48"},{"id":"test_49","name":"test_49","role":"MEMBER"},{"id":"test_50","name":"test_50"},{"id":"test_51","name":"test_51","role":"MEMBER"},{"id":"test_52","name":"test_52"},{"id":"test_53","name":"test_53","role":"MEMBER"},{"id":"test_54","name":"test_54"},{"id":"test_55","name":"test_55","role":"MEMBER"},{"id":"test_56","name":"test_56"},{"id":"test_57","name":"test_57","role":"MEMBER"},{"id":"test_58","name":"test_58"},{"id":"test_59","name":"test_59","role":"MEMBER"},{"id":"test_60","name":"test_60"},{"id":"test_61","name":"test_61","role":"MEMBER"},{"id":"test_62","name":"test_62"},{"id":"test_63","name":"test_63","role":"MEMBER"},{"id":"test_64","name":"test_64"},{"id":"test_65","name":"test_65","role":"MEMBER"},{"id":"test_66","name":"test_66"},{"id":"test_67","name":"test_67","role":"MEMBER"},{"id":"test_68","name":"test_68"},{"id":"test_69","name":"test_69","role":"MEMBER"},{"id":"test_70","name":"test_70"},{"id":"test_71","name":"test_71","role":"MEMBER"},{"id":"test_72","name":"test_72"},{"id":"test_73","name":"test_73","role":"MEMBER"},{"id":"test_74","name":"test_74"},{"id":"test_75","name":"test_75","role":"MEMBER"},{"id":"test_76","name":"test_76"},{"id":"test_77","name":"test_77","role":"MEMBER"},{"id":"test_78","name":"test_78"},{"id":"test_79","name":"test_79","role":"MEMBER"},{"id":"test_80","name":"test_80"},{"id":"test_81","name":"test_81","role":"MEMBER"},{"id":"test_82","name":"test_82"},{"id":"test_83","name":"test_83","role":"MEMBER"},{"id":"test_84","name":"test_84"},{"id":"test_85","name":"test_85","role":"MEMBER"},{"id":"test_86","name":"test_86"},{"id":"test_87","name":"test_87","role":"MEMBER"},{"id":"test_88","name":"test_88"},{"id":"test_89","name":"test_89","role":"MEMBER"},{"id":"test_90","name":"test_90"},{"id":"test_91","name":"test_91","role":"MEMBER"},{"id":"test_92","name":"test_92"},{"id":"test_93","name":"test_93","role":"MEMBER"},{"id":"test_94","name":"test_94"},{"id":"test_95","name":"test_95","role":"MEMBER"},{"id":"test_96","name":"test_96"},{"id":"test_97","name":"test_97","role":"MEMBER"},{"id":"test_98","name":"test_98"},{"id":"test_99","name":"test_99","role":"MEMBER"},{"id":"test_100","name":"test_100"}]}
01:02:25.728 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - 메시지 받는데 걸린 시간 : 506ms

 

테스트 케이스 3) 프로토콜 : HTTP, Accept: application/json

Armeria가 제공해주는 HTTP API 기능을 통해서 API를 요청해보자. 기능은 다음과 같이 간단하게 구현할 수 있다.

class AccountHttpService {

    @Get("/accounts")
    fun getAccountAll(): HttpResponse {
        val accounts = AccountService.getAccountAll()
        return HttpResponse.of(MediaType.JSON, accounts.toJson())
    }

}
private fun ServerBuilder.setHttpService() = this.apply {
    annotatedService(AccountHttpService())
}

테스트 코드를 작성하고 실행해보자.  

    test("http로 데이터를 받을 수 있다.") {
        val url = "http://localhost:8080"
        val client = WebClient.builder(url)
            .build()
        val header = RequestHeaders.builder()
            .method(HttpMethod.GET)
            .add(HttpHeaderNames.CONTENT_TYPE, "application/json")
            .path("/accounts")
            .build()
        val actual = client
            .execute(header)
            .aggregate()
            .join()
        logger.info { actual.contentUtf8() }

        actual.content().length() shouldBe 6203
    }

JSON 함수를 너무 대충 만들어서 정렬이 이상하지만 http body가 커진 것을 알 수 있다. 하지만 요청하는 시간은 gRPC 보다 시간이 더 짧았다. 내 생각에는 gRPC를 그대로 사용하는 것이 아니라 Armeria Proxy 서버를 거쳐서 사용하기 때문에 작업이 더 소요되는 것이 아닌가 싶다.

01:27:18.228 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - {
 "accounts" : [{	"id": "test_1", 
	"name": "test_1", 
	"role": "ADMIN" 
},
// 생략
01:27:18.233 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - 메시지 받는데 걸린 시간 : 450ms

테스트 케이스 4) 프로토콜 : HTTP, Accept: null (byte)

속도가 오히려 HTTP가 빨랐고 메시지를 protobuf가 이론적으로 더 빠를 것이다. 그래서 더 빠르고 경량화할 수 있도록 구성을 해봤다.

class AccountHttpService {

    // 생략

    @Get("/v2/accounts")
    fun getAccountAllV2(): HttpResponse {
        val accounts = AccountService.getAccountAll().toProto()
        return HttpResponse.of(ResponseHeaders.of(HttpStatus.OK), HttpData.wrap(accounts.toByteArray()))
    }

}
    test("http + protobuf로 데이터를 받을 수 있다.") {
        val url = "http://localhost:8080"
        val client = WebClient.builder(url)
            .build()
        val header = RequestHeaders.builder()
            .method(HttpMethod.GET)
            .path("/v2/accounts")
            .build()
        val actual = client
            .execute(header)
            .aggregate()
            .join()
        val response = GetAccountAllResponseV1.parseFrom(actual.content().array())
        logger.info { response.toJson() }
        actual.content().length() shouldBe 2084
    }

 

01:53:12.310 [pool-2-thread-1] INFO me.dgahn.LauncherKtTest - 메시지 받는데 걸린 시간 : 510ms

 

마무리

결과적으로 Armeria와 gRPC를 같이 사용하면 마지막 케이스가 제일 성능이 좋았다. 하지만 하나의 요청에 대해서만 테스트를 한 것이기 때문에 스트레스 테스트를 진행한다면 결과가 달라질 수도 있다. 그리고 MSA 환경에서 속도, 용량 뿐만 아니라 인터페이스를 관리하는 측면에서는 확실히 gRPC가 편리하기 때문에 해당 부분도 고려해서 어떤 것을 선택할지는 개발자의 선택으로 보인다.