팀 이야기

[스프링팀] gRPC

MASHUP 2024. 10. 15. 21:42

gRPC란 무엇인가요?

gRPC는 Google에서 개발한 원격 프로시저 호출(Remote Procedure Call, RPC) 시스템입니다. 간단히 말해, gRPC는 서버와 클라이언트 간의 통신을 쉽게 할 수 있게 도와주는 프레임워크입니다.

클라이언트가 서버에게 데이터를 요청할 때, 마치 서버 안에 있는 함수나 메서드를 직접 호출하는 것처럼 요청을 보낼 수 있습니다. 실제로는 네트워크를 통해 요청이 전달되지만, gRPC를 사용하면 서버에 메서드를 호출하는 것처럼 프로그래밍할 수 있습니다.

gRPC는 HTTP/2 프로토콜을 기반으로 하여 고성능, 저지연 네트워크 통신을 지원합니다. HTTP/2는 기존의 HTTP/1.1과 비교하여 멀티플렉싱(Multiplexing), 헤더 압축(Header Compression) 등의 최적화 기능을 도입함으로써 대역폭 효율성을 극대화하고 전송 지연(Latency)을 최소화합니다.

gRPC에서 지원하는 통신 방식

  1. Unary RPC
    클라이언트가 하나의 요청을 보내고 서버는 하나의 응답을 반환하는 가장 일반적인 형태의 통신 방식입니다.
  2. Server Streaming RPC
    클라이언트가 하나의 요청을 보내면 서버는 여러 개의 응답을 스트리밍으로 보냅니다.
  3. Client Streaming RPC
    클라이언트가 여러 요청을 스트리밍 방식으로 보내고 서버는 하나의 응답을 반환합니다.
  4. Bidirectional Streaming RPC
    클라이언트와 서버가 양방향으로 여러 요청과 응답을 스트리밍 방식으로 주고받을 수 있습니다.

Protocol Buffers(Protobuf)란 무엇인가요?

Protocol Buffers(Protobuf)는 gRPC에서 사용하는 데이터 직렬화 방식으로, 데이터를 작고 빠르게 전송하기 위해 바이너리 형식으로 압축하여 전달합니다. JSON이나 XML처럼 사람이 읽기 쉬운 텍스트 형식과는 달리, Protobuf는 더 작은 파일 크기와 더 빠른 처리 속도를 제공하여 네트워크 효율성을 극대화합니다.

Protobuf가 더 빠른 이유는 데이터를 바이너리 형식으로 직렬화하여 전송량을 줄이고, 파싱 오버헤드를 최소화하며, 작고 효율적인 데이터 구조 덕분에 더 빠른 처리 속도를 제공하기 때문입니다. Protobuf는 텍스트 기반의 JSON이나 XML과 달리 태그나 공백 같은 불필요한 데이터를 포함하지 않으며, 숫자로 정의된 필드 번호를 사용해 데이터를 효율적으로 구분합니다. 또한, 메타데이터 오버헤드를 줄여 데이터가 더 컴팩트해지며, 사전 정의된 데이터 구조로 인해 파싱 과정이 단순화되어 빠른 데이터 접근이 가능합니다.

IDL(Interface Definition Language)이란 무엇인가요?

IDL(인터페이스 정의 언어)은 클라이언트와 서버가 어떤 데이터 구조로 통신할지, 어떤 서비스를 제공할지 미리 정의해주는 언어입니다. gRPC에서는 Protocol Buffers(proto 파일)를 IDL로 사용합니다.

proto 파일에서, 클라이언트와 서버가 통신하는 규칙을 정의하면, 이 파일을 바탕으로 코드를 생성할 수 있습니다. 즉, 서버와 클라이언트가 어떤 형식의 데이터를 주고받을지 사전에 약속을 정해두는 것이라고 이해할 수 있습니다.

Protocol Buffers (proto) 예시

Protocol Buffers 파일(.proto 파일)은 gRPC에서 서비스의 인터페이스와 데이터 구조를 정의하는 파일입니다. 예시로, 아래는 간단한 gRPC 서비스와 데이터 구조를 정의한 .proto 파일입니다.

syntax = "proto3";

service UserService {
  rpc GetUser (UserRequest) returns (UserResponse);
}

message UserRequest {
  int32 user_id = 1;
}

message UserResponse {
  string name = 1;
  string email = 2;
}
  • service UserService:
    • UserService라는 이름의 gRPC 서비스를 정의합니다.
  • rpc GetUser (UserRequest) returns (UserResponse):
    • GetUser라는 Unary RPC 메서드를 정의합니다. 클라이언트가 UserRequest 메시지를 서버에 보내면, 서버는 UserResponse 메시지로 응답을 반환합니다.
  • message UserRequest:
    • 클라이언트가 서버로 보낼 요청 데이터 구조입니다. 여기에는 user_id라는 필드가 포함되어 있습니다.
  • message UserResponse:
    • 서버가 클라이언트에게 보낼 응답 데이터 구조입니다. 여기에는 name과 email이라는 필드가 포함됩니다.

JSON과 gRPC 비교 성능 테스트

성능 테스트

nGrinder 30대 → java-service(JSON / gRPC) c5.large, 12대

JSON

JSON
Vusers
TPS
Time
JAVA CPU
600
29,116.2
19.94 ms
80% 후반
900
30,800.0
28.46 ms
80% 후반
gRPC
Vusers
TPS
Time
JAVA CPU
600
59,373.2
10.07 ms
80% 중반
900
62,308.7
14.40 ms
80% 후반

nGrinder 30대php-service( c5.4xlarge, 13대java-service(JSON / gRPC) c5.large, 12대

JSON
Vusers
TPS
Time
PHP CPU
JAVA CPU
600
4,761.5
117.22 ms
30% 초반
20% 중반
900
5,928.3
137.21 ms
30% 초반
20% 중반
gRPC
Vusers
TPS
Time
PHP CPU
JAVA CPU
600
14,588.4
40.27 ms
70% 초반
30% 중반
900
15,497.2
56.9 ms
80% 초반
30% 중반

커넥션 재사용 테스트

nGrinder → java-service(gRPC)

gRPC
비율
ActiveConnection 82.94% 🔺
NewConnection 17.06% 🔻

 

nGrinder → php-service → java-service(gRPC)

gRPC
비율
ActiveConnection 78.82% 🔺
NewConnection 21.98% 🔻
gRPC(force_new)
비율
ActiveConnection 0.74% 🔻
NewConnection 99.26% 🔺
JSON
비율
ActiveConnection 0.31% 🔻
NewConnection 99.69% 🔺

 

테스트 결과

  1. TPS와 CPU 사용률
  • gRPC는 동일한 CPU 사용률(80%대)에서 JSON 대비 약 2배 이상의 TPS를 처리할 수 있습니다.
  • 예: 600 Vusers 기준으로 gRPC는 59,373 TPS, JSON은 29,116 TPS를 기록했습니다.
  1. 응답 시간
  • gRPC의 응답 시간은 JSON보다 절반 이하로 짧습니다.
  • 예: 600 Vusers 기준으로 gRPC는 10.07ms, JSON은 19.94ms입니다.
  1. 커넥션 재사용
  • gRPC는 활성 커넥션의 82.94%를 재사용하며, 새 연결 생성이 매우 적습니다.
  • JSON은 커넥션 재사용률이 거의 없고, 요청마다 새 연결을 99.69% 생성합니다.

결론: gRPC는 TPS 처리량과 응답 시간에서 JSON보다 훨씬 우수하며, 커넥션 재사용 측면에서도 더 효율적입니다.

테스트 결과 분석

PHP + JSON 병목 지점

  1. 직렬화/역직렬화의 오버헤드
  • JSON은 텍스트 기반으로, 직렬화 및 역직렬화에 상대적으로 많은 시간이 소요됩니다. 이는 JSON을 처리할 때, 네트워크 오버헤드나 데이터 전송 지연을 발생시키며, TPS를 저하시킬 수 있습니다.
  1. 네트워크 대역폭 병목
  • JSON은 데이터가 커지고, 이를 전송하는 데 더 많은 대역폭이 필요합니다. 특히 트래픽이 증가하면 네트워크 전송 속도가 저하되고 병목 현상이 발생할 수 있습니다.
  • gRPCJSON의 요청당 크기를 비교했을 때, JSON 요청이 약 1278 bytes이고, gRPC 요청이 약 866 bytes로, 약 47% 차이가 있습니다.
  1. 커넥션 재생성으로 인한 병목
  • HTTP/1.1 Keep-Alive가 활성화되어 있어도, 요청이 순차적으로 처리되기 때문에 동시 요청을 처리하지 못합니다. 이로인해 동시 요청이 많은 경우, 대기 중인 요청을 처리하기 위해 커넥션이 자주 생성되며, 커넥션 재사용율이 매우 낮습니다. 실제로 JSON 서버와 통신할 때 커넥션 재사용율이 1% 이하로 나타났습니다.
  • 커넥션을 매번 새로 생성하거나 직렬적으로 처리함에 따라 네트워크 레이턴시가 증가하고, 네트워크 연결 설정과 해제의 반복이 성능 병목을 일으킵니다.

PHP는 프로세스 기반인데, 커넥션 재사용이 가능할까요?

PHP-FPM과 프로세스 재사용

  • PHP-FPM은 PHP의 요청 처리 방식을 최적화하기 위해 FastCGI 프로토콜을 사용합니다.
  • 중요한 점은 새로운 요청마다 프로세스를 생성하는 것이 아니라, 기존 프로세스를 재사용한다는 것입니다.
  • 따라서, 동일한 프로세스가 여러 번의 요청을 처리할 수 있습니다.

PHP-FPM can reuse worker processes repeatedly instead of having to create and terminate them for every single PHP request. Although the cost of starting and terminating a new web server process for each request is relatively small, the overall expense quickly increases as the web server begins to handle increasing amounts of traffic. PHP-FPM can serve more traffic than traditional PHP handlers while creating greater resource efficiency.

PHP에서 gRPC 커넥션 재사용

  • gRPC 채널은 서버와 클라이언트가 연결되는 통신 통로입니다. gRPC는 이러한 채널을 통해 클라이언트가 서버에 요청을 보냅니다.
  • gRPC는 효율성을 위해 채널을 재사용할 수 있는 메커니즘을 갖추고 있습니다. 새로 생성하는 대신, 기존에 생성된 채널을 재사용하는 방식입니다.

gRPC 커넥션 재사용 방법

  1. Persistent List에 저장된 채널 재사용 (Persistent Channel)
  • PHP는 gRPC 채널을 처음 생성할 때, 이 채널을 Persistent List에 저장합니다.
  • 다음 요청에서 동일한 채널이 필요하면, 새로 생성하는 대신 Persistent List에 저장된 채널을 재사용합니다. 이렇게 하면 불필요한 자원 낭비를 줄일 수 있습니다.
  1. Channel 객체 생성 및 Persistent List 재사용 로직

PHP에서 gRPC 채널을 사용할 때, 다음 절차에 따라 채널을 재사용할 수 있습니다:

  1. 채널이 이미 존재하는지 확인: 요청에서 사용할 채널이 Persistent List에 이미 저장되어 있는지 확인합니다.
  2. 채널이 없으면 새로 생성: 채널이 없으면, 새로운 gRPC 채널을 생성하여 Persistent List에 저장합니다.
  3. 채널이 있으면 재사용: 이미 같은 조건으로 만들어진 채널이 있으면, 새로운 채널을 만들지 않고 기존 채널을 재사용합니다.
extern HashTable grpc_persistent_list;

if (!(PHP_GRPC_PERSISTENT_LIST_FIND(&grpc_persistent_list, key, key_len, rsrc))) {
    // 채널이 없으면 새로 생성해서 Persistent List에 저장
    create_and_add_channel_to_persistent_list(
        channel, target, args, creds, key, key_len, target_upper_bound TSRMLS_CC);
} else {
    // 채널이 존재하면 이를 재사용
    channel_persistent_le_t *le = (channel_persistent_le_t *)rsrc->ptr;
    if (strcmp(target, le->channel->target) != 0 ||
        strcmp(sha1str, le->channel->args_hashstr) != 0 ||
        (creds != NULL && creds->hashstr != NULL &&
         strcmp(creds->hashstr, le->channel->creds_hashstr) != 0)) {
      // 해시 충돌이 발생하거나 조건이 맞지 않으면 새로 생성
      create_and_add_channel_to_persistent_list(
          channel, target, args, creds, key, key_len, target_upper_bound TSRMLS_CC);
    } else {
      // 기존 채널 재사용
      efree(args.args);
      free_grpc_channel_wrapper(channel->wrapper, false);
      gpr_mu_destroy(&channel->wrapper->mu);
      free(channel->wrapper);
      channel->wrapper = NULL;
      channel->wrapper = le->channel;
      php_grpc_channel_ref(channel->wrapper);
      update_and_get_target_upper_bound(target, target_upper_bound);
    }
}
  1. 새로운 채널 생성 조건

채널을 무조건 재사용하지는 않으며, 특정 상황에서는 새로운 채널을 생성해야 합니다:

  • 강제 새로운 채널 생성: force_new 옵션을 사용하면 기존의 채널을 재사용하지 않고 항상 새로운 채널을 생성합니다.
  • 서버 주소나 인증 정보가 변경되는 경우: 요청에서 사용된 서버나 인증 정보가 변경되면, 기존 채널을 사용할 수 없으므로 새로운 채널을 생성해야 합니다.
if (force_new || (creds != NULL && creds->has_call_creds)) {
    // force_new 옵션이 있거나 채널 크리덴셜에 call 크리덴셜이 있는 경우 재사용하지 않고 새로운 채널을 생성
    create_channel(channel, target, args, creds);
} else if (!(PHP_GRPC_PERSISTENT_LIST_FIND(&grpc_persistent_list, key, key_len, rsrc))) {
    // force_new가 없으면 재사용 가능한 채널을 찾고, 없으면 새로 생성
    create_and_add_channel_to_persistent_list(
        channel, target, args, creds, key, key_len, target_upper_bound TSRMLS_CC);
}

FYI; https://github.com/grpc/grpc/blob/df0b1dfed8f3b61ca625b2e4a43b29425ed2236b/src/php/ext/grpc/channel.c#L49