Home gRPC 톺아보기와 적용
Post
Cancel

gRPC 톺아보기와 적용


gRPC 등장 배경

구글에서 개발한 프레임워크이다. PB(Protocol Buffer, 프로토콜 버퍼)기반 Serizlaizer에 HTTP/2를 결합한 RPC 프레임워크이다.

IPC (Inter Process Communication 프로세스 간 통신)

운영체제에는 IPC기법이 있다. 이 방식으로 서로 다른 서버간의 프로세스끼리 통신을 하며 정보를 교환할 수 있다.

IPC 기법에는 소켓, 공유 메모리, PIPE, 메시지 큐 등 여러가지 기법이있다.

그 중 소켓 통신은 E2E를 직접 연결하여 데이터를 스트리밍하며 받는 형태로 통신하기 때문에 통신간에 발생하는 예외처리, 주고받아야할 데이터가 방대해질 때의 후처리가 매우 어렵기 때문에 확장성 측면의 불편함이 있다.

RPC (Remote Procedure Call 원격 프로시저 호출)

이러한 IPC의 단점을 조금 더 완화하고자 RPC기법이 등장했다.

RPC는 네트워크 상으로 연결된 원격 서버의 함수를 호출 할 수 있도록 해서 네트워크 통신을 위한 작업을 고려하지 않도록할 수 있고 통신이나 call 방식에 신경쓰지 않고 원격지의 자원을 사용할 수 있다.

RPC에는 Stub이라는 주요 개념이 등장한다. Stub이란 Client와 Server간의 데이터를 각 환경에 맞게 변환해주는 것이다.

만약 Stub이 없다면

  • Client와 Server는 전혀 다른 메모리 공간을 사용하고 있기 때문에 Client측 에서 가리키던 포인터가 Server측으로 넘어가게 되면서 전혀 다른 곳을 가리키게된다.
  • Client는 리틀 엔디안 형식을 사용하지만 Server측은 빅 엔디안 형식을 사용한다는 경우 의도와 다르게 동작할 것이다.

이러한 이유들로 각 데이터들은 Stub을 거쳐 Packet의 형태로 전달된다.


gRPC - ProtoBuf (Protocol Buffer, 프로토콜 버퍼)

Google에서 개발한 구조화된 데이터를 직렬화(Serialization)하는 기법이다. 직렬화란, 데이터 표현을 바이트 단위로 변환하는 작업으로 아래 그림처럼 같은 정보를 저장해도

TEXT기반인 JSON의 경우 82Byte가 소요되는데 직렬화 된 Protocol Buffer는 필드 번호, 필드 유형 등을 1Byte로 받아서 식별하고, 주어진 length 만큼만 읽도록 하여 33Byte만 필요하게 된다.

ProtoBuf - Base 128 Varint

Protobuf-guide에 따르면 프로토버퍼는 기본 인코딩 기법으로 Base 128 Varints라는 인코딩 형식을 사용하여 데이터를 압축적으로 표현할 수 있다.

Varint는 하나 이상의 바이트를 사용해서 가변 길이의 정수를 인코딩(직렬화)하는 방식으로 1~10Byte의 길이를 가지고 그 크기는 인코딩하려는 값에 따라 달라진다.

숫자가 더 적을수록 적은 수의 바이트를 사용한다.

마지막 바이트를 제외한 Varint에는 최상위 비트(most significant bit, MSB)가 설정되어 있고, 이는 앞으로 바이트가 더 있음을 나타낸다.

Varint 인코딩 방식은 다음과 같다.

각 바이트의 최상위 비트는 Continuation bit이며, 이는 현재 바이트 다음에 더 이어질 바이트가 있는지를 나타낸다. 그래서 1이면 숫자를 표현하기 위한 바이트가 뒤에 더 있음을, 0이면 숫자의 끝을 의미한다. 이 bit를 제외한 나머지 7비트는 실제 정수를 표현하기 위한 데이터이다.

예를 들어, 숫자 300을 Varint로 인코딩하면 두 바이트가 필요하다.

Decimal -> Varint

  1. 숫자 300을 이진수로 바꾸면 100101100이 된다. 이 숫자를 Varint로 인코딩하려면 7비트 단위로 쪼개야 한다.
    1. 100101100은 9비트이므로, 앞에 0으로 Padding하여 14비트로 만든다. 따라서 00000100101100이 된다.
  2. 이제 이를 7비트 단위로 나누면 0000010 0101100이 된다. (varint는 little-endian 방식을 사용하므로, 작은 단위의 바이트부터 읽어야 한다.)
  3. Varint 인코딩에서는 각 7비트 덩어리 앞에 Continuation 비트를 추가한다.
    1. 첫 번째 7비트는 다음에 또 다른 바이트가 온다는 뜻으로 1을 붙이고, 두 번째 7비트는 더 이상 바이트가 없다는 뜻으로 0을 붙인다. 그래서 10101100 00000010이 된다.

Varint -> Decimal

  1. 10101100 00000010를 다시 10진수로 변환하면,
    1. 10101100은 최상위 비트를 제외하고 나머지를 이진수로 해석하면 0101100 즉, 44이다.
    2. 00000010은 최상위 비트를 제외하고 나머지를 이진수로 해석하면 0000010 즉, 2이다.
  2. 이 두 숫자를 그냥 더하면 안된다. Varint는 각 바이트에서 실제 데이터를 나타내는 7비트를 차례대로 이어붙인다. 이 때 주의해야할 점은 big-endian으로 배치해야하므로 0000010 101100이 되겠다.
  3. 이진수로 표현된 000001010110010101100과 같고 이를 10진수로 변환하면 300이 된다.

위와 같은 직렬화 방식으로 원래 일반적으로 정수를 표현하기 위해 4Byte를 사용하던 직렬화 방식을 2Byte를 사용하도록 최적화 하는 것이 Base 128 Varint 방식이된다.


ProtoBuf - Message Buffer

직렬화 후 메시지는 Key-Value로 구성된 이진 데이터 스트림이 된다. 이 구조는 서로 다른 필드를 구분하기 위해 구분 기호가 필요하지 않는다.

Optional 필드의 경우, 메시지에 필드가 없을 때 최종 메시지 버퍼에 포함되지 않는다. 이로인해 메시지 자체의 크기를 절약할 수 있다.

Key

Key는 특정 필드를 식별하는 데 사용된다. 압축을 풀 때 클라이언트는 구조체 객체를 생성하고 Protobuf는 데이터 스트림에서 데이터를 읽고 역직렬화한다. 키를 기반으로 해당 값을 구조체의 적절한 필드에 일치시킬 수 있다.

Key는 필드 번호와 자료형(Wire Type)으로 구성된다. 바이트의 하위 3비트는 자료형(Wire Type)을 나타내고 나머지 비트는 필드 번호를 나타낸다.


gRPC - HTTP/2

위에서 알아본 직렬화 기법으로 만들어진 Binary Data는 gRPC에서 클라이언트와 서버 간에 HTTP/2.0 프로토콜로 전송된다.

HTTP/2.0에는 멀티플렉싱, 헤더 압축, 서버 푸시 등 몇 가지 주요 기능이 도입되었다.

멀티플렉싱

멀티플렉싱은 단일 연결을 통해 여러 요청과 응답을 전송할 수 있어 지연 시간을 줄이고 전반적인 성능을 향상시킨다.

헤더 압축

헤더 압축은 헤더 필드 인덱싱과 정적 또는 동적 테이블을 사용한 헤더 필드 표현이라는 두 가지 주요 기술을 적용하여 헤더 전송의 오버헤드를 줄여 효율성을 더욱 향상시키며 HPACK이라고도 한다.

서버 푸시

서버 푸시는 서버가 클라이언트의 요청없이도 클라이언트에 리소스를 전송할 수 있도록하여 추가적인 요청이 필요하지 않도록 해준다.

바이너리 기반 데이터 전송

HTTP/1.1은 일반적으로 TEXT로 데이터를 표현하여 전송한다. 하지만 HTTP/2.0부터는 데이터를 Binary Frame으로 표현하여 전송하므로 통신 간 용량이 감소되었다.


HTTP Versioning

HTTP/1.1

“Hello, World!” 라는 응답 메시지를 HTTP/1.1로 전송한다면, 일반적으로 우리가 주고받는 형태의 TEXT기반의 데이터를 사용한다.

```http request

HTTP/1.1 200 OK\r\n Date: Mon, 27 Jul 2009 12:28:53 GMT\r\n Server: Apache\r\n Last-Modified: Wed, 22 Jul 2009 19:15:56 GMT\r\n Content-Length: 13\r\n Content-Type: text/plain\r\n Connection: Closed\r\n \r\n Hello, World!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
### HTTP/2

"Hello, World!" 라는 응답 메시지를 HTTP/2로 전송한다면, 요청과 응답은 바이너리로 인코딩된다.

```http request
+------------------------------------+
| Length (24)                        |
+------------------------------------+----------------+
| Type (8) | Flags (8)               |
+-+----------------------------------+----------------+
| R | Stream Identifier (31)        |
+=+==================================+===============+
| Frame Payload (0...)            ...
+------------------------------------+

왜 gRPC는 HTTP/2를 채택했는가?

바이너리 프로토콜과 양방향 스트리밍 방식 덕분에 HTTP/2.0의 성능과 효율성은 HTTP/1.1과 HTTPS보다 훨씬 뛰어나다.

또한 HTTP/3.0은 UDP 위에 구축된 QUIC 전송 프로토콜을 사용하여 향상된 안정성, 낮은 지연 시간, 더 나은 혼잡 제어 기능을 제공한다.

HTTP/3.0은 가장 좋은 성능을 제공할 수 있으나 프로토콜의 성숙도, 사용 가능한 구현, 기존 인프라 및 도구와의 호환성 등 여러 고려사항으로 인해 아직 gRPC에 적용된 사항이 아니게되었다.

결론적으로, 효율을 더 높일 수 있는 HTTP/2.0을 채택했으나 더 좋은 효율성을 기대할 수 있는 HTTP/3.0은 아직 불안정하다고 판단했기 때문에 HTTP/2.0을 사용한다. 추후 많은 증명과 고찰이 담긴다면 이 기반 프로토콜 역시 변경될 여지가 있다.


gRPC 사용법 [Java/Spring Boot]

gRPC Interface

gRPC로 E2E 통신을 하기 위해서는 상호간이 알고 있는 Protobuf를 정의해야한다. 이를 인터페이스라고 지칭한다.

우선 Java/Spring 기반의 프로젝트에서 사용할 예정이라면 아래와 같은 의존성을 추가해야한다.

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
32
33
34
35
36
37
38
39
40
41
42
buildscript {
    ext {
        protobufVersion = '3.14.0'
        protobufPluginVersion = '0.9.4'
        grpcVersion = '1.60.0'
    }
}

plugins {

    ...

    id 'com.google.protobuf' version "${protobufPluginVersion}"

    ...
}
dependencies {

    ...

    implementation "io.grpc:grpc-protobuf:${grpcVersion}"
    implementation "io.grpc:grpc-stub:${grpcVersion}"

    ...
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:${protobufVersion}"
    }
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }
    generateProtoTasks.generatedFilesBaseDir = 'src/generated'
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

위 의존성을 땡겨받은 후에 아래와 같은 형식으로 패키징을 잡는다.

protobuf-package.png

그리고 데이터를 주고받을 DTO성격의 Protobuf를 정의하고 공통 모듈이든 각 E2E에든 알고 있게 해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.palette.grpc";

message GPassport {
  int64 id = 1;
  string email = 2;
  string nickname = 3;
  string username = 4;
  string role = 5;
  bool isActivated = 6;
  string accessedAt = 7;
  string createdAt = 8;
  string deletedAt = 9;
  string integrityKey = 10;
}

이렇게 셋팅하면 우선 인터페이스 정의는 끝났다. 이제 Client, Server측에서 이 Protobuf를 사용하는 예시를 보자.

gRPC Client

우선 아래와 같이 build.gradle(.kts)에 공통모듈 의존성을 추가하고

build.gradle(.kts)

1
2
3
4
5
6
7
dependencies {
    // Common Module
    implementation(project(":common-module"))

    // gRPC
    implementation("net.devh:grpc-spring-boot-starter:2.15.0.RELEASE")
}

implementation("net.devh:grpc-spring-boot-starter:2.15.0.RELEASE")이 의존성이 SpringBoot MVC에서 gRPC를 다룰 수 있도록 도와주는 라이브러리 의존성이다. 클라이언트/서버 의존성에 추가하는 것이 좋다.

해당 라이브러리의 자세한 내용은 이 링크에 있다.

그 후, settings.gradle(.kts)에 공통 모듈을 프로젝트에 포함시켜야한다. 아래 구문은 프로젝트 구조에 따라 구문은 달라질 수 있고 다른 모듈을 참조하는 방법은 이 포스팅 주제에 벗어나므로 부연설명하지 않는다.

settings.gradle(.kts)

1
2
3
4
rootProject.name = "easel-notification-service"

include(":common-module")
findProject(":common-module")?.projectDir = file("../common-module")

gRPCConfig

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
import jakarta.annotation.PostConstruct;
import net.devh.boot.grpc.common.util.GrpcUtils;
import net.devh.boot.grpc.server.config.GrpcServerProperties;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.netflix.eureka.serviceregistry.EurekaRegistration;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties
public class GrpcConfig {
    private final EurekaRegistration eurekaRegistration;
    private final GrpcServerProperties grpcProperties;

    public GrpcConfig(@Qualifier("eurekaRegistration") EurekaRegistration eurekaRegistration, GrpcServerProperties grpcProperties) {
        this.eurekaRegistration = eurekaRegistration;
        this.grpcProperties = grpcProperties;
    }

    @PostConstruct
    public void init() {
        final int port = grpcProperties.getPort();
        eurekaRegistration.getInstanceConfig().getMetadataMap()
                .put(GrpcUtils.CLOUD_DISCOVERY_METADATA_PORT, Integer.toString(port));
    }
}

위와 같은 gRPC Config로 Service Discovery와 연계하여 MSA환경에서 gRPC 통신 시 Service Discovery에게 통신을 하기 위한 서비스의 주소/포트를 획득하고 로드밸런싱을 위임한다.

gRPC Client

우선 아래와 같이 어떤 gRPC의 서버를 사용할 것인지 grpc.client 속성에 정의한다. 아래 코드는 Discovery Service를 통해 gRPC 서버를 등록했다.

1
2
3
4
5
6
7
8
9
grpc:
    server:
        port: 11009
    client:
        social-service:
            address: 'discovery:///SOCIAL-SERVICE'
            enableKeepAlive: true
            keepAliveWithoutCalls: true
            negotiationType: plaintext
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
import io.grpc.StatusRuntimeException;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.palette.easelnotificationservice.exception.BaseException;
import org.palette.easelnotificationservice.exception.ExceptionType;
import org.palette.grpc.GFollowerIdsRequest;
import org.palette.grpc.GFollowerIdsResponse;
import org.palette.grpc.GSocialServiceGrpc;
import org.springframework.stereotype.Component;

@Component
public class GrpcSocialClient {

    @GrpcClient("social-service")
    private GSocialServiceGrpc.GSocialServiceBlockingStub gSocialServiceBlockingStub;

    public GFollowerIdsResponse getPaintWriterFollowersIds(final Long writerId) {
        try {
            return gSocialServiceBlockingStub.getFollowerIds(
                GFollowerIdsRequest.newBuilder()
                    .setUserId(writerId)
                    .build());
        } catch (final StatusRuntimeException e) {
            throw new BaseException(ExceptionType.NOTIFICATION_500_000001);
        }
    }
}

위와 같이 gRPC클라이언트의 로직을 작성하면 사용하는 것은 끝난다. 이전에 정의한 Protobuf에 맞게 메서드 및 매개변수를 빌드해주면 되고 Stub을 통해 실제 요청을 날리도록 작성하는 구문이다.

gRPC Server

우선 아래와 같이 어떤 gRPC 서버를 어떤 포트로 개방할 것인지 grpc.server 속성에 정의한다.

1
2
3
grpc:
    server:
        port: 11002
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
import net.devh.boot.grpc.server.service.GrpcService;
import org.palette.easelsocialservice.persistence.domain.User;
import org.palette.easelsocialservice.service.UserService;
import org.palette.grpc.*;


@GrpcService
@RequiredArgsConstructor
public class GrpcServer extends GSocialServiceGrpc.GSocialServiceImplBase {
    private final UserService userService;

    @Override
    public void createUser(
            final GCreateUserRequest request,
            final StreamObserver<GCreateUserResponse> responseStreamObserver) {
        userService.createUser(convertToUser(request));
        GCreateUserResponse response = GCreateUserResponse.newBuilder().setIsSuccess(true).build();
        responseStreamObserver.onNext(response);
        responseStreamObserver.onCompleted();
    }
}

gRPC 서버 역할을 하는 코드에는 xxx.yyyServiceImplBase이라는 클래스를 상속받아 이에 해당하는 메서드를 구현해주면 서버 코드가 완성된다.

이 때도 역시 gRPC 인터페이스에서 정의한 Protobuf기반으로 작성하면 된다.

This post is licensed under CC BY 4.0 by the author.