Tech story/Cloud

효율적인 gRPC 서비스 설계: Protobuf 작성 컨벤션 도입기

kt cloud 테크블로그 2024. 11. 4. 15:30

[kt cloud 플랫폼Innovation팀 강솔 님]

 

효율적인 gRPC 서비스 설계: Protobuf 작성 컨벤션 도입기

 

클라이언트와 서버는 항상 동시에 업데이트될 수 없기 때문에, 이들이 항상 동기화될 것이라는 가정은 위험합니다. 특히, Breaking Changes(호환되지 않는 변경)가 발생했을 때 클라이언트나 서버가 최신 업데이트를 반영하지 못하면 서비스 중단이나 통신 오류가 발생할 수 있습니다.

 

이와 관련하여 Protobuf를 정의할 때 유의할 점에 대해 살펴보고, 이를 예방하고 안정적인 서비스 운영을 위해 저희 프로젝트에서 정의한 Protobuf 작성과 관련된 컨벤션 규칙에 대해 설명하겠습니다.

 

 

1. Proto 파일 버전에 따른 메시지 내용 변경

 

먼저 .proto 파일이 변경될 경우 구 버전과 신 버전이 공존한다면 어떤 영향이 있을지, 그리고 이를 대비하기 위한 컨벤션 전략에 대해 설명하겠습니다.

1.1. Proto 파일 버전에 따른 메시지 내용 변경

.proto 파일의 경우 json과 달리 바이너리 데이터입니다. 따라서 서버와 클라이언트 간 버전이 호환되지 않을 경우, 에러가 발생하지 않고 잘못된 메세지가 전달될 수 있습니다.

 

아래와 같이 서로 다른 버전의 클라이언트를 두 개를 준비하고, .proto 파일을 변경했을 때 서버와 클라이언트에서 정의된 버전에 따라 어떻게 메세지를 출력하는지 확인해보겠습니다.

 

메시지 변경에 따른 예상 케이스

메시지 변경에 따른 영향도를 크게 세 가지 케이스로 구분해보았습니다.

 

 

 

  • proto 파일 내 정의된 메시지 타입 변경
    • v1 → v2: 신규 타입 추가 및 필드명 변경
    • v2 → v3: 필드명 변경(필드의 용도 변경)

  • Case에 따른 메시지 노출

  • Case #1
    • type”이 전달된 메시지에 정의되지 않았기 때문에 V2에서 정의된 ENUM의 기본형을 반환합니다.
  • Case #2
    • V1에서 “type” 필드가 정의되지 않았기 때문에 필드 번호(3)와 ENUM의 Value를 출력합니다.
    • model” 필드의 경우도 V1에서 정의된 “year”로 출력합니다.
      • model”과 “year”는 동일한 의미로 사용되기 때문에 서비스에 문제가 발생하지 않습니다.
  • Case #3
    • 2번째 필드의 이름이 “price”로 변경되었으며, 이에 대해 각 각 정의된 버전에 맞춰 “year”, “model”로 메시지를 출력합니다.
      • 변경된 필드명이 내포한 의미가 기존 필드와 상이하기 때문에 서비스에 문제가 발생할 수 있습니다.

 

위의 예시들처럼 Protobuf의 경우 서버와 클라이언트에 적용된 버전이 다르더라도 필드 번호를 기준으로 직렬화 및 역직렬화를 수행하기 때문에 시스템 상 오류가 발생하지 않습니다.

그러나 이로 인해 예상치 못한 로직 오류가 발생할 수 있으며 이를 예방하기 위한 컨벤션 수립이 반드시 필요합니다.

1.2. 변경에 대한 영향도를 최소화하는 메시지 정의 가이드

앞서서 .proto 파일 변경과 각 버전에 따라 메세지가 어떻게 노출되는지 살펴보았습니다. 이를 대비하여 저희 프로젝트에서 수립한 컨벤션 규칙을 설명하겠습니다.

 

1. 필드명 또는 필드 번호 재사용 금지

   a. 필드명이 변경될 경우, 메시지가 갖는 의미가 버전에 따라 달라질 수 있습니다. 따라서 해당 필드에 대한 목적이 변경될 경우 새롭게 필드를 정의해야 합니다.

   b. 사용했던 필드명 및 번호에 대해서는 reserved 키워드를 사용해 예약 처리하여 재사용을 방지합니다.

message user {
  string name = 1;
  // int32 age = 2; // 제거
  // string role = 3; // 제거
}
reserved 2, 3; // 제거된 필드에서 사용했던 필드번호 예약 처리

 

2. required 사용 금지

   a. 메시지에서 정의되는 필드는 언제든 변경될 수 있기 때문에 이를 required로 선언할 경우 역직렬화 실패에 따른 시스템에 문제를 일으킬 수 있습니다.

 

3. ENUM 타입 기본값 지정하기

   a. ENUM의 경우 데이터가 정의되지 않으면 기본 값이 노출되기 때문에 변경되지 않는 값을 지정하도록 권장합니다.

   b. “미지정” 상태를 의미하는 기본 값을 정의하는 것을 권장합니다.

enum Genre {
  UNSPECIFIED = 0;
  HORROR = 1;
  FANTASY = 2;
  ROMANCE = 3;
}

 

4. 메시지 필드의 자료형 변경 금지

   a. protobuf의 경우 필드 번호를 기준으로 직렬화 및 역직렬화를 수행하며, 이 때 자료형에 따라 처리 방식이 상이하기 때문에 자료형을 변경할 경우 시스템 상 문제가 발생할 수 있습니다.

 

 

2. 목적에 따른 메시지, 서비스, Enum분리: 1:1:1 패턴

 

2.1. 1:1:1 패턴

하나의 proto 파일 내에서 최소한의 메시지와 서비스 등을 정의하여 파일을 분리하는 설계 방안을 말합니다.

 

1. 1:1:1 패턴의 장점

   a. 가독성 및 명확성 향상

   b. 유지보수성 개선

   c. 단일 책임 원칙 준수

   d. 의존성 관리 최적화

 

2. 1:1:1 패턴 적용 예시

   a. 학생 정보와 관련된 메시지 정의

      1) 학번 메시지

// student_id.proto
package my.package;
message StudentID {
  string value = 1;
}

       2) 이름 메시지

// full_name.proto
package my.package;
message FullName {
  string family_name = 1;
  string given_name = 2;
}

       3) 학생 메시지

// student.proto
package my.package;
import "student_id.proto";
import "full_name.proto";
message Student {
  StudentId id = 1;
  FullName name = 2;
}

       4) 학생 정보 입력 및 출력 관련 Request, Response 정의

// create_student_request.proto
package my.package;
import "student_id.proto";
message CreateStudentRequest {
  Student student = 1;
}
// create_student_response.proto
package my.package;
import "student_id.proto";
message CreateStudentResponse{
  Student student = 1;
}

     

5) 학생 정보 조회 서비 정의

// student_service.proto
package my.package;
import "create_student_request.proto";
import "create_student_response.proto";
service StudentService {
  rpc CreateStudent(CreateStudentRequest) returns (CreateStudentResponse);
}

2.2. 목적에 따른 네이밍 규칙 수립

저희 프로젝트에서는 유지 보수와 메세지 가독성을 고려하여 1:1:1 패턴을 채택하였습니다. Java와 달리 모든 정의가 .proto 파일로 이루어지므로, 파일명 만으로 타입과 목적을 파악할 수 있도록 규칙을 정하는 것이 중요합니다.

따라서 Message, Service, Enum을 각각 정의할 때, 파일의 목적을 명확하게 표현하기 위해 일관된 네이밍 규칙을 정의하였습니다.

 

1. 네이밍 규칙 - 목적에 따른 Post-Fix 사용

   a.파일명에 _message, _service 등 Post-Fix를 명시해 파일 목적을 쉽게 구분합니다.

 

2. 요청 및 응답 메세지 별도 정의

   a. rpc 메소드의 요청과 응답 메세지는 별도로 정의합니다.

   b. 요청과 응답은 {메소드명} + {메세지 타입}으로 정의합니다.

 

3. 학생 정보와 관련된 메세지 및 서비스 이름 정의 예시

src/main/java
└── my
    └── package      
        ├── requests  /* rpc 요청 정의 */
        │     └── create_student_request.proto
        ├── responses /* rpc 응답 정의 */
        │     └── create_student_response.proto
        ├── services  /* 서비스 정의 */
        │     └── student_service.proto
        └── messages  /*  서비스 메세지 정의 */ 
              ├── student_id_message.proto
              └── student_message.proto     

 

4. ENUM의 경우 Value 정의 시 ENUM명을 Pre-Fix로 사용

   a. ENUM의 경우 Value명이 패키지 내에서 중복되지 않아야 합니다.

enum Genre {
  GENRE_UNSPECIFIED = 0;
  GENRE_HORROR = 1;
  GENRE_FANTASY = 2;
  GENRE_ROMANCE = 3;
}

 

 

이러한 명확한 컨벤션 수립은 안정적인 서비스 운영과 확장 가능한 설계를 보장하며 새롭게 정의할 규칙이 있다면 Protobuf 디자인 가이드(Proto Best Practices https://protobuf.dev/programming-guides/dos-donts/ ) 기준에 따라 프로젝트에 맞는 컨벤션 정책을 추가로 수립할 수 있습니다.

 

 

기타/참고

 

관련글