콘텐츠로 건너뛰기

buf 로 Protobuf 사용하기

최근에 proto 파일을 관리해야할 일이 생기면서 알게된 buf 에 대한 글이다. 이 글에서는 buf 가 왜 등장했는지, 그리고 실제로 어떻게 사용하는지도 알아본다.

Protobuf 를 사용할 때 기존 방식의 문제점

보통 Protobuf 를 통해 API 를 만드는 것이 REST 방식으로 JSON 기반 API를 만드는 것보다 보편화되어 있지 않다. 그래서 아직은 Protobuf 를 통해 API를 만드는 것에 대한 표준이 없으며, 조직마다 Protobuf API 만드는 방식이 다르다.

두 번째로, Protobuf 파일들을 가져오기 위해서 수동으로 Github 레포지토리로부터 가져와야 한다. 또한 해당 proto 파일이 의존하는 다른 벤더들의 proto 코드도 모두 가져와야 한다.

세 번째로, 사실 Protobuf 의 강점 중에 JSON과 달리 하나가 상위 호환과 하위 호환을 쉽게 맞출 수 있다고 알려져 있지만 실제로 하위 호환을 맞추는게 쉽지 않고 하위 호환을 잘 맞추는 방법이 많이 알려져 있지 않다.

네 번째로, Protobuf 를 사용하는 곳에서는 protoc 를 통해 생성된 코드를 올리던가, proto를 사용하는 클라이언트들이 직접 protoc 를 통해 Stub 코드를 만들어야 했다. 하지만 protoc 를 잘 사용하기위한 학습 난이도가 있기 때문에 각 팀에서는 Protobuf 파일과 stub들을 관리하는데 어려움이 있었다.

마지막으로 대부분 REST/JSON 방식으로 API를 만들기 때문에 이런 API를 만들기 위한 여러가지 툴들이 존재하지만 Protobuf 생태계에서는 그렇지 않다. 그래서 각 팀에서 이런 툴들을 각자 만들어 쓰고 있기 때문에 많은 불편함들이 존재한다.

buf 가 어떻게 이런 문제들을 해결해주는가?

Buf에서는 buf 라는 CLI 툴과 Buf Schema Registry (BSR) 라는 proto 레지스트리를 통해 이런 문제들을 해결한다.

buf CLI

buf CLI 툴을 통해서 버전별 호환성을 잘 관리할 수 있고 내장된 lint 기능을 통해 쉽게 컨벤션에 맞춰 Protobuf 파일들을 관리할 수 있게 된다. 구체적으로 CLI를 통해 할 수 있는 것들을 정리하면 다음과 같다

  • 기존 protoc 보다 좋은 성능의 Protobuf 컴파일러를 제공해준다
  • Lint 기능을 제공해주기 때문에 좋은 API 디자인과 구조를 따르도록 강제할 수 있다
  • Breaking change dectector 를 통해 호환성이 맞는지 체크해준다
  • 설정 파일을 통해서 필요한 의존성과 함께 쉽게 stub 코드를 생성할 수 있다.

Buf Schema Registry (BSR)

BSR 은 Buf 에서 호스팅해서 제공하는 SaaS 플랫폼이다. BSR 을 통해서 각 팀들은 각자의 Protobuf 파일을 등록할 수 있다. 또한 다른 사람들이 올린 Protobuf 파일을 통해서 각 팀은 새로운 Protobuf API를 작성할 수 있다. 이런 의존성 관리는 Buf의 설정 파일을 통해서 할 수 있으며 마치 npm 과 같이 buf 와 BSR 을 통해 필요한 의존 Protobuf 를 받을 수 있게 된다.

REST/JSON 기반 API의 자유로운 데이터 형식이 문제를 일으킨다

buf 는 왜 Protobuf 와 관련된 도구를 만들었을까? buf 팀은 기존에 일반적으로 REST/JSON 기반 API 형태에 문제가 있다고 봤다.

현재 존재하는 대부분의 API는 대부분 RESTful 한 방식으로 구성되어있다. 데이터는 JSON 형태로 표현되고 HTTP를 통해서 전달된다. JSON 데이터는 타입이 존재하지 않는다. 그렇기 때문에 데이터 형식을 정의하는 방식에 제한이 없다. 다시 말해 자유도가 높다. 일부에서는 자유도가 높고 타입이 없기 때문에 생산성이 높다고 말하지만 사실은 그렇지 않다. 그리고 타입이 없다는 것이 API를 만들 때 몇 가지 문제들을 일으킨다. 서비스 규모가 커지면은 이런 문제점들이 더욱 명확해진다.

API 개발을 할 때 요청값과 응답값 형식을 자유롭게 가져갈 수 있기 때문에 모든 회사들의 API 설계 방식이 모두 다르다. 예를 들어 네이밍 컨벤션도 그렇고 페이지네이션 형식, 버저닝 등과 같은 것들이 제각각 다르다. 회사 안에서도 이러한 방식들이 다를 수도 있다.

API 요청/응답값이 표준화되어 있지 않다면 해당 API를 어떻게 써야하는지 파악하기가 어렵다. 또한 개발하는 입장에서도 표준을 강제적으로 따르게 하기 위한 수단이 없기 때문에 여러 사람들이 이를 지키면서 개발하는 것이 쉬운 일이 아니다.

이는 동적인 타입 언어로 프로그램을 개발하는 것과 비슷하다. 입력값과 반환값의 타입이 자유롭기 때문에 함수에 입력값으로 어떤 것을 주어야 하는지, 반환값의 형태가 어떤지 파악하기가 어렵다. 그리고 동적인 타입 언어로 서비스를 만들 때 그 규모가 커지면 커질 수록 이런 문제가 더 심해진다.

그리고 더 큰 문제가 남아있다. 바로 API 형식의 업데이트이다. API 형식이 바뀌게 되면 downstream에 있는 서비스들, 즉 API를 사용하는 서비스들에 어떤 영향이 갈지 파악하기가 어렵다. 그렇기 때문에 많은 팀들이 큰 변경을 하기가 쉽지 않고 많은 시간과 비용이 소모된다.

Schema Driven Development

이런 문제들을 해결하기 위해서 모든 API 요청/응답값 형식이 프로그래밍 방식으로 정의되고 우리는 이를 스키마(schema)라고 부른다. 이렇게 요청/응답값이 코드로 정의된 상태로 API가 개발되면 서버를 만드는 측이나 클라이언트 측에서 훨씬 편해진다. 네이밍 컨벤션의 경우 린트(lint)로 사전에 잡아낼 수도 있고 요청/응답값의 형태가 변경되면 클라이언트 측에서 컴파일 레벨에서 곧바로 알 수 있다. 또한 protobuf와 같은 툴을 이용해서 스키마를 바탕으로 서버와 클라이언트에 필요한 코드들을 자동으로 생성하고 개발자는 비즈니스 로직 작성에만 집중할 수 있다.

현재 이런 Schema Driven Development 를 가장 잘 실현할 수 있는 방법이 Protobuf라고 봤고 그래서 buf 팀은 Protobuf 를 통해 관련된 도구를 만들어내고 있다.

Buf Example

buf와 BSR의 기본적인 사용 방법과 예제는 Buf document에서 제공하는 Tour를 따라가보면 모두 확인해볼 수 있고 자세히 설명되어 있다. Tour를 따라가보면 자연스럽게 어떻게 buf 를 사용할 수 있는지 알 수 있다.

Blog 서버에서 proto를 buf 로 관리하기

이 포스팅에서는 다른 활용 사례로 Cosmos Application Blockchain 을 만들 때 생성하는 proto 파일을 어떻게 하면 Buf 로 관리할 수 있는지 확인해보겠다. Cosmos 블록체인에 관한 지식은 전혀 없어도 되고 단순하게 Protobuf 를 통해서 API를 만들고 제공하는 서버라고 생각하면 된다.

예제로 나오는 어플리케이션은 간단한 블로그 서버라고 생각할 수 있고 포스트와 댓글을 저장할 수 있다. 또한 어플리케이션 서버는 개발이 모두 마무리된 상태이고 proto 파일들을 좀 더 효과적으로 관리하기 위해서 buf를 도입하는 단계라고 생각하면 된다.

Init & Lint & Compile

전체 코드는 여기서 확인할 수 있으며 현재 우리는 ff77935 커밋 상태이다. 기존 proto 파일들은 proto/ 경로에서 관리되고 있었다. 그래서 해당 경로에서 buf mod init 을 통해 buf 설정을 초기화 한다. 그러면 proto/buf.yaml 파일이 생성되고 아래와 같다.

version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

우리는 여기서 namedeps 를 추가로 명시하는데 name 은 BSR 에 등록될 repository 이름이라고 볼 수 있고 deps 는 기존 proto 파일들이 의존하고 있는 벤더들의 proto 를 명시한다

version: v1
+ name: "buf.build/zerofruit/cosmos-blog"
+ deps:
+   - buf.build/cosmos/gogo-proto
+   - buf.build/cosmos/cosmos-sdk
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

buf가 deps 에 명시된 Protobuf를 받게하기 위해 buf mod update 하고 buf lint 해서 나온 결과물 확인해보자. 그러면 기존에 작성한 proto 들이 컨벤션에 맞게 작성되었는지 확인할 수 있다.

blog/comment.proto:2:1:Files with package "cosmonaut.blog.blog" must be within a directory "cosmonaut/blog/blog" relative to root but were in directory "blog".
blog/comment.proto:2:1:Package name "cosmonaut.blog.blog" should be suffixed with a correctly formed version, such as "cosmonaut.blog.blog.v1".
blog/comment.proto:11:10:Field name "postID" should be lower_snake_case, such as "post_id".
blog/comment.proto:12:9:Field name "createdAt" should be lower_snake_case, such as "created_at".
...

Lint 메세지를 간단하게 살펴보면 디렉토리 구조와 package 명 일치하는지, camelCase 로 필드가 작성된게 없는지 등과 같은 것들을 체크해주고 있다.

Lint 규칙에 맞게 수정해준 뒤 마지막으로 buf build 를 통해 Protobuf 가 제대로 컴파일 되는지 확인하자. 변경 사항들은 2f11d9d 커밋에서 확인할 수 있다.

Generate Stub

이전 단계에서 초기화를 하고 컴파일이 제대로 되는지를 확인했다. 이제 정의한 proto 파일들로 stub 을 만들어보자. 어떻게 stub 을 만들지는 buf.gen.yaml 파일에서 설정한다.

proto 파일을 이용하여 protoc-gen-gocosmos, protoc-gen-grpc-gateway 를 통해 stub 을 만들어야 하기 때문에 plugins[].name 을 각각 gocosmos, grpc-gateway 로 명시했다. buf 에서는 기본적으로 PATH 에서 protoc-gen-<name> 으로 된 바이너리를 찾아 stub 을 생성한다.

out 에서 어디에 stub을 생성할지 명시하고 opt 는 기존 protoc 의 옵션들을 명시해주면 된다. 변경 사항은 e3fd3aa 에서 살펴볼 수 있다.

version: v1
plugins:
  - name: gocosmos
    out: ../x
    opt:
      - paths=source_relative
      - Mgoogle/protobuf/any.proto=github.com/cosmos/cosmos-sdk/codec/types
  - name: grpc-gateway
    out: ../x
    opt:
      - paths=source_relative
      - logtostderr=true
      - allow_colon_final_segments=true
      - Mgoogle/protobuf/any.proto=github.com/cosmos/cosmos-sdk/codec/types

설정 파일을 만든뒤 proto/ 위치에서 buf generate . 를 통해서 stub을 생성할 수 있다.

기존 use-case라면 별 문제가 없겠지만 Cosmos 어플리케이션이다보니 소소한 문제가 있다.

stub이 생성된 위치를 보면 x/blog/v1beta1 인데 원하는 것은 x/blog/types/v1beta1 이다. 이는 proto 파일들의 packageblog.v1beta1 이기 때문이다.

이 문제를 해결하기 위해서는 두 가지 방법이 있는데 첫 번째는 단순하게 cp 명령어로 옮기는 것이고, 두 번째는 proto 파일의 package 를 수정하는 것인데 이렇게 했을 때 문제는 lint에 패키지명과 디렉토리 구조가 일치하지 않는다고 뜰 것이고 그러면 우리는 proto의 디렉토리 구조를 proto/blog/types/v1beta1/ 로 수정해야한다.

Cosmos-SDK 에서 어떻게 하고 있나를 찾아보니 첫 번째 방법을 택하고 있다. 우리도 일단 첫 번째 방법으로 가보자

cp ../x/blog/v1beta1/* ../x/blog/types/
rm -rf ../x/blog/v1beta1/

이전에 lint 규칙에 맞게 proto 파일들을 수정하면서 생성된 stub 구조체명들이 바뀐게 있을테니 깨진 타입들 수정해준다.

여기까지하면 buf를 이용해서 proto를 관리하고 stub을 생성하는 단계까지 마쳤다. 이제 downstream 서비스들에서 이 cosmos-blog 어플리케이션이 정의한 proto 들을 쓸 수 있도록 하기 위해 BSR에 등록해보자

우선 buf.build에서 proto repository를 만들고 buf.yaml 이 존재하는 proto/ 경로로 이동하고 buf push 로 푸시한다. 그렇다면 우리가 생성한 proto repository에 아래 이미지와 같이 제대로 올라간 것을 확인할 수 있다.

Repository link: buf.build/zerofruit/cosmos-blog

buf cosmos-blog repository main page
cosmos-blog buf repository main page

buf cosmos-blog repository generated docs
cosmos-blog buf repository generated docs

Blog 클라이언트에서 buf 로 proto를 받아서 사용하기

이제 downstream 서비스 중 하나인 cosmos-blog-client 에서 cosmos-blog 에서 제공하는 proto 를 받아서 stub을 만들고 이를 이용하여 코드를 작성한다고 해보자.

cosmos-blog-client는 Typescript로 관리되는 프로젝트라고 하고 간단하게 프로젝트를 셋업한다. 여기서 사용되는 클라이언트 코드는 여기서 살펴볼 수 있다.

클라이언트 프로젝트에서는 BSR에서 받아오는 proto를 어떻게 generate 해야하는지는 알아야하므로 buf.gen.yaml 을 생성해서 그 방법을 명시해준다. 클라이언트는 npm i -g ts-proto 를 통해 Typescript protoc-gen 바이너리를 받고 buf generate buf.build/zerofruit/cosmos-blog 를 통해서 stub 코드를 생성한다. 완성된 buf.gen.yaml 은 다음과 같다.

version: v1
plugins:
  - name: ts_proto
    out: ./codegen 
    opt:
      - paths=source_relative
      - esModuleInterop=true
      - snakeToCamel=true
      - outputTypeRegistry=true

그런데 한가지 아쉬운 점이 있다. 바로 ts-proto를 클라이언트가 직접 설치하고 stub 코드를 생성해야한다는 것이다. 스크립트를 통해서 의존하고 있는 툴들을 설치하는 과정은 감춰볼 수 있지만, 예를 들어 Blog 클라이언트 코드를 SDK 형태로 만들어서 제공한다고 했을 때 SDK에서 ts-proto를 관리할 수 없다는 문제가 있다.

이 때 path 를 통해 protoc-gen 바이너리 경로를 명시할 수 있다. 우리는 node_modules/ 의 ts_proto를 사용하고 싶기 때문에 buf.gen.yaml을 다음과 같이 수정하자.

version: v1
plugins:
  - name: ts_proto2
    out: ./codegen
+   path: ./node_modules/ts-proto/protoc-gen-ts_proto
    opt:
      - paths=source_relative
      - esModuleInterop=true
      - snakeToCamel=true
      - outputTypeRegistry=true

이제 buf generate 를 실행한다면 아래와 같이 cosmos-blog stub을 모두 생성할 뿐만 아니라 cosmos-blog stub이 의존하고 있는 다른 stub들도 모두 생성된다. 아래와 같이 생성된 파일을 살펴보면 cosmos-blog에서 명시한 deps 의 stub 까지 모두 생성되기 때문에 아주 편하다.

.
├── buf.gen.yaml
├── buf.lock
├── codegen
│   ├── blog
│   │   └── v1beta1
│   │       ├── comment.ts
│   │       ├── genesis.ts
│   │       ├── params.ts
│   │       ├── post.ts
│   │       ├── query.ts
│   │       └── tx.ts
│   ├── cosmos
│   │   └── base
│   │       └── query
│   │           └── v1beta1
│   │               └── pagination.ts
│   ├── gogoproto
│   │   └── gogo.ts
│   ├── google
│   │   ├── api
│   │   │   ├── annotations.ts
│   │   │   └── http.ts
│   │   └── protobuf
│   │       └── descriptor.ts
│   └── typeRegistry.ts
├── package-lock.json
├── package.json
└── tsconfig.json

Outro

Cosmos-SDK 에서도 buf로 proto 파일을 관리하는 것 같던데 어떻게 하나 찾아보다가 결국 이 글까지 쓰게 되었다. Comos-SDK에서는 아주 낮은 버전의 buf 를 사용하고 있어서 지금 최신 버전에서 소개되고 있는 방식과 꽤 다르게 관리되고 있다. 그리고 지금 하고 있는 프로젝트에서도 proto 파일을 관리하는 방법을 보다보니 buf를 통해 proto 파일을 관리하는 방식을 개선할 수 있을 것 같아서 정리해보았다.

Leave a comment