지금까지 gRPC에 대한 소개 및 해당 기술이 가진 이점에 대해서 살펴봤습니다. 이번 포스팅에서는 kotlin 환경에서 gRPC 관련 기본 설정 셋업하는 방법에 대해서 다루어보도록 하겠습니다. 프로젝트 설정은 gradle 기반의 기본 Kotlin 빈 프로젝트는 생성되었음을 가정하고 진행하겠습니다.
1. gradle 설정 추가
build.gradle.kts
import com.google.protobuf.gradle.*
plugins {
...(중략)...
id("com.google.protobuf") version "0.8.13"
}
가장 먼저 설정할 것은 protobuf 관련 plugin을 설정하는 것입니다. 위 내용을 gradle.kts 파일 plugins 항목에 추가합니다.
build.gradle.kts
...(중략)...
val grpcVersion = "3.19.4"
val grpcKotlinVersion = "1.2.1"
val grpcProtoVersion = "1.44.1"
dependencies{
implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
implementation("io.grpc:grpc-protobuf:$grpcProtoVersion")
implementation("com.google.protobuf:protobuf-kotlin:$grpcVersion")
}
그 다음에는 protobuf 관리와 stub을 자동으로 생성해주는 라이브러리 의존성을 위와같이 추가합니다.
마지막으로 설정할 내용은 build 시점에 protobuf를 생성하기 위한 task를 추가하는 작업입니다. 해당 설정을 통해 Java Stub 파일과 Kotlin Stub파일을 생성할 수 있습니다.
기본 설정이 모두 끝났으면 gradle refresh를 통해서 설정을 마무리합니다.
2. 임시 Protobuf 생성 테스트
설정이 완료되었으면, Protobuf를 만들어보고 정상적으로 Stub 클래스가 생성되는지 확인해보도록 하겠습니다.
먼저 main 디렉토리 하위에 proto 디렉토리를 생성합니다.
생성된 proto 디렉토리 하위에 test.proto를 생성합니다.
간단한 테스트를 위해 위 내용을 기입합니다.
gradle 탭에서 build 버튼을 클릭합니다.
build가 정상적으로 완료되면, 위 그림과 같이 build 폴더가 생깁니다. 이를 확인해봅시다.
Stub 클래스가 정상 생성되었는지 확인을 위해 build > generated > source > proto > main 하위에 java와 kotlin 패키지를 열어봅니다. 만약 정상적으로 build가 완료되었으면, 위 그림과 같이 Test Stub 클래스가 생성된 것을 확인할 수 있습니다.
생성된 Stub 클래스를 프로그램내에서 정상 사용할 수 있는지 여부를 테스트하기 위해 위와 같이 별도 main 함수를 만들어 생성 가능 여부를 확인해봅니다.
만약 클래스 참조가 불가하다면, build.gradle.kts 파일에 sourceSets 내 경로가 일치하는지 확인 후 수정합니다.
지금까지 과정이 모두 정상적이라면, proto 파일을 만들고 이를 build하고 Stub 클래스를 생성 후 프로그램 참조하는 모든 과정을 가볍게 훑어볼 수 있었습니다. 테스트를 위해 사용되었던 test.proto 파일은 더 이상 필요하지 않으므로 제거해도 좋습니다.
3. 마치며
이번 포스팅은 Kotlin 기반에서 gRPC 설정 하는 방법에 대해서 알아봤습니다. 다음 포스팅부터는 본격적으로 protobuf 사용법에 대해서 알아보겠습니다.
지난 포스팅에서는 gRPC에서 사용되는 protobuf와 REST 통신에서 사용되는 JSON 크기와 Serialization/Deserialization 관점에서 성능을 비교해봤습니다. 이번에는 gRPC에서 제공하는 통신 방법에 대해서 살펴보고 REST 단건 통신과 비교하여 송/수신 시간을 비교해보겠습니다.
1. gRPC 통신 방법
gRPC는 HTTP 2.0을 기반으로 구성되어있기 때문에 Multiplexing으로 연결을 구성할 수 있습니다. 따라서 단일 Connection으로 순서의 상관없이 여러 응답을 전달받을 수 있는 Streaming 처리가 가능합니다. gRPC는 총 4가지의 통신 방법을 지원하며 그 중 3가지 방식은 Streaming 처리 방식입니다. 지금부터 하나씩 살펴보겠습니다.
2. Unary
첫 번째 방식은 Unary 통신 방식입니다.
이는 가장 단순한 서비스 형태로써 클라이언트가 단일 요청 메시지를 보내고 서버는 이에 단일 응답을 내려보내주는 방식입니다. 일반적으로 사용하는 REST API를 통해 주고 받는 Stateless 방식과 동일하다고 볼 수 있으며, 개념적으로 이해하기 쉽습니다.
그렇다면 gRPC의 Unary 통신과 REST의 성능을 비교해보면 어떤차이를 보일까요? 테스트 시나리오를 기반으로 두 통신방법을 비교해보록 하겠습니다.
1. 사용자를 등록하는 서비스가 있다고 가정한다. 2. 10, 100 등 10만까지 10의 거듭 제곱 형태로 delay없이 요청 횟수를 늘리면서 REST와 gRPC의 응답 총 시간을 구한다. 3. 테스트 시작전 warm up을 위해 50회의 요청 수행 후 테스트를 진행한다.
Protobuf는 위와 같이 디자인했습니다. message 포맷은 이전 포스팅에서 설계 내용과 동일합니다. 여기서 새로 추가된 항목은 service 부분입니다. 추가된 내용을 살펴보면 인자로 Person 타입을 입력받고 반환 값은 없으므로 Empty를 지정하였습니다.
이번 포스팅 내용은 통신 방법에 대한 설명이므로 syntax 설명은 향후 다른 포스팅 내용으로 다루겠습니다.
class PersonGrpcService : PersonServiceGrpcKt.PersonServiceCoroutineImplBase() {
override suspend fun register(request: Person): Empty {
//TODO : request 처리
return Empty.getDefaultInstance()
}
}
Proto 파일 디자인 후 Build하면 Stub 클래스가 자동 생성됩니다. 위 코드는 gRPC 서비스 처리를 구현하기 위해 Stub 클래스인 PersonServiceCoroutineImplBase을 상속받아 구현한 코드입니다. 테스트 시나리오에서는 전달받은 Person 객체를 따로 저장하거나 처리하지 않고 Empty 객체를 반환하도록 구현하였습니다.
fun main() {
val server = ServerBuilder.forPort(6565)
.addService(PersonGrpcService())
.build()
server.start()
server.awaitTermination()
}
Server 기동 시에 Service를 등록 시켜서 Client의 요청이 들어왔을 경우에 해당 Service로 Routing 하도록 설정합니다. 이후 Server를 기동합니다.
fun main() {
val channel = ManagedChannelBuilder.forAddress("localhost", 6565)
.usePlaintext()
.build()
val stub = PersonServiceGrpc.newBlockingStub(channel)
execute(stub, 50) //warm up phase
val base = 10.0
val dec = DecimalFormat("#,###")
for (exponent in 1..5) {
val iterCount = base.pow(exponent).toInt()
val time = measureTimeMillis {
execute(stub, iterCount)
println("count : ${dec.format(iterCount)}")
}
println("elapsed time $time ms")
println("------------------------------------")
}
channel.shutdown()
}
Unary 테스트를 위한 client 코드는 위와같습니다. Server를 localhost의 6565 포트에서 기동중이므로 해당 요청에 대한 Channel을 생성합니다.
이후 proto 파일 Build 과정에서 생성된 PersonServiceGrpc 내에 있는 BlockingStub 객체를 생성 해서 해당 Channel에 Binding 합니다. Channel에 Binding 한 다음에는 Stub 객체의 메소드를 호출하면 Server와 통신을 수행할 수 있습니다.
stub 객체까지 만들고 나면, 10 ~ 10만번까지 10의 거듭제곱 형태로 늘려가면서 gRPC Unary 통신을 수행 후 총 수행 시간을 출력합니다.
위 코드에서 실질적으로 gRPC를 호출하는 부분은 execute 함수입니다.
fun execute(stub: PersonServiceGrpc.PersonServiceBlockingStub, count: Int) {
repeat(IntRange(1, count).count()) {
stub.register(
person {
name = "kevin"
age = (1..50).random()
address = address {
city = "seoul"
zipCode = "123456"
}
hobbies.addAll(listOf("foot ball", "basket ball"))
}
)
}
}
execute 함수를 살펴보면 위와 같이 iteration count를 인자로 전달받고 그 횟수만큼 gRPC 요청을 보내는 것을 확인할 수 있습니다.
프로그램을 실행하면 위와 같이 Unary 요청 수행 결과를 확인할 수 있습니다.
이번에는 REST 통신을 통해 같은 횟수를 반복했을 때 Unary 통신과 비교하여 총 수행시간이 얼만큼의 차이가 있는지를 비교해보도록 하겠습니다. 이때 Unary 테스트 또한 단일 Channel에서 Blocking 방식으로 수행시간을 측정하였으므로 REST 통신 또한 같은 방법으로 테스트를 진행하겠습니다.
data class PersonDto(
val name : String,
val age : Int,
val hobbies : List<String>? = null,
val address : AddressDto? = null
)
data class AddressDto(
val city : String,
val zipCode : String
)
JSON으로 입력받을 DTO를 위와 같이 디자인합니다.
@RestController
class PersonController(private val service: PersonService) {
@PostMapping("/person")
suspend fun register(@RequestBody person : PersonDto) {
//TODO : request 처리
}
}
REST Controller 코드는 위와 같습니다. gRPC 서비스 코드에서도 인자를 전달받아 아무런 처리를 하지 않았기 때문에 마찬가지로 요청만 전달받고 아무 처리를 수행하지 않도록 구성하였습니다.
@Component
class RegisterTest : CommandLineRunner {
override fun run(vararg args: String?) {
val client = WebClient.builder()
.build()
execute(client, 50) // warm up phase
val base = 10.0
val dec = DecimalFormat("#,###")
for (exponent in 1..5) {
val iterCount = base.pow(exponent).toInt()
val time = measureTimeMillis {
execute(client, iterCount)
println("count : ${dec.format(iterCount)}")
}
println("elapsed time $time ms")
println("------------------------------------")
}
}
private fun execute(client: WebClient, count: Int) {
repeat(IntRange(1, count).count()) {
client.post().uri("localhost:8080/person")
.bodyValue(
PersonDto(
name = "kevin",
age = (1..50).random(),
address = AddressDto(city = "seoul", zipCode = "123456"),
hobbies = listOf("foot ball", "basket ball")
)
)
.retrieve()
.bodyToMono(Void::class.java)
.block()
}
}
}
Client 수행 프로그램은 위와 같습니다. gRPC 테스트 코드와 크게 다르지 않으며, 차이점이 있다면 Stub 객체를 사용한 것이 아닌 Webclient를 사용한 부분입니다.
Client 코드를 수행하면 위와 같은 결과를 얻을 수 있습니다.
횟수
REST
gRPC(Unary)
성능
10
23 ms
14 ms
1.64배
100
165 ms
101 ms
1.63배
1,000
1,000 ms
694 ms
1.44배
10,000
4,109 ms
2,132 ms
1.92배
100,000
41,491 ms
13,768 ms
3.01배
결과를 살펴보면, Iteration 횟수가 증가할 수록 그 차이가 벌어지는 것을 확인할 수 있습니다. 격차가 벌어진 이유는 다양한 이유가 있지만 Protobuf의 Serialization & Deserialization이 가장 큰 영향을 미치지 않았을까 생각합니다.
이번에는 네트워크 패킷을 통해서 REST와 gRPC의 통신 과정을 비교 해보겠습니다. 비교를 위해서 사용자 등록을 5회만 수행 후 종료한 내용을 확인해보도록 하겠습니다.
REST 통신을 5회 수행하였을 때, 네트워크 흐름을 표시하면 위 그림과 같습니다. 자세히보면 REST 통신은 HTTP 1.1을 사용한 것을 알 수 있고 SYN, ACK와 FIN, ACK가 매 요청마다 보이지 않는 것으로 보아 Connection을 매번 요청하지 않았음을 확인할 수 있습니다.
이번에는 gRPC Unary 통신 결과입니다. REST에서는 HTTP 1.1 방식이었던 것과 달리 예상대로 HTTP 2.0으로 통신을 수행한 것을 확인할 수 있습니다.
gRPC에서 Unary 통신은 HTTP 2.0 Stream으로 데이터를 전송합니다. 따라서 위 패킷 내용을 살펴보면, Stream 통신에 있어서 필요한 데이터 흐름을 파악할 수 있습니다.
가령 WINDOW_UPDATE를 통해서 Client가 수신할 수 있는 Byte 수를 Server에 알려줘 해당 정보를 기반으로 Flow control이 가능하도록 사전 설정하는 것을 확인할 수 있습니다. 또한 PING 패킷의 경우는 연결된 Channel 에서 사용중인 Connection liveness를 체크합니다. 만약 PING 단계에서 정상 응답을 수신 받지 못하면, Connection을 끊습니다. 이후 Connection 재생성을 통해 다시 연결할 수 있습니다.
이번에는 데이터 패킷을 상세하게 살펴보도록 하겠습니다.
위 그림은 요청 패킷을 구조화한 모습입니다.
Header를 살펴보면, Header의 길이 그리고 Header의 종류 flag가 보입니다. 그리고 Stream ID가 표시된 것을 볼 수 있는데, 이는 HTTP Stream 내에서 사용되는 Stream 메시지 별 Unique ID 입니다. Client에서 보내는 메시지는 Stream ID가 홀수개로 증가합니다.
Header에는 그 밖에 요청 Path 정보 및 Schema, Content-type이 표시됩니다. 내부적으로 요청은 POST로 요청되는 것을 확인할 수 있습니다.
Data 영역에는 실제 전달되는 데이터와 Flag등을 전달합니다. Unary 통신의 경우 gRPC Stream 요청은 아니므로 Flag에는 End Stream으로 지정된 것을 확인할 수 있습니다.
응답 패킷은 크게 3가지 부분으로 이루어져있습니다. 첫번째는 요청에 대한 응답헤더이고, 두 번째는 응답에 대한 데이터 마지막으로는 trailer 헤더로 구성되어있습니다.
그렇다면 위 5개의 데이터 전송 흐름에서 gRPC 패킷은 어떤 특징을 지니고 있을까요?
요청 패킷을 살펴보면, Header 길이가 최초 메시지를 보낼 때보다 크기가 줄어든 것을 확인할 수 있습니다. 또한 Stream ID는 홀수 번호로 순차 증가한 것을 확인할 수 있습니다.
마찬가지로 응답 패킷을 살펴보면, 최초 응답 헤더에 비해 이후 응답 메시지의 Header 크기가 줄어든 것을 확인할 수 있습니다.
위와 같이 gRPC는 기반에 HTTP 2.0을 기반으로 하여 메시지 전송간 데이터 Payload가 줄어드는 장점이 존재하기 때문에 이전 REST 방식에 통신에 있어서 조금 더 빠른 결과를 나타낼 수 있습니다.
3. Streaming
이번에는 Streaming 처리 방법에 대해서 살펴보도록 하겠습니다. Stream은 데이터를 한번만 전송하는 것이 아니라 연속적인 흐름으로 전달하는 것을 의미합니다.
gRPC에서는 총 3가지 종류의 Streaming이 존재합니다.
1) Client Stream
Client는 Stream 형태로 전달하고 Client의 요청이 끝나면 Server에서 한번에 응답을 내려주는 경우는 Client Stream이라고 부릅니다.
2) Server Stream
Client의 요청은 한번만 전달하고 Server에서 응답은 여러 번에 걸쳐 전송하는 경우는 Server Stream이라고 부릅니다.
3) Bidirectional Stream
양방향 모두 Stream으로 데이터를 전송하는 경우는 Bidirectional Stream 이라고 부릅니다.
Stream 처리 방법은 개념적으로 어렵지 않고 이번 포스팅에서는 사용 방법 보다는 성능 비교가 주 목적이므로 모든 Stream 방식에 대한 구현을 다루지는 않겠습니다.
Stream 처리 관련해서 다루어볼 내용은 Client Stream 방식을 활용해서 Unary, REST 방식의 테스트 시나리오를 동일하게 적용하여 어떤 차이점이 있는지를 살펴보도록 하겠습니다.
먼저 Stream 처리를 위해 서비스에 RPC를 등록합니다. 이후 Build를 수행합니다.
class PersonGrpcService : PersonServiceGrpcKt.PersonServiceCoroutineImplBase() {
...(중략)...
override suspend fun registerBatch(requests: Flow<Person>): Empty {
val start = System.currentTimeMillis()
requests
.catch {
//TODO : Error 처리
}
.onCompletion {
println("${System.currentTimeMillis() - start} ms elapsed. ")
}
.collect {
//TODO : request 처리
}
return Empty.getDefaultInstance()
}
}
Build 이후 해당 Stub 메소드 구현을 위해서 PersonServiceCoroutineImplBase Stub 클래스에서 RPC 관련 메소드를 override 합니다. 이때 Stream으로 전달받은 데이터를 기반으로 비즈니스 로직 처리는 수행하지 않기 때문에 collect 부분은 아무런 작업을 수행하지 않도록 구성했습니다.
fun main() {
val channel = ManagedChannelBuilder.forAddress("localhost", 6565)
.usePlaintext()
.build()
val stub = PersonServiceGrpcKt.PersonServiceCoroutineStub(channel)
runBlocking { execute(stub, 50) } // warm up phase
val base = 10.0
val dec = DecimalFormat("#,###")
runBlocking {
for (exponent in 1..5) {
val iterCount = base.pow(exponent).toInt()
val time = measureTimeMillis {
execute(stub, iterCount)
println("count : ${dec.format(iterCount)}")
}
println("elapsed time $time ms")
println("------------------------------------")
}
}
}
suspend fun execute(stub: PersonServiceGrpcKt.PersonServiceCoroutineStub, count: Int) {
try {
stub.registerBatch(
IntRange(1, count)
.map {
person {
name = "kevin"
age = (1..50).random()
address = address {
city = "seoul"
zipCode = "123456"
}
hobbies.addAll(listOf("foot ball", "basket ball"))
}
}
.asFlow()
)
} catch (e: StatusException) {
println(e)
}
}
Client 프로그램은 위와 같이 구성했습니다. gRPC의 Stream 처리를 구현하기 위해서 StreamObserver를 활용해서 구현하는 방식과 Kotlin의 Coroutine 방식 두 가지 방식으로 구현 가능한데, 위 코드는 Coroutine 방식으로 구현하였습니다.
내용을 살펴보면 이전 Unary 코드와 크게 다르지는 않으며, 데이터 전달시 Flow로 변환하여 전달하는 것을 확인할 수 있습니다.
코드 구현이 완료되었으면 실행 후 결과를 비교해보겠습니다.
실행 결과를 살펴보면, REST와 gRPC(Unary)와 비교했을 때 엄청난 개선이 이루어진 것을 확인할 수 있습니다.
이를 표로 나타내면 다음과 같습니다.
횟수
REST
gRPC(Unary)
gRPC(Client Stream)
10
23 ms
14 ms
9 ms
100
165 ms
101 ms
20 ms
1,000
1,000 ms
694 ms
106 ms
10,000
4,109 ms
2,132 ms
468 ms
100,000
41,491 ms
13,768 ms
2,880 ms
요청 횟수가 적을 때보다 횟수가 늘어감에 따라 차이가 더 커지는 것을 확인할 수 있습니다. 가령 10만번 데이터 전송의 경우 REST 방식보다 14.4배 Unary 방식에 비교하면 4.78배 효율이 좋은 것을 확인할 수 있습니다.
그렇다면 Stream 처리 방식은 왜 이리 많은 차이를 보이는 것일까요? 이전과 마찬가지로 패킷의 흐름을 살펴보겠습니다.
위 내용은 Stream 형식으로 Person 데이터를 50회 전송했을 때 네트워크 흐름입니다.
Unary와 REST 방식은 5회만 전송했는데도 많은 Network 요청이 있었던 것과 비교하여 50회 데이터를 전송했는데도 패킷의 횟수가 그리 많지 않습니다.
데이터 전송 부분만 살펴보면, 요청을 전달할 때 Header는 한번만 전송한 것을 확인할 수 있고, 응답 또한 한번만 전달받은 것을 확인할 수 있습니다.
그리고 데이터는 여러번 전달한 것이 아니라 한 Packet안에 여러개의 요청이 포함되어 전달된 것을 확인할 수 있습니다.
위 패킷 흐름에는 총 2번 전달하는 과정에서 50개의 요청이 담겨있는 것을 확인할 수 있습니다.
한번에 동일 요청 다수를 함께 전달할 경우, Stream 방식이 매번 요청을 수행하는 Unary 방법보다 효율적인 데이터 전송이 가능합니다. 따라서 네트워크 전달 과정에서 많은 비용을 감소하여 성능이 더욱 좋다고 볼 수 있습니다.
4. 마치며
지난 포스팅과 이번 포스팅을 통해서 gRPC의 성능 이점에 대해서 다양한 각도로 살펴봤습니다. 다음 포스팅부터는 gRPC를 사용하는 방법에 대해서 차차 알아보도록 하겠습니다.
이전 포스팅에서는 gRPC에 대한 기본적인 소개를 다루어 봤습니다. 이번에는 gRPC에서 사용하는 Protocol Buffer(aka Protobuf)와 보편적으로 사용하는 JSON 메시지 포맷에 대한 비교를 통해 어떤 부분에서 Protobuf가 이점이 있는지를 살펴보겠습니다.
1. JSON, Protobuf 변환 속도 비교
이전 포스팅에서 살펴봤듯이 REST 통신에서는 JSON 규격으로 메시지를 주고 받았고 이때 발생하는 Serialization & Deserialization 과정은 비용이 소모되는 작업임을 살펴봤습니다. 반면 gRPC에서는 binary 포맷으로 데이터를 주고받기 때문에 변환 과정에 따른 비용이 JSON에 비해서 적다고 설명했습니다.
그렇다면 실제 Protobuf 변환 과정과 JSON 변환 과정을 측정해보면 얼마나 유의미한 결과를 나타낼까요? 테스트를 통해 차이가 얼마나 발생하는지 살펴봅시다.
data class PersonDto(
val name : String,
val age : Int,
val hobbies : List<String>? = null,
val address : AddressDto? = null
)
data class AddressDto(
val city : String,
val zipCode : String
)
JSON 변환 테스트를 위해 Sample 객체를 위와 같이 디자인합니다. 위 데이터 구조는 Person이라는 객체를 생성함에 있어 이름, 나이, 주소 정보를 입력받으며 취미의 경우 다수가 존재하므로 List로 입력받도록 디자인 했습니다.
앞서 구현한 data class에 대응되는 Proto 파일은 위와 같이 구현합니다. 아직 Protobuf에 대해서 본격적으로 다루어보지 않은만큼 syntax가 이해되지 않더라도 좋습니다.
기본 Spec을 정의하였으면 이제 변환 과정 테스트 시나리오를 정의해봅시다.
1. 10, 100 ... 천만번까지 10의 거듭제곱 횟수만큼 변환 과정을 수행하면서 각 단계에서 걸린 총 시간을 측정한다.
2. 단계별 warm up 과정을 추가하고 해당 단계에서의 결과는 제외한다. 따라서 단계별 50회 변환 과정을 추가한다.
3. JSON, Proto 변환 측정 과정은 다음과 같다. - JSON : DTO를 JSON Byte 배열로 변환한 다음 해당 Byte을 다시 DTO로 변환하는데 걸린 시간 - Proto : Stub을 Byte 배열로 변환한 다음 해당 Byte 배열을 다시 Stub 객체로 변환하는데 걸린 시간
테스트 시나리오를 위해 작성한 메인 프로그램의 흐름은 위와 같습니다. 10 부터 천만번까지 각각 변환과정을 수행한 결과를 출력하도록 구성했습니다.
측정 과정은 앞서 시나리오대로 단계별 변환 횟수에 맞추어 변환 작업을 수행하며, 단계별 최초 50회는 warm up 단계로 구성하여 결과에서 제외한 총 수행시간을 반환하도록 작성했습니다.
Stub 객체를 Byte 배열로 변환하고 이를 다시 Stub 객체로 변환하는 코드는 위와 같습니다.
DTO 객체를 JSON Byte 배열로 저장한 다음 이를 다시 Person DTO 객체로 변환하는 코드는 위와 같습니다. 이 과정에서 Parser로는 Jackson을 사용했습니다.
코드 작성은 모두 마무리되었습니다. 이제 프로그램을 수행시킨 결과를 확인해봅시다.
측정 결과는 위와 같습니다. 살펴보면 변환 횟수가 증가하면서 두 방식의 변환 시간의 차가 크게 벌어지는 것을 확인할 수 있습니다. 가령 천만번 변환의 경우 7배 빠른 것으로 확인되었습니다.
그렇다면 위 측정결과를 gRPC가 REST 방식에 비해 7배 빠르다고 말할 수 있을까요?
요청에 대해서 응답을 처리하는 전체 flow를 아주 간략하게 표현한다면, 위와 같이 표현할 수 있을 것입니다. 위 과정에서 오래걸리는 영역은 당연히 Business Logic 처리를 위한 수행시간일 것입니다. 따라서 Business Logic 수행 시간이 오래 걸릴 수록 격차는 현격히 줄어들 것입니다.
하지만 TPS가 높은 시스템에서는 1ms라도 응답 속도를 줄이는 것이 중요하기 때문에 이런 경우 매우 유의미한 결과라고 볼 수 있습니다.
2. JSON, Protobuf 크기 비교
이번에는 기존에 사용했던 DTO, Stub 인스턴스를 byte 배열로 변환하였을 때 크기에 대해서 비교해보고 차이점을 통해 Protobuf의 특징을 확인해보겠습니다.
사이즈 크기 비교를 위해 작성한 프로그램은 위와 같습니다. 이전 내용과 같이 PersonDTO와 Stub 객체를 생성 후 둘 다 byte 배열로 변환한 크기를 출력하도록 구성했습니다.
실행 결과를 보면, 동일한 데이터 입력에 있어 JSON 방식과 Proto 방식간의 결과물 크기가 상당히 차이나는 것을 확인할 수 있습니다.
이러한 차이가 발생하는 이유는 Proto 메시지 정의에 따라서 Binary 데이터를 만드는 encoding 과정에서 데이터가 압축되기 때문입니다. 이와 관련하여 자세한 기술적인 내용은 아래 네이버 기술 블로그와 구글 Protocol Encoding 공식문서를 살펴보시면 도움 되실 것 같습니다.
이전에 살펴본 Person의 proto 정의는 위와 같습니다. 그리고 테스트 프로그램에서 수행한 실제 Stub 객체에는 hobbies와 address가 포함되지 않았음을 확인할 수 있습니다.
proto 파일에서 눈여겨 볼 점은 실제 Property 옆에 표시된 field 번호가 존재하는 점입니다. 가령 name에는 1이 age에는 2가 지정되어있습니다.
해당 번호는 Protobuf의 필드를 인식하게 만들어주는 Key를 구성하는 요소입니다. 참고로 이전에 첨부한 Naver 기술 블로그나 Google 공식 문서에서는 해당 Field 번호와 Wiretype가 조합된 Key를 이용하여 Encoding 및 Decoding을 수행하여 필드 값을 Parsing 함을 자세히 확인할 수 있습니다.
그렇다면 hobbies와 address가 입력되지 않았을 때 개념적으로 어떤 변화가 발생했을까요? 먼저 개념적으로 이해하기 위해 추상적으로 어떻게 표현되었는지 살펴봅시다.
protobuf에서는 field 번호가 해당 객체 내에서 필드 값을 식별하는데 있어 주요 역할을 수행합니다. 따라서 protobuf를 설계할 때 field 별로 부여하는 field 번호는 unique 해야합니다.
결과물을 살펴보면, JSON 표현 방식에 비해서 2가지 특징을 지닌 것을 확인할 수 있습니다.
1. 해당 객체 값에 값이 입력되지 않았을 경우 결과물에 포함시키지 않습니다. 따라서 JSON에 비해서 Byte 배열 크기가 줄어들 수 있습니다.
2. 실제 필드명의 길이가 어떻든 관계없이 field 번호를 기반으로 Binary 데이터가 만들어지기 때문에 payload 크기가 감소됩니다. 이는 field 명이 길어질 수록 payload 크기가 커지는 JSON과 대비하여 공간을 절약할 수 있습니다.
이번에는 패킷 수준에서 메시지 내용을 자세하게 살펴보겠습니다. 내용을 보면 방금전 설명했던 설명과 유사함을 확인할 수 있습니다.
데이터 구조를 살펴보면 Field Number와 Wire Type을 기반으로 ( (Field Number << 3) | Wire Type ) 형태로 Hex 값으로 구성되어 있습니다. 또한 모든 Field 내용이 저장되어있지 않고 사용자가 기입한 내용만 저장되어있는 것을 확인할 수 있습니다.
더 자세히 확인하기 위해 실제 Stub 객체에서 생성되는 Binary 내용을 해석해보도록 하겠습니다.
0A: name의 field 번호 1, wire type 2이므로 ( (1 << 3) | 2 ) 수행하면 10입니다. 따라서 이는 Hex 값으로 0A입니다.
0D: value의 길이를 의미합니다. 여기서 name에 저장된 값은 polar penguin 총 13자이므로 이는Hex값으로 0D입니다.
10: age의 field 번호 2, wire type 0이므로 ( (2 << 3) | 0 ) 수행하면 16입니다. 따라서 이는Hex값으로 10입니다.
14: age의 값인 20입니다. 이는 Hex값으로 14입니다.
지금까지 Proto에 저장되는 결과를 알아보기 위해 실제 저장된 Binary 구조까지 살펴봤습니다. 모든 기술이 장점이 있으면 단점이 존재하듯이 Protobuf는 결과물이 Binary 포맷이기 때문에 결과 값을 유추하기 쉽지 않은 점은 단점이라고 볼 수 있습니다. 하지만 성능이 더 중요시되는 환경에서는 짧은 Payload는 전송 속도에 있어 강점입니다.
마치며
이번 포스팅에서는 Protobuf와 JSON을 비교하여 변환 속도와 Payload 크기 차이점을 비교해봤습니다. Protobuf는 gRPC의 핵심 요소로써 gRPC가 가지는 성능 이점의 주요 부분 중 하나라고 생각합니다. 다음 포스팅에서는 HTTP 2.0 기반으로 gRPC의 통신 방법에 대해서 살펴보겠습니다.
최근 MSA가 각광받으면서 많은 회사에서 Monolithic 구조를 여러개의 마이크로 서비스로 분리하려고 시도하고 있습니다.
MSA 구성은 다양한 장점을 내포하고 있으나 그만큼 다양한 문제점 또한 상존합니다. 이 글에서는 MSA의 문제점 중 하나인 네트워크 통신 overhead에 초점을 맞추어 gRPC 기술이 어떤 부분을 해소해줄 수 있는지에 대해서 다루어보고 해당 기술은 어떻게 사용할 수 있는지에 대해서 설명해보고자 합니다.
2. 마이크로 서비스간 통신 이슈
Monolithic 구조에서는 하나의 프로그램으로 동작하기 때문에 그 안에서 구조적인 2개의 서비스간의 데이터는 공유 메모리를 통해서 주고받을 수 있습니다. 따라서 이 경우 서비스간 메시지 전송 성능은 큰 이슈가 되지 않습니다.
반면 MSA에서는 여러 모듈로 분리되어있고 동일 머신에 존재하지 않을 수 있습니다. 따라서 일반적으로는 보편화된 방식인 REST 통신을 통해 메시지를 주고 받습니다.
문제는 Frontend 요청에 대한 응답을 만들어내기 위해 여러 마이크로 서비스간의 협력이 필요하다면, 구간별 REST 통신에 따른 비효율로 인해 응답속도가 저하된다는 점입니다. 그렇다면 구체적으로 어떤 요인으로 인해 응답 속도 저하가 발생될까요? 이에 대해서 알아보기 전에 HTTP 1.1의 특징에 대해서 이해하고 HTTP 1.1의 또 다른 이슈를 확인해보도록 하겠습니다.
3. HTTP 1.1 통신 방법
HTTP는 TCP위에서 동작하므로 데이터 송수신에 앞서서 TCP 연결 시점에 3 way handshake 과정을 거치며, 연결을 종료할 때도 4 way handshake 방식으로 종료하게됩니다.
이러한 경우 만약 여러 데이터를 전송 응답을 반복해야하는 상황이라면, 매번 연결을 맺고 종료하는 과정으로 인한 비효율이 발생합니다.
앞서 살펴본 HTTP 1.0은 요청/응답을 하기에 앞서 매번 Connection을 맺고 끊어야했기 때문에 연결 요청/해제 비용이 상당히 높았습니다.
따라서 이러한 성능 이슈를 해결하고자 HTTP 1.0 기반의 브라우저와 서버에서는 자체적으로 Keep-alive 기능을 지원하기도 했습니다. 이 경우 Header에 Keep alive 관련 헤더를 포함해서 Connection을 유지하는 경우도 있었습니다. 하지만 해당 기능은 공식 Spec은 아니였습니다.
HTTP 1.1에서는 1.0의 문제점을 해결하고자 Persistent Connection과 Pipelining 기법을 제공하였습니다. 해당 기능이 무엇인지 알아봅시다.
Persistent Connection의 경우 Keep Alive와 같이 요청/응답을 위해 매번 Connection을 맺는 것이 아니라 연결을 일정시간 지속하는 것을 의미합니다.
다만 Persistent Connection만 적용했을 경우 왼쪽 그림과 같이 1개의 요청을 보내고 요청에 대한 응답이 와야 그 다음 요청을 보내기 위해 기다려야 합니다. 따라서 오른쪽과 같이 추가로 Pipelining을 적용하여 각 요청마다 응답을 기다리지 않고, 요청을 하나의 Packet에 담아 지속적으로 요청을 전달할 수 있도록 개선하였습니다.
Pipelining을 살펴보면 HTTP 1.0과 비교해서 많은 부분이 개선된 것으로 보입니다. 하지만 Pipelining에서도 성능 이슈는 존재합니다. 과연 무엇일까요?
4. HTTP 1.1 문제점
1. HOLB(Head Of Line Blocking)
Pipelining에서 요청 자체는 응답 여부와 관계없이 보낼 수 있습니다. 하지만 여전히 순차적으로 응답을 받아야합니다. 따라서 첫 번째 요청에 대한 응답이 오래걸리는 상황이라면, 두 번째 세번 째 요청 응답은 첫번째 요청이 응답처리가 완료되기 전까지 대기해야합니다. 이러한 문제를 Head Of Line Blocking(HOLB)라고 합니다.
만약 위 예시와 같이 B, C, D, E 자원의 경우 크기가 작아 빠르게 처리될 수 있다면, 사용자 응답성이 좋아질 수 있습니다. 하지만 HTTP 1.1의 경우에는 A 자원의 응답처리가 완료되지 않았기 때문에 결과적으로는 전체 응답의 대기가 발생합니다. 이는 곧 사용성이 나빠지는 원인이 됩니다.
이러한 이슈를 해소하기 위해 대개 브라우저에서는 도메인당 기본 6개(브라우저 별 상이)의 Connection을 맺어놓고 데이터를 병렬적으로 요청 및 응답을 통해서 응답성을 개선하고 있습니다.
또한 개발자 입장에서는 브라우저 특성을 활용하여 자원 다운로드 속도를 빠르게 하기 위해 여러 기법을 사용합니다. 그 중 대표적인 방법은 여러 도메인으로 데이터를 분산하여 저장하고 도메인마다 병렬적으로 Connection 맺어 빠르게 많은 자원을 다운로드하도록 개선하는 방법입니다. 이러한 기법을 도메인 샤딩(Domain Sharding)이라고 합니다.
2. Header 문제
HTTP 통신시 헤더에는 많은 메타 정보가 저장되어 있습니다. 이때 사용자가 특정 사이트를 접속하게되면 방문 시점에 다수의 HTTP 요청이 발생하게 될 것입니다. 그리고 매 요청마다 중복된 헤더 값을 전달하며, 쿠키 또한 매 정보 요청마다 포함되어 전송됩니다. 더욱이 Header 정보는 Plain text로 전달되고 이는 Binary에 비해 상대적으로 크기가 크기 때문에 전송시 많은 비효율이 발생한다고 볼 수 있습니다.
5. HTTP 2.0 등장
HTTP 2.0은 2014년에 표준안이 제안되고 15년에 공개된 프로토콜입니다. HTTP 1.x 버전의 성능 개선을 위해 Multiplexed Streams 기술을 사용합니다. 해당 기술은 이전에 살펴본 HTTP pepelining의 개선 버전으로 하나의 Connection으로 여러개의 데이터를 주고 받을 수 있도록 Stream 처리가 가능합니다.
또한 응답에 대해서 우선순위(Priority)가 주어져서 요청 순서와 관계없이 우선순위가 높을 수록 더 빨리 응답을 할 수 있는 것이 특징입니다.
세 번째 특징으로는 HTTP 1.1에서는 매 요청마다 동일한 Header 정보를 보내야하는데 반해서 HTTP 2.0 버전에서는 Header 압축을 통해서 지속적인 데이터 요청에 대한 Header 크기를 줄일 수 있습니다.
즉 HTTP 2.0을 사용하게되면 더 적은 Connection으로 더 적은 Header 크기를 전송할 수 있으며 Stream 통신으로 인해 여러 데이터를 주고 받을 수 있게 되었습니다.
그 밖에 여러 특징이 존재하며, HTTP 2.0에 대해서 더 자세한 내용은 구글 개발자 페이지를 참고하시기 바랍니다.
6. REST API 이슈
gRPC는 HTTP 2.0 기반위에서 동작하기 때문에 지금까지 HTTP 2.0의 특징에 대해서 살펴봤습니다. 짧게 정리하자면, Header 압축, Multiplexed Stream 처리 지원 등으로 인해 네트워크 비용을 많이 감소시켰습니다.
그렇다면 HTTP 2.0 특징을 제외한 gRPC만의 특징은 무엇이 있을까요? 먼저 REST API 통신의 문제점에 대해서 먼저 살펴본 다음 gRPC의 특징에 대해서 살펴보도록 하겠습니다.
1) JSON Payload 비효율
REST 구조에서는 JSON 형태로 데이터를 주고 받습니다. JSON은 데이터 구조를 쉽게 표현할 수 있으며, 사람이 읽기 좋은 표현 방식입니다. 하지만 사람이 읽기 좋은 방식이라는 의미는 머신 입장에서는 자신이 읽을 수 있는 형태로 변환이 필요하다는 것을 의미합니다.
따라서 Client와 Server간의 데이터 송수신간에 JSON 형태로 Serialization 그리고 Deserialization 과정이 수반되어야합니다. JSON 변환은 컴퓨터 CPU 및 메모리 리소스를 소모하므로 수많은 데이터를 빠르게 처리하는 과정에서는 효율이 떨어질 수 밖에 없습니다.
2) API Spec 정의 및 문서 표준화 부재
REST API를 사용할 때 가장 큰 고민은 API 개발자와 API를 사용자 간의 효율적인 커뮤니케이션 방법입니다. 가령 API가 어떻게 디자인 되었는지, 그리고 해당 속성은 어떤 값을 입력해야하는지에 대해 상호간의 이해가 필요합니다. REST를 사용한다면 이를 위해서 자체적인 문서나 Restdocs 혹은 Swagger를 통해서 API 문서를 공유합니다. 하지만 이러한 방식은 REST와 관련된 표준은 아닙니다.
두 번째 이슈는 JSON 구조는 값은 String으로 표현됩니다. 따라서 사전에 타입 제약 조건에 대한 명확한 합의가 없거나 문서를 보고 개발자가 인지하지 못한다면, Server에 전달전에 이를 검증할 수 없습니다. 가령 위 예시와 같이 Server에서 zipCode는 숫자 타입으로 처리되어야하지만 Client에서는 이에 대한 제약 없이 문자열을 포함시켜 전달할 수 있음을 의미합니다.
그렇다면 gRPC 기술은 위 두 가지 이슈를 어떻게 풀어내었을까요?
7. gRPC Protobuf
Client에서 Server측의 API를 호출하기 위해서 기존에는 어떤 Endpoint로 호출해야할 지 그리고 전달 Spec에 대해서 API 문서 작성 혹은 Client와 Server 개발자간의 커뮤니케이션을 통해 정의해야했습니다. 그리고 이는 별도의 문서 생성이나 커뮤니케이션 비용이 추가로 발생합니다.
이러한 문제를 감소시키기 위해 다양한 방법이 존재합니다. 그 중 한가지는 Server의 기능을 사용할 수 있는 전용 Library를 Client에게 제공하는 것입니다. 그러면 Client는 해당 Library에서 제공하는 Util 메소드를 활용해서 호출하면 내부적으로는 Server와 통신하여 올바른 결과를 제공할 수 있습니다. 또한 해당 방법은 Server에서 요구하는 Spec에 부합되는 데이터만 보낼 수 있게 강제화 할 수 있다는 측면에서 스키마에 대한 제약을 가할 수 있습니다.
gRPC에서는 위 그림과 같이 이와 유사한 형태인 Stub 클래스를 Client에게 제공하여 Client는 Stub을 통해서만 gRPC 서버와 통신을 수행하도록 강제화 했습니다.
Protocol Buffer는 Google이 공개한 데이터 구조로써, 특정 언어 혹은 특정 플랫폼에 종속적이지 않은 데이터 표현 방식입니다. 하지만 Protocol Buffer는 특정 언어에 속하지 않으므로 Java나 Kotlin, Golang 언어에서 직접적으로 사용할 수 없습니다.
따라서 Protocol Buffer를 언어에서 독립적으로 활용하기 위해서는 이를 기반으로 Client 혹은 Server에서 사용할 수 있는 Stub 클래스를 생성해야합니다. 이때 protoc 프로그램을 활용해서 다양한 언어에서 사용할 수 있는 Stub 클래스를 자동 생성할 수 있습니다.
만약 Server가 Java 혹은 Kotlin 기반으로 구성되어있고 Client도 Java 혹은 Kotlin이라면, 위와 같이 Stub 생성을 자동으로 해주는 Library를 활용할 수 있습니다.
위 그림은 Library를 활용해서 Build 시점에 Proto 파일을 찾고 컴파일 단계에서 이를 분석해서 Stub 클래스를 자동으로 생성된 모습입니다.
Stub 클래스를 생성하면, 해당 클래스 정보를 Server와 Client에 공유한 다음 Stub 클래스를 활용하여 서로 양방향 통신을 수행할 수 있습니다.
위 코드는 Stub 객체를 활용하여 Client에서 특정 RPC를 호출한 모습입니다. REST 방식을 활용한다면 RestTemplate 혹은 Webclient나 Retrofit2와 같은 도구 활용해서 JSON으로 데이터를 전송해야합니다. 반면 gRPC 방법에서는 위와같이 Stub 객체에 정의된 메소드 호출을 통해서 Client/Server간 데이터 송수신을 수행할 수 있어 편리합니다.
지금까지 학습한 Protocol Buffer 내용을 정리하면 다음과 같은 장점을 지닌 것을 확인할 수 있습니다.
1. 스키마 타입 제약이 가능하다
2. Protocol buffer가 API 문서를 대체할 수 있다.
위 두가지 특징은 이전에 REST에서 다룬 이슈 중 하나인 API Spec 정의 및 문서 표준화 부재의 문제를 어느정도 해소해줄 수 있습니다. 그렇다면 또 하나의 이슈인 JSON Payload 비효율 문제와 대비하여 gRPC는 어떠한 이점을 지니고 있을까요?
JSON 타입은 위와같이 사람이 읽기는 좋지만 데이터 전송 비용이 높으며, 해당 데이터 구조로 Serialization, Deserialization 하는 비용이 높음을 앞서 지적했습니다.
gRPC의 통신에서는 데이터를 송수신할 때 Binary로 데이터를 encoding 해서 보내고 이를 decoding 해서 매핑합니다. 따라서 JSON에 비해 payload 크기가 상당히 적습니다.
또한 JSON에서는 필드에 값을 입력하지 않아도 구조상에 해당 필드가 포함되어야하기 때문에 크기가 커집니다. 반면 gRPC에서는 입력된 값에 대해서만 Binary 데이터에 포함시키기 때문에 압축 효율이 JSON에 비해 상당히 좋습니다.
결론적으로 이러한 적은 데이터 크기 및 Serialization, Deserialization 과정의 적은 비용은 대규모 트래픽 환경에서 성능상 유리합니다.
8. gRPC 단점
지금까지 gRPC에서 사용되는 기반 기술에 대해서 살펴봤습니다. gRPC는 MSA 환경에서 문제점인 네트워크 지연 문제를 어느정도 해결해 줄 수 있는 기술로써 점차 많은 곳에서 도입을 진행하고 있지만 다음과 같은 문제점 또한 존재합니다.
1) 브라우저에서 gRPC를 직접 지원 안함
현재 gRPC-WEB을 사용해서 직접 브라우저에서 서버로 gRPC 통신을 수행할 수 없습니다. 따라서 Envoy와 같은 Proxy 서버를 통해 요청을 Forwarding 해야합니다.
또 다른 방법으로는 gRPC 서버와 브라우저 사이에 Aggregator 서버를 별도로 두어 Aggregator와 브라우저간에는 REST 통신을 수행하고 Aggregator와 gRPC 서버간에 gRPC 통신을 수행하는 방법을 사용해야합니다.
2) Stub 관리 비용 추가
Client와 Server는 Stub 클래스를 통해 서로 통신을 수행합니다. 하지만 요구사항 변경으로인해 Stub 클래스 변경이 필요할 때 Server에서 변경한 내용을 Client에서도 적용을 해야합니다. 이 경우 버전 차이로 인한 하위 호환성 문제가 발생할 수 있기 때문에 서비스간 Stub 관리 방법을 정의해야합니다.
가장 많이 사용하는 방법으로는 Proto 파일을 중앙에서 gitops 형식으로 관리하고 변경이 생겼을 때 이를 감지하고 언어별로 컴파일하여 Stub 클래스를 라이브러리 형태로 배포하는 방법을 많이 사용합니다.
마치며
이번 포스팅에서는 gRPC가 MSA 환경에서 왜 대두되었는지 기존의 방식과 어떠한 차이점이 있는지에 대해서 간략하게 알아봤습니다. 다음 포스팅에서는 gRPC와 REST를 다각도로 비교해보면서 gRPC가 어떠한 장점이 있는지를 분석해보겠습니다.