du.study기록공간

GRPC 사용 시 client, server의 protobuf 차이가 발생할 때(버전이 다른 경우) 발생되는 상황에 대하여 본문

자바

GRPC 사용 시 client, server의 protobuf 차이가 발생할 때(버전이 다른 경우) 발생되는 상황에 대하여

du.study 2025. 2. 13. 00:10
728x90

이번 글에서는 protobuf version 차이로 인하여 어떤 문제가 발생될 수 있는지 어떤경우는 문제가 되지않는지를 작성해보고자 합니다.

 

현재 운영에서 grpc를 사용하고 있고, idl 명세가 변경되면서 server, clinet 간의 protobuf 가 차이가 나는 경우가 있습니다.

이때마다 어떤 상황이 일어나는지를 정리해봅니다.

 

 

우선 간단하게 설정을 진행합니다. spring을 사용하는 테스트가 아니여서 아래와 같이 세팅하였습니다.

build.gradle

plugins {
    id 'java'
    id 'com.google.protobuf' version '0.8.17' // Protobuf를 사용하기 위한 플러그인 추가

}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.10.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    // gRPC 라이브러리 및 관련 의존성 추가
    implementation 'io.grpc:grpc-netty-shaded:1.40.0' // gRPC 통신을 위한 Netty 서버
    implementation 'io.grpc:grpc-protobuf:1.40.0' // gRPC와 protobuf 통합
    implementation 'io.grpc:grpc-stub:1.40.0' // gRPC 스텁 생성
    implementation 'com.google.protobuf:protobuf-java:3.17.3' // 프로토콜 버퍼 자바 라이브러리
    implementation 'javax.annotation:javax.annotation-api:1.3.2'
}

test {
    useJUnitPlatform()
}


protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.17.3' // Protoc 컴파일러 버전 설정
    }
    plugins {
        grpc {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.40.0' // gRPC 자바 코드 생성 플러그인
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {} // 모든 프로토콜 버퍼 파일에 대해 gRPC 코드 생성 활성화
        }
    }
}

sourceSets {
    main {
        java {
            // 자바 코드로 생성된 proto 및 grpc 파일 위치
            srcDirs 'build/generated/source/proto/main/java', 'build/generated/source/proto/main/grpc'
        }
    }
}

 

server는 main에서 구현체를 직접 등록시켜서 응답을 받게끔 설정합니다.

public class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
    @Override
    public void helloWorld(Hello.HelloWorld message, StreamObserver<Hello.HelloWorld> responseObserver) {
        System.out.println("-------------------------------");
        System.out.println(message.toString());
        System.out.println("field1: " + message.getField1());
        System.out.println("field2: " + message.getField2());
        System.out.println("field3: " + message.getField3());
        System.out.println("-------------------------------");

        responseObserver.onNext(message);
        responseObserver.onCompleted();
    }
}


public class ServerApplication {
    public static void main(String[] args) throws IOException, InterruptedException {
        Server server = ServerBuilder.forPort(9090)
            .addService(new OrderServiceImpl()) // 서비스 구현 등록
            .build();

        server.start();
        server.awaitTermination();
    }
}

 

client는 그냥 메인에서 한번씩 호출하는 방식으로 적용해두었습니다.

public static void main(String[] args) {
        // gRPC 채널을 생성 (localhost:50051로 서버에 연결)
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090)
            .usePlaintext()
            .build();

        // 서비스의 스텁 생성
        HelloServiceGrpc.HelloServiceBlockingStub stub = HelloServiceGrpc.newBlockingStub(channel);

        // 요청 메시지 생성
        Hello.HelloWorld request = Hello.HelloWorld.newBuilder()
            .setField1(Integer.MAX_VALUE)
            .setField2(true)
            .setField3("hello world")
            .build();

        // RPC 호출
        try {
            Hello.HelloWorld response = stub.helloWorld(request);
            System.out.println("response: " + response);
        } catch (StatusRuntimeException e) {
            System.err.println("RPC failed: " + e.getStatus());
        } finally {
            // 채널 종료
            channel.shutdown();
        }
    }

 

proto는 다음과 같습니다. 처음은 server/client 동일하다는 가정입니다.

syntax = "proto3";
package com.study;

message HelloWorld {
  int32 field1 = 1;
  bool field2 = 2;
  string field3 = 3;
}

service HelloService {
  rpc helloWorld(HelloWorld) returns (HelloWorld);
}

 

 

운영이라면 각 protobuf를 빌드해서 version단위로 관리를 하고있겠지만, 테스트라서 각각의 idl를 선언하고 테스트를 진행합니다.

 

 

1. 동일한 버전의 protobuf 를 client, server가 가지고 있는경우

이 경우 당연히 각 필드에 매핑이 잘 되고 출력을 해보면 정상적으로 필드가 매핑된것을 볼 수 있습니다. 호출하면 다음과같은 출력결과가 잘 나오게 됩니다. request toString만으로도 잘 노출이 되네요

 

 

2. server의 protobuf에 필드가 추가된경우, (순서는 변경되지않고 추가)

이번엔 서버쪽의 idl을 수정해보겠습니다. 

아래와같이 필드를 하나 추가하고, 출력필드란에 해당필드를 추가하여 클라이언트를 호출한 결과입니다.

syntax = "proto3";
package org.study;

message HelloWorld {
  int32 field1 = 1;
  bool field2 = 2;
  string field3 = 3;
  int64 field4 = 4;
}

service HelloService {
  rpc helloWorld(HelloWorld) returns (HelloWorld);
}

 

request를 보면 field4가 빠져있고, 실제로 출력을 해보면 0으로 들어있는것을 볼 수 있습니다.
이 케이스를 통해서 서버쪽 필드가 추가된경우 (기존 순서를 변경하지 않은경우) 기존 클라이언트 rpc를 통해 요청이 가능하고, 추가된 필드만 무시되는걸 확인할 수 있습니다. 필드 매핑관련 설정은 4번에서 좀더 상세히 볼 예정입니다.

3. client의  protobuf에 필드가 추가된경우, (순서는 변경되지않고 추가)

이번엔 반대로  client에만 field4를 추가하고 출력되는 결과를 보면 server request에는 관련 요청이 다 찍혀있는것을 확인할 수 있습니다.  이때 필드명 대신 순서가 찍혀있는 내용이 확인됩니다. (toString 내부에서 확인 불가능한 필드는 순서가 key가 됩니다.)

 

이 경우에도 별다른 에러없이 서버는 처리가 가능한 상태입니다.

다만, 클라이언트는 분명 4개를 보냈고, 정상 처리된것처럼 보이나, 실제로 서버는 3개만 받아 처리중이니 이 경우 idl의 차이로 인하여 서로 인지하는 입장이 달라 장애가 발생할 여지가 있습니다.

4. protobuf의 필드 타입이 변경되는 경우

해당 부분을 설명하기 전, 먼저 message에서 필드를 세팅하는 코드를 확인하려 합니다.

구현체를 보면 tag라는 값을 받아서 switch문을 돌려 맞춤 필드에 값을 저장하는 로직을 볼 수 있습니다.

위에 공유드린 proto 구현체를 보면 아래와같이 tag값이 따라 field를 저장하는 로직을 볼 수 있습니다.  

    private HelloWorld(
      ......
      try {
        boolean done = false;
        while (!done) {
          int tag = input.readTag();
          switch (tag) {
            case 0:
              done = true;
              break;
            case 8: {

              field1_ = input.readInt32();
              break;
            }
            case 16: {

              field2_ = input.readBool();
              break;
            }
            case 26: {
              java.lang.String s = input.readStringRequireUtf8();

              field3_ = s;
              break;
            }
            default: {
              if (!parseUnknownField(
                  input, unknownFields, extensionRegistry, tag)) {
                done = true;
              }
              break;
            }
          }
        }
      } 
      .......

 

해당 tag의 경우 proto의 순서와, wire type을 합쳐서 인코딩한 값을 tag로 사용하게 됩니다.

즉  tag = (field_number << 3) | wire_type 해당 공식으로 tag값이 결정되게 됩니다.

 

wire type은 공식문서에서 확인이 가능합니다.

https://protobuf.dev/programming-guides/encoding/


field1이 첫번째 순서이고, int32이므로 공식을 대입하면

( 1 << 3) | 0 계산을 통해 8이 나옵니다.

field2의 경우, 두번째 순서에 bool이므로

( 2 << 3) | 0 을 통해 16이 나오게 됩니다.

 

field3의 경우, 세번째 순서에 string이므로

(3 << 3) | 2 계산을 통해 26이 나오게 되어 위에 switch문 구현체가 만들어지게 됩니다.

그렇다면 type이 달라지는 경우 어떻게 되는지를 이제 보겠습니다.

 

 

- client가 field3의 string field를 int64로 바꿔서 호출하는 경우

 

client에서 field3을 int64로 바꿔서 호출해버리는 경우, request에서도 field3을 매핑하지 못하고 순서값이 request에 찍히고, field3에 매핑되지않는 것을 확인 할 수 있습니다.

server의 경우 field3의 tag가 26이지만, request로 보낸 값의 경우, tag가 24로 찍히기에 매핑이 안되는 케이스입니다. (디버깅자료 첨부드립니다)

실제 field3매핑시, tag가 24로들어오는 모습

 

- client가 field1의 int32를 int64로 바꾸는 경우 

이 케이스의 경우 wire type이 동일하기에 field1에 값이 저장될 수 있습니다.

client에서 field1의 type을 int64 로 변경하고, int의 최대범위보다 + 10을 더해서 호출하면 다음과 같은 결과를 얻을 수 있습니다.

Hello.HelloWorld request = Hello.HelloWorld.newBuilder()
    .setField1(Integer.MAX_VALUE + 10L)
    .setField2(true)
    .setField3("Hello, gRPC!")
    .build();

server에서 받은 출력값

tag값이 동일하기에 field1에 세팅이 되었으나, 실제 long값을 대신 INT 최대 범위가 넘어가서 음수가 저장된것을 볼 수 있습니다.

이 경우에도 idl의 차이로 인하여 서로 인지하는 입장이 달라 장애가 발생할 여지가 있습니다.

 

5. protobuf의 필드순서가 변경되는 경우

client에서 field2와 field3의 순서를 아래와 같이 바꿔서 호출을 해보겠습니다.

message HelloWorld {
  int64 field1 = 1;
  string field3 = 2;
  bool field2 = 3;
}

 

그에 대한 서버 출력은 다음과 같습니다.

서버에서는 실제 request가 client가 요청한대로 들어온것이 보이나 field 매핑이 안된것을 볼 수 있습니다.

이 경우에도 에러케이스가 발생되지 않으면서 idl의 차이로 인하여 서로 인지하는 입장이 달라 장애가 발생할 여지가 있습니다.

 

6. idl의 package가 일치하지 않는 경우

갑자기 패키지가 맘에안들어서 client의 proto package 를 수정해봅니다.

package org.study2;

 

이 경우 호출을 하게되면 client단에서 아래와 같은 에러를 확인할 수 있습니다. 아래 에러를 보면 package와 Service명, method명이 중요하게 동작하는 것을 알 수 있습니다.

RPC failed: Status{code=UNIMPLEMENTED, description=Method not found: org.study2.HelloService/helloWorld, cause=null}

 

실제로 proto file의 이름을 바꿔서, 생성되는 객체의 명칭을 바꿔도 package, Service, method명이 일치하는 경우 정상 호출이 됩니다.

 

 

 

 

 

사실 운영과정에 field 순서를 바꾸거나 package를 바꾸는 경우는 거의 없는것 같습니다. (설계의 문제, 배포과정에 서비스 셧다운이 필요한 문제입니다.)
거의 대부분 필드 추가건이 일어날것 같은데, 이 경우에는 server 단의 방어로직을 잘 작성해둔 후,  server -> client순으로 배포하는 방식으로 문제를 잘 풀어가는게 중요할 것 같습니다.

 

 

728x90
Comments