winterjung blog


IDL 읽는 법 (Protocol Buffers)

idl header image

클라이언트와 서버간 api 명세를 protocol buffers(이하 protobuf)로 idlInterface Description Language 삼아 개발할 때, 이 idl을 어떻게 읽어야 하는지 간단하게 알아보자.

서버는 grpc를 지원하지만, 클라이언트가 json api 통신을 한다는 가정하에 grpc-gateway를 사용한다.

user 서비스를 예로#

사용자 생성, 조회, 수정하는 간단한 유저 관리 서비스가 있다고 할 때 아래처럼 protobuf를 source of truth삼아 api 명세를 소통하려 한다. 아래 같은 protobuf 파일은 해당 유저 서비스 레포에 존재하거나 조직의 공통 protobuf 관리 레포에 있거나 할 텐데 그래서 어느 endpoint에 어떤 요청을 보내야 하는지 헷갈릴 수 있다.

파트를 나눠 어떤 요청 응답이 오가는지 보기에 앞서 서비스에 어떤 api들이 있는지 읽어보자.

syntax = "proto3";

package user;

import "google/api/annotations.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/wrappers.proto";

service User {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{user_id}"
    };
  }
  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) {
    option (google.api.http) = {
      patch: "/v1/users/{user_id}"
      body: "update_spec"
    };
  }
}

읽어보기#

meta 정보#

syntax = "proto3";

package user;

import "google/api/annotations.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/wrappers.proto";

크게 중요하진 않다. 주로 이 protobuf를 가지고 python, golang, java, swagger 등 다양한 output으로 generate 할 때 필요한 정보다.

서비스와 rpc#

service User {
  rpc CreateUser;
  rpc GetUser;
  rpc UpdateUser;
}

예제로 나온 유저 관리 서비스처럼 특정 서버는 여러 api를 가진다. 여기선 User 서비스에 사용자 생성, 조회, 업데이트 api가 있음을 알려준다. rpc 하나하나가 api에 대응된다.

rpc Xxx(Yyy) returns (Zzz)#

rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {

rpc의 형태는 rpc Xxx(Yyy) returns (Zzz) 처럼 생겼는데 마치 우리가 함수를 만들어 쓰듯 “Xxx rpc는 Yyy를 input으로 받고 Zzz를 output으로 준다”는 의미다. 위 예시 rpc는 CreateUser라는 사용자 생성을 위한 rpc고 CreateUserRequest를 input으로 받아 CreateUserResponse를 반환하는 api라고 볼 수 있다. 이 때 CreateUserRequest, CreateUserResponse message는 {...}처럼 json body라고 생각할 수 있다.

그래서 이 rpc를 어떤 endpoint로 호출해야 하는지, 실제로 주고받는 body 내용은 뭔지는 후술한다.

  ┌──────────────────┐                          ┌──────────────────┐
  │CreateUserRequest │   ┌──────────────────┐   │CreateUserResponse│
  │                  │   │                  │   │                  │
  │{                 │──▶│  CreateUser rpc  │──▶│{                 │
  │  // fields       │   │                  │   │  // fields       │
  │}                 │   └──────────────────┘   │}                 │
  └──────────────────┘                          └──────────────────┘
  
rpc 호출 구조

rpc option#

  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
    option (google.api.http) = {
      post: "/v1/users"
      body: "*"
    };
  }
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option (google.api.http) = {
      get: "/v1/users/{user_id}"
    };
  }
  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) {
    option (google.api.http) = {
      patch: "/v1/users/{user_id}"
      body: "update_spec"
    };
  }

여기서 post: "/v1/users"처럼 생긴 부분이 http api로 rpc를 호출할 때 어떤 endpoint를 어떤 method로 호출해야 하는지 알려준다.

body: "*", body: "update_spec" 부분은 XxxRequest message 안에 있는 필드 중 무엇을 실제 요청으로 보내는 json body로 간주할 거냐는 의미다. "*"면 모든 필드가 body에 들어가고 "update_spec"처럼 있으면 뒤에 나올 XxxRequest message 안에서 update_spec이라는 이름을 가진 필드의 내용만 보내면 된다는 뜻이다.

hostname(e.g. https://api.winterjung.dev)까지 protobuf로 나타내진 않고 주석처럼 다른 방법으로 합의한다.

주고받는 api 명세#

message를 통해 실제로 어떤 값을 보내야 하고 어떤 값을 반환해주는지 정의해둔다.

enum UserRole {
  USER_ROLE_UNKNOWN = 0;
  USER_ROLE_ADMIN = 1;
}

message CreateUserRequest {
  string name = 1;
  google.protobuf.StringValue memo = 2;
  UserRole role = 3;
  int64 joined_at_ms = 4;
}

message User {
  string id = 1;
  string name = 2;
  google.protobuf.StringValue memo = 3;
  UserRole role = 4;
}

message CreateUserResponse {
  User user = 1;
}

message GetUserRequest {
  string user_id = 1;
  bool should_include_id = 2;
}

message GetUserResponse {
  User user = 1;
}

message UpdateUserRequest {
  message UserUpdateSpec {
    string name = 1;
    google.protobuf.StringValue memo = 2;
    UserRole role = 3;
  }
  string user_id = 1;
  UserUpdateSpec update_spec = 2;
  google.protobuf.FieldMask update_mask = 3;
}

message UpdateUserResponse {}

뭔가 많지만 하나씩 보면 json과 그리 다를 바 없다.

읽어보기#

각종 타입#

message CreateUserRequest {
  string name = 1;
  google.protobuf.StringValue memo = 2;
  UserRole role = 3;
  int64 joined_at_ms = 4;
}

아까 말했던 POST /v1/users의 json body로 들어가는 message다. 오른쪽에 붙어있는 1, 2, 3, 4 같은 숫자는 protobuf의 field number인데 grpc 동작 방식상 중요하나 실제로 json api 명세상 무시해도 되는 부분이다. json body를 보낼 땐 아래처럼 생겼다.

{
    "name": "john",
    "memo": null,
    "role": "USER_ROLE_ADMIN",
    "joined_at_ms": "1612137600000"
}

여기서 나와 있진 않지만, 필드 중 타입 앞에 repeated가 붙은 필드가 있다. (e.g. repeated int32 values = 1;) 이런 필드는 array 타입이고 {"values": []}, {"values": [1, 2, 3]}처럼 주고받을 수 있다.

message는 object#

message User {
  string id = 1;
  string name = 2;
  google.protobuf.StringValue memo = 3;
  UserRole role = 4;
}

message CreateUserResponse {
  User user = 1;
}

CreateUser rpc(POST /v1/users)를 호출한 후 응답으로 CreateUserResponse message가 오는데 위와 같이 해석할 수 있다.

{
    "user": {
        "id": "1",
        "name": "john",
        "memo": null,
        "role": "USER_ROLE_ADMIN"
    }
}

path와 query parameter#

message GetUserRequest {
  string user_id = 1;
  bool should_include_id = 2;
}

message GetUserResponse {
  User user = 1;
}

GetUserRequest message에 있는 user_id는 json body로 들어가는 게 아니라 아까 봤던 get: "/v1/users/{user_id}" 여기처럼 path에 들어간다. path에 있는 필드 이외의 필드(e.g. should_include_id boolean 필드)는 모두 쿼리 파라미터로 들어간다. 결국 위 사용자 조회 api는 GET /v1/users/123?should_include_id=true처럼 사용할 수 있다.

만약 repeated 필드가 있고 이를 쿼리 파라미터로 보내고 싶다면 GET /v1/users?keys=1&keys=2 처럼 같은 쿼리 파라미터 key를 여러 번 사용하면 된다.

body option#

message UpdateUserRequest {
  message UserUpdateSpec {
    string name = 1;
    google.protobuf.StringValue memo = 2;
    UserRole role = 3;
  }
  string user_id = 1;
  UserUpdateSpec update_spec = 2;
  google.protobuf.FieldMask update_mask = 3;
}

message UpdateUserResponse {}

뭔가 조금 복잡해 보이지만 결국 클라이언트에선 PATCH /v1/users/{user_id} api에 아래처럼 보내주면 된다.

위에서 언급했듯 UpdateUser rpc는 body: "update_spec" option이 있는데 이 때문에 실제로 클라이언트에서 보내야 할 body는 update_spec 필드에 해당하는 UserUpdateSpec message 내용만 보내주면된다.

그 외#

protobuf는 사용하는 조직마다 그 컨벤션이 다르기에 네이밍 부분이라든지, 상세한 규칙이 다른 부분은 있겠으나 보편적으로 읽는 방법은 비슷하다. protobuf를 기반으로 generate 된 swagger를 swagger editor 같은 곳에 붙여넣어 보는 방법도 있겠으나 source of truth는 protobuf이므로 idl이니 만큼 protobuf로 소통하는 방법이 가장 오해가 적을 거라 생각한다.