본문 바로가기

프로젝트/대학생 커뮤니티

[성능 개선] REST 대신 gRPC를 사용해보자

gRPC

1. gRPC를 사용한 이유?

MSA를 적용하면서 내부 서비스 간 통신이 필요해지면서 REST API 방식을 사용하게 되었다.

REST API 방식은 널리 사용되고 있는 방식으로 다양한 client가 지원하고 있고

텍스트 기반의 JSON 포맷을 사용하고 있어 접근성이 좋다.

 

외부로 공개되는 API의 경우 범용성을 고려해서 http/1.1 기반의 REST API를 쓰는 것이 좋지만

서비스 내부적으로 호출되는 API의 경우 REST API를 고집할 필요는 없다.

gRPC는 http/2 기반으로 동작하는 원격 procedure를 호출하는 framework로

binary 형식의 protocol buffers 포맷을 사용한다.

gRPC는 기본적으로 http/2를 사용하기 때문에 http/1.1에 비해 성능이 좋고

binary 형식의 protocol buffers 포맷을 사용하기 때문에 데이터 크기도 작고 직렬화/역직렬화 과정에서 파싱 속도도 빠르다.

 

2. HTTP/1.1과 HTTP/2

http/2는 http/1.1에 비해 다음과 같은 이점이 있다.

 

http/1.1은 하나의 connection에서 순차적으로 요청을 처리하기 때문에 head-of-line-blocking이란 문제가 발생한다.

http/2에서는 binary frame layer가 추가되면서 위의 문제를 해결할 수 있게 되었다.

최소 전송 단위는 frame으로 요청과 응답 message는 여러 개의 frame으로 이루어지며 하나의 stream안에서 속한다.

하나의 connection안에는 여러 개의 stream이 속할 수 있어서 병렬처리가 가능하다. 이를 multiplexing이라고 한다.

 

http/1.1에서는 연속적인 요청이 발생하면 message에서 동일한 header를 반복하게 되는데

http/2에서는 중복되는 필드는 index만 전송하게 되고 HPACK이란 압축 방식으로 인코딩되도록 개선되었다.

 

3. gRPC란?

gRPC는 http/2 기반으로 동작하는 원격 procedure를 호출하는 framework이다.

 

gRPC를 사용하려면 client와 server가 공유하는 service와 message 구조를 .proto 파일에 정의해야한다.

이후 protocol buffers compiler를 사용해서 각 언어에 맞는 코드를 자동 생성한다.

gRPC server에서는 자동 생성된 service interface를 사용해서 비즈니스 로직을 구현하고

gRPC client에서는 자동 생성된 stub 코드를 사용해서 rpc 메서드를 호출한다.

 

gRPC 적용

1. gRPC 설정

build.gradle.kts에서 아래와 같은 설정을 한다.

 

plugin과 library 의존성 설정을 한다.

plugins {
    id("com.google.protobuf") version "0.9.4"
}

dependencies {
    implementation("net.devh:grpc-server-spring-boot-starter:2.15.0.RELEASE")
    implementation("net.devh:grpc-client-spring-boot-starter:2.15.0.RELEASE")
    implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
    implementation("io.grpc:grpc-protobuf:$grpcProtoVersion")
    implementation("com.google.protobuf:protobuf-kotlin:$grpcVersion")
}

 

protoc이란 protocol buffers의 compiler로 compiler를 먼저 지정한다.

java용 코드 생성 plugin과 kotlin용 코드 생성 plugin을 정의하고

compiler의 코드 변환 작업에 적용한다.

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

    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:$grpcProtoVersion"
        }
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk7@jar"
        }
    }
    
    generateProtoTasks {
        all().forEach {
            it.plugins {
                id("grpc")
                id("grpckt")
            }
            it.builtins {
                id("kotlin")
            }
        }
    }
}

 

2. Follower 조회 로직 (BlockingStub 사용)

post service에서 특정 유저의 follower를 구하기 위해 follow service로 요청을 한다.

 

.proto 파일을 아래와 같이 설정한다.

요청 message, 응답 message, service를 정의한다.

protocol buffers compiler는 이 파일을 기반으로 service interface와 stub 코드를 자동생성한다.

syntax = "proto3";

option java_multiple_files = true;
option java_outer_classname = "FollowerProto";
option java_package = "com.xircle.followservice.application.grpc.proto";

package com.xircle.followservice;

message GetFollowerRequest {
  int64 userId = 1;
}

message GetFollowerResponse {
  repeated int64 userId = 1;
}

service GetFollowerService {
  rpc GetFollowerCall (GetFollowerRequest) returns (GetFollowerResponse) {}
}

 

gRPC server역할을 하는 follow service에서 자동 생성된 abstract class인 GetFollowerServiceImplBase를 상속받아

getFollowerCall 메서드를 오버라이딩하면서 비즈로직 로직을 구현한다.

@GrpcService
class GetFollowerServiceImpl(
    private val followerReader: FollowReader
) : GetFollowerServiceGrpc.GetFollowerServiceImplBase() {
    override fun getFollowerCall(request: GetFollowerRequest, responseObserver: StreamObserver<GetFollowerResponse>) {
        val followerList = followerReader.findFollowerList(request.userId)
            .map { memberNode ->
                memberNode.id
            }

        val response = GetFollowerResponse.newBuilder()
            .addAllUserId(followerList)
            .build()

        responseObserver.onNext(response)
        responseObserver.onCompleted()
    }
}

 

gRPC client 역할을 하는 post service에서 자동 생성된 GetFollowerServiceBlockingStub 객체를 사용해서 호출한다.

@Component
class FollowReaderImpl(
    @GrpcClient("grpc-server")
    private val getFollowerServiceBlockingStub: GetFollowerServiceGrpc.GetFollowerServiceBlockingStub
) : FollowReader {
    override fun findAllFollower(followeeId: Long): List<Long> {
        val request = GetFollowerRequest.newBuilder()
            .setUserId(followeeId)
            .build()

        return getFollowerServiceBlockingStub.getFollowerCall(request).userIdList
    }
}

 

3. Profile 조회 로직 (FutureStub 사용)

gateway service에서 token을 검증하고 token에서 추출한 유저 아이디를 사용해서

user service로 부터 해당 유저의 프로필 정보를 조회하고

이후 routing되는 service로 해당 정보를 같이 보내준다.

 

.proto 파일을 아래와 같이 설정한다.

요청 message, 응답 message, service를 정의한다.

protocol buffers compiler는 이 파일을 기반으로 service interface와 stub 코드를 자동생성한다.

syntax = "proto3";

option java_multiple_files = true;
option java_outer_classname = "MemberProfileProto";
option java_package = "com.xircle.userservice.application.grpc.proto";

package com.xircle.userservice;

message GetMemberProfileInfo {
  int64 id = 1;
  string email = 2;
  string profileImage = 3;
  string nickname = 4;
}

message GetMemberProfileRequest {
  int64 userId = 1;
}

message GetMemberProfileResponse {
  GetMemberProfileInfo info = 1;
}

service GetMemberProfileService {
  rpc GetMemberProfileCall (GetMemberProfileRequest) returns (GetMemberProfileResponse) {}
}

 

gRPC server 역할을 하는 user service에서 자동 생성된 abstract class인

GetMemberProfileServiceImplBase를 상속받아

getFollowerCall 메서드를 오버라이딩하면서 비즈로직 로직을 구현한다.

@GrpcService
class GetMemberProfileServiceImpl(
    private val memberReader: MemberReader
) : GetMemberProfileServiceGrpc.GetMemberProfileServiceImplBase() {
    override fun getMemberProfileCall(
        request: GetMemberProfileRequest,
        responseObserver: StreamObserver<GetMemberProfileResponse>
    ) {
        val member = memberReader.findMemberById(request.userId)
        val memberInfo = GetMemberProfileInfo.newBuilder()
            .setProfileImage(member.profileImage)
            .setId(member.id!!)
            .setNickname(member.nickname)
            .setEmail(member.email)
            .build()

        val response = GetMemberProfileResponse.newBuilder()
            .setInfo(memberInfo)
            .build()

        responseObserver.onNext(response)
        responseObserver.onCompleted()
    }
}

 

gRPC client 역할을 하는 gateway service에서 GetMemberProfileServiceFutureStub 객체를 사용해서 호출한다.

netty 기반의 spring cloud gateway에서 blocking을 피하기위해서 future stub을 사용했다.

future stub은 ListenableFuture를 반환하므로 Mono로 변환해주는 로직을 추가했다.

@Component
class UserServiceClientAdapter(
    @GrpcClient("grpc-server")
    private val getMemberProfileServiceFutureStub: GetMemberProfileServiceGrpc.GetMemberProfileServiceFutureStub
) {
    fun getMemberInfo(memberId: Long): Mono<MemberInfo> {
        val request = GetMemberProfileRequest.newBuilder()
            .setUserId(memberId)
            .build()
        val future = reactorGetMemberProfileServiceStub.getMemberProfileCall(request)
        return future.toMono()
            .map { response ->
                val info = response.info
                MemberInfo(info.id, info.email, info.profileImage, info.nickname)
            }
    }
}
fun <T> ListenableFuture<T>.toMono(): Mono<T> =
    Mono.create { sink ->
        Futures.addCallback(this, object : FutureCallback<T> {
            override fun onSuccess(result: T) {
                sink.success()
            }
            override fun onFailure(t: Throwable) {
                sink.error(t)
            }
        }, MoreExecutors.directExecutor())
    }

 

테스트 

1. 테스트 시나리오

가상 사용자

- 100명 ~ 150명

테스트 시간

- 20분

테스트 로직

- 100명의 유저 중 랜덤하게 로그인하고 유저 검색 요청과 피드 조회 요청을 보낸다.

 

2. 모니터링 항목

RPS(초당 요청수)

- 부하 생성기인 locust를 사용하면서 수집된 지표를 사용하였다.

응답 시간

- 부하 생성기인 locust를 사용하면서 수집된 지표를 사용하였다.

내부 통신 시간

- micrometer의 Timer를 사용해서 통신 시간을 측정하고

  fetch_member_info_timer_second와 fetch_follower_timer_second라는 지표를 만들어서

  prometheus를 사용해서 수집했다.

내부 통신 payload byte 크기

- micrometer의 DistributionSummary를 사용해서 응답 payload의 byte 크기를 측정하고

  fetch_member_info_payload_bytes와 fetch_follower_payload_bytes라는 지표를 만들어서

  prometheus를 사용해서 수집했다.

 

3. 유저 검색 테스트 결과

먼저 locust에서 제공해주는 RPS와 응답 시간을 비교해봤다.

 

다음 2개의 그래프는 REST를 사용하는 방식에서의 RPS와 응답 시간을 나타낸다.

 

다음 2개의 그래프는 gRPC를 사용하는 방식에서의  RPS와 응답 시간을 나타낸다.

 

그 다음 prometheus로 수집한 통신 시간과 payload 크기를 grafana로 비교해봤다.

아래와 같은 식을 사용해서 1분마다 평균 통신시간과 평균 payload 크기를 그래프로 나타냈다.

rate(fetch_member_info_timer_second_sum[1m])/rate(fetch_member_info_timer_second_count[1m])

rate(fetch_member_info_payload_bytes_sum[1m])/rate(fetch_member_info_payload_bytes[1m])

 

다음 2개의 그래프는 REST를 사용하는 방식에서 통신 시간과 payload 크기를 측정했다.

 

다음 2개의 그래프는 gRPC를 사용하는 방식에서 통신 시간과 payload 크기를 측정했다.

 

평균적으로 개선된 수치는 아래와 같다.

  개선 전 개선 후
RPS 298 398
응답 시간(ms) 517 372
통신 시간(s) 0.449 0.146
payload 크기(bytes) 67 28

 

 

 

4. 피드 조회 테스트 결과

먼저 locust에서 제공해주는 RPS와 응답 시간을 비교해봤다.

 

다음 2개의 그래프는 REST를 사용하는 방식에서의 RPS와 응답 시간을 나타낸다.

 

다음 2개의 그래프는 gRPC를 사용하는 방식에서의 RPS와 응답 시간을 나타낸다.

 

그 다음 prometheus로 수집한 통신 시간과 payload 크기를 grafana로 비교해봤다.

아래와 같은 식을 사용해서 1분마다 평균 통신시간과 평균 payload 크기를 그래프로 나타냈다.

rate(fetch_follower_timer_second_sum[1m])/rate(fetch_follower_timer_second_count[1m])

rate(fetch_follower_payload_bytes_sum[1m])/rate(fetch_follower_payload_bytes[1m])

 

다음 2개의 그래프는 REST를 사용하는 방식에서 통신 시간과 payload 크기를 측정했다.

 

다음 2개의 그래프는 gRPC를 사용하는 방식에서 통신 시간과 payload 크기를 측정했다.

 

평균적으로 개선된 수치는 아래와 같다.

  개선 전 개선 후
RPS 69 75
응답 시간(ms) 1989 1080
통신 시간(s) 0.091 0.076
payload 크기(bytes) 291 101