gRPC는 구글에서 만들고 오픈 소스로 운영 중인 RPC(Remote Procedure Call) 프레임워크입니다. Protocol Buffers를 사용하여 서비스를 쉽게 정의하고 사용할 수 있습니다. HTTP/2를 기반으로 구성되어서 상당히 빠르며, 양방향 스트리밍도 가능합니다.

다양한 장점에도 불구하고 익숙한 HTTP/JSON을 뒤로한 채 낯선 gRPC를 도입한다는 것이 쉬운 결정은 아닙니다. Protocol buffers 도 공부해야 하고, 서버 구성도 바꿔야 하고, 에러 로깅, 디버깅 방법 등 고려해야 할 것들이 정말 많죠.

버즈빌은 많은 고민 끝에 gRPC를 도입하기로 결정했고 다양한 곳에서 gRPC를 사용하고 있습니다. 이 글에서는 gRPC를 도입한 이유와 방법, 그 과정에서 배운 것들을 공유하겠습니다.

 

버즈빌 시스템은 마이크로서비스 아키텍처(MSA, Microservice Architecture)를 적용하고 있습니다. 버즈빌에서 왜 마이크로서비스 아키텍처를 사용하는지는 버즈빌 아키텍트 팀 글에 설명해 두었습니다.

MSA를 구성하다보면 다양한 문제들을 맞이합니다.

중요한 허들 중 하나는 네트워크 속도입니다. 모놀리식(Monolithic) 아키텍처에서 모듈 간의 통신은 하나의 프로세스 안에서 이루어집니다. 반면 MSA 에서는 다양한 서비스들이 다른 프로세스, 다른 호스트에서 돌아가고 있으며, 각 서비스 사이의 통신이 매우 빈번하게 일어납니다. Protocol Buffers 를 사용하는 gRPC는 일반적으로 HTTP/JSON 요청보다 빠른 속도를 보입니다. 물론 요청 한 번에 엄청나게 많은 시간을 절약해 주는 것은 아닙니다. 많은 지식과 코드가 필요한 것에 비해서 얻는 속도는 몇 ms 없을테니까요. 다만 여전히 하나의 요청에 많은 내부 요청을 사용하는 마이크로서비스 아키텍처를 위해서는 네트워크 요청 시간을 조금이라도 더 줄이는 것이 매우 중요합니다.

서비스 간의 통신 규약을 정하는 것도 매우 중요합니다. 서비스 사이 의존성이 낮아지면서 얻은 장점 대신, 각 서비스가 변경 되면서 발생할 수 있는 문제들이 생겨나게 되었죠. JSON을 통한 직렬화(Serialization)는 서비스 사이에서 쉽게 규약이 어긋나거나 실수하기 쉽고, API Route 역시 변경 사항을 널리 적용하기 어렵습니다. 물론 잘 구성된 문서를 통해 규약을 맞추거나, OpenAPI(Swagger) 같은 도구를 사용하는 방법도 있습니다. 하지만 이 역시 학습 비용이 필요합니다. 어차피 학습해야 한다면, 성능이 더 좋은 것을 학습하는 것이 좋지 않을까요?

gRPC는 Protocol Buffers를 통해 클라이언트 코드와 서버 인터페이스 코드를 생성합니다. 옵션에 따라 생성하는 언어를 변경할 수 있죠. 하나의 proto 파일을 사용해서 go, python, java, swift 등 다양한 언어의 서버/클라이언트 코드를 생성할 수 있습니다.

버즈빌에서는 Protocol Buffers로 씌여진 서비스 정의를 모아두기 위해 buzzapis 레포지토리를 구성했습니다. 해당 repository 안에는 버즈빌에서 구성한 모든 마이크로서비스의 proto 파일이 들어 있습니다. 덕분에 한 눈에 어떤 서비스가 어떤 API를 제공하는지 알 수 있죠.

 

packages├── auth│   ├── auth.proto│   └── package.json├── geo│   ├── geo.proto│   └── package.json├── reward│   ├── reward.proto│   └── package.json...

 

buzzapis에서 API 변경이 일어나면 자동으로 배포를 진행합니다. Pull Request를 통해 변경 사항을 리뷰하고 master 브랜치에 머지되면, 변경된 package는 구성되어 있는 CD(Continous Delivery)를 통해서 각 언어들로 빌드됩니다. 이렇게 빌드된 라이브러리들은 각자의 패키지 매니저(e.g PyPI, bintray)로 배포되죠. 듣기에 간단한 작업이지만 각 스텝마다 해결해야 할 문제 상황들이 발생합니다.

protoc를 설치한 뒤 아래 명령만 수행하면 해당 언어로 컴파일 됩니다. 참 쉽죠?

 

$ protoc -I=packages/auth --go_out=build/go/auth auth.proto

 

마지막으로 Makefile 을 건드린게 언제인지 기억 나시나요? 대학교를 졸업하면 잘 안 건드리는 것 중 하나가 Makefile 이죠. 헌데 서비스가 많아지고 다양한 언어와 환경을 손쉽게 조절하려고 생각해보면 갑작스레 Makefile 이 생각날 때가 있습니다. protoc를 사용해서 변경된 proto 파일을 컴파일하기에 Makefile 은 정말 유용한 도구 입니다.

 

build/go/%.pb.go: %.proto    protoc -I=$(dir $<) -f $(notdir $<) --go_out=$(dir $@)

 

Protocol Buffers를 사용해 서비스를 쉽게 정의했다면, 정의한 서비스를 쉽게 구성하고 사용하는 것 또한 중요하겠죠. 앞서 언급 했듯이 Protocol Buffers로 정의된 서비스는 각 언어에서 사용하는 Package Manager로 자동 배포됩니다. 각 언어로 빌드 된 라이브러리를 자동으로 배포 하려면 아래와 같은 조건이 충족되어야 합니다:

  1. 최근 배포 이후 수정 사항이 있는 서비스만 배포할 수 있다.
  2. 각 서비스별로 버전을 관리할 수 있다.
  3. 언어별로 라이브러리 배포에 필요한 파일(e.g setup.py, .gemspec, build.gradle)을 생성할 수 있다.

 

lerna

버즈빌에서는 lerna를 활용해서 이런 작업들을 좀 더 쉽게 수행합니다. lerna는 npm 관리를 위해 제작된 툴이지만, 모노리포에서 여러 프로젝트를 손쉽게 관리하는데 유용하게 사용될 수 있습니다.

lerna를 사용하면 각 폴더 안에 존재하는 package.json을 바탕으로 버전을 관리할 수 있습니다.

 

$ npx lerna list -llerna notice cli v3.13.3lerna info versioning independentauth          v0.1.2 packages/authconfig       v0.1.12 packages/config...

 

또한 변경 사항이 있는 package 들만 버전을 올릴 수도 있죠.

$ npx lerna version patch --no-push --yes

 

Gomplate와 같은 템플릿 도구를 사용하면 package.json 파일 내용을 바탕으로 언어별 라이브러리 배포에 필요한 파일들을 생성할 수 있습니다. Makefile을 활용하면 이 같은 도구들을 사용해서 수정 사항이 있는 패키지들만 빌드하고, 배포에 필요한 파일을 생성한 뒤, 각 언어별 라이브러리 레포지토리에 배포할 수 있습니다.

회사 내부에서 사용하는 서비스 정의와 서버 인터페이스, 클라이언트 라이브러리를 외부로 공개할 수는 없겠죠. 이를 위해서 Private Package Manager 관리가 필요합니다.

 

  • Python 은 PyPI 서버를 설치하여 관리하고 있습니다. Pip을 사용해 설치할 때 — external-index-url 옵션을 사용해서 private PyPI 서버를 지정하게 할 수 있습니다. 이 PyPI 서버의 접근만 제어할 수 있다면 라이브러리를 외부로 공개하지 않으면서 손쉽게 디펜던시를 관리할 수 있습니다.
  • Java 라이브러리는 고맙게도 Bintray가 권한 관리 부분을 해결해주고 있습니다.
  • Go 의 경우 go mod를 사용하고 있는데, go mod 자체가 github 등의 소스 관리 툴을 Package Manager 로 사용하고 있기 때문에 해당 repository에 대한 접근 권한만 관리해주면 됩니다.

 

그 외에도 대부분의 Package Manager는 private library를 배포하는 방식을 제공하고 있으니 해당 가이드에 따라서 설정이 가능합니다.

여전히 RESTful API는 대세입니다. 특히 JSON 형태로 API를 제공하지 않는 곳을 보면 왠지 뒤쳐져 있는 서비스 같은 느낌을 받기도 합니다. 내부 서비스들 끼리는 gRPC를 사용하더라도, 같은 기능을 외부에 HTTP/JSON 형태로 제공해주고 싶은 욕심이 생깁니다. 좋은 것은 나눌 수록 신나니까요.

그렇다고 HTTP 서버를 따로 띄우자니 관리 비용이 걱정입니다. 로직 변경이 있을 때 두 가지를 모두 변경하는 것은 여간 고통스러운 일이 아니죠. 클린 아키텍처 등을 적용해 비지니스 로직을 최대한 따로 두어 재활용 한다고 해도, 힘든 것은 여전히 힘든 것입니다.

헌데 다행이도 우리는 gRPC를 HTTP/JSON 변환할 수 있습니다! Protocol Buffers로 적절하게 HTTP/JSON 형태 선언만 해주면 하나의 gRPC 서버로 두 형태 모두 대응할 수 있는 것이죠. 공짜로 HTTP/JSON을 지원할 수 있다는 점 역시 버즈빌이 gRPC를 사용하기로 결정하는데 큰 역할을 했습니다.

 

rpc GetShelf(GetShelfRequest) returns (Shelf) {  option (google.api.http) = { get: "/v1/shelves/{shelf}" };}message GetShelfRequest {  int64 shelf = 1;}

 

이렇게 설정해두면 GetShelf RPC도 사용할 수 있고, /v1/shelves/{shelf} REST API도 사용할 수 있게 되는 것이죠. 서버 구현 쪽은 전혀 변경한 것이 없었습니다.

세상에 간단한 일은 없더군요. 이 모든 과정을 쉽고 자연스럽게 개발하고 배포하려면 CI/CD를 잘 구성해야 합니다. 버즈빌에서는 Kubernetes와 Istio를 통해 Transcoder를 구성하고, Helm과 Spinnaker를 활용해서 CD를 구성하였습니다. 이 부분은 좋아요가 많이 달리면 다음 글에서 좀 더 자세히 풀어보도록 하겠습니다.

변화는 항상 두려움을 동반합니다. 그럼에도 그 두려움을 뚫고 새로운 시도를 할 수 있는 조직에 있다는 것은 매우 즐거운 일입니다. gRPC 도입은 그저 프로토콜의 변화만이 아닌 API 우선 법칙을 포함한 개발 문화와 방법론의 변화까지 가지고 오게 된 좋은 선택이었습니다. 여전히 넘어야 할 산들이 많이 남아있지만, 이런 산들을 넘어가면서 더욱 안정적이고 빠르고 변화에 유연한 멋진 시스템이 될 것이라고 믿습니다.

 

글. 버즈빌의 CA, Whale

  • #tech blog
  • #buzzvil
  • #grpc
  • #makefile
  • #software architecture
  • #microservice