Protocol Buffers와 gRPC로 MSA 서비스 간 통신하기
MSA 통신, 어떻게 할 것인가?
마이크로서비스 아키텍처(MSA)를 구현하는 방법은 다양하다. DDD(Domain-Driven Design) 기반 설계, 이벤트 소싱, CQRS 등 여러 패턴이 있고, 서비스 간 통신 방식도 REST API, GraphQL, 메시지 큐, gRPC 등 선택지가 많다.
이 글에서는 Protocol Buffers(Protobuf)와 gRPC를 사용한 서비스 간 통신 방법을 소개한다.
왜 Protobuf + gRPC인가?
1. 인터페이스 우선 개발 (Interface-First Development)
Protobuf의 가장 큰 장점은 인터페이스를 먼저 정의하고 개발을 시작할 수 있다는 것이다.
Frontend와 Backend가 함께 .proto 파일을 작성하고 합의하면, 그 순간부터 각자 개발을 진행할 수 있다. Backend는 서비스 구현을, Frontend는 Mock 데이터로 UI 개발을 병렬로 진행한다. 인터페이스가 명확하니 나중에 통합할 때 문제가 적다.
2. 타입 안전성
Proto 파일에서 코드가 자동 생성되므로 타입 불일치로 인한 런타임 에러가 줄어든다. Protobuf는 Go, Java, Python, TypeScript, Rust 등 다양한 언어를 지원하므로 팀의 기술 스택에 맞게 선택할 수 있다. (이 글의 예제는 TypeScript와 Go로 작성했다.)
3. 공동 소유권 (Shared Ownership)
IDL(Interface Definition Language) 저장소는 Backend만의 것이 아니다. Frontend, Backend, 심지어 모바일 개발자까지 누구나 오너로서 참여할 수 있다. API 변경이 필요하면 누구든 PR을 올리고, 관련 팀들이 리뷰한다. 이런 구조가 팀 간 협업을 자연스럽게 만든다.
IDL 저장소 구조
.proto 파일을 관리하고, Go와 TypeScript 코드를 생성하는 저장소 구조는 다음과 같다.
idl/
+-- proto/
| +-- user/v1/
| | +-- user.proto # User 메시지 정의
| | +-- service.proto # User 서비스 RPC 정의
| +-- post/v1/
| | +-- post.proto # Post 메시지 정의
| | +-- service.proto # Post 서비스 RPC 정의
| +-- buf.yaml # buf 설정
| +-- buf.gen.yaml # Go 코드 생성 설정
| +-- buf.gen.ts.yaml # TypeScript 코드 생성 설정
+-- gen/
| +-- go/ # 생성된 Go 코드
| +-- ts/ # 생성된 TypeScript 코드
+-- package.json # npm publish용
버전 관리를 위해 v1 디렉토리를 뒀다. 나중에 breaking change가 생기면 v2를 만들면 된다.
Proto 파일 작성
메시지 정의
// proto/post/v1/post.proto
syntax = "proto3";
package post.v1;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/repo/idl/gen/go/post/v1;postv1";
message Post {
int64 id = 1;
string title = 2;
string content = 3;
User user = 4; // required - 글쓴이는 무조건 있음
optional Category category = 5; // optional - 카테고리는 없을 수 있음
google.protobuf.Timestamp created_at = 6;
}
message GetPostRequest {
int64 id = 1;
}
message GetPostResponse {
Post post = 1;
}
포인트:
optional없으면 required (User)optional있으면 nullable (Category)
서비스 정의
// proto/post/v1/service.proto
syntax = "proto3";
package post.v1;
import "post/v1/post.proto";
service PostService {
rpc CreatePost(CreatePostRequest) returns (CreatePostResponse);
rpc GetPost(GetPostRequest) returns (GetPostResponse);
rpc GetPostBySlug(GetPostBySlugRequest) returns (GetPostBySlugResponse);
rpc UpdatePost(UpdatePostRequest) returns (UpdatePostResponse);
rpc DeletePost(DeletePostRequest) returns (DeletePostResponse);
rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
}
TypeScript Optional 지옥에서 탈출
Protobuf generated 코드를 쓰다 보면 불편한 점이 있다.
대부분의 코드 생성기는 모든 필드를 optional로 만든다:
// 기본 설정으로 생성된 코드
interface Post {
id?: string;
title?: string;
content?: string;
category?: Category;
}
interface GetPostResponse {
post?: Post;
}
왜 이렇게 될까?
Protobuf3의 설계 철학
Protobuf3에서는 필드가 default value면 wire에 아예 전송되지 않는다:
int64는 0string은 빈 문자열bool은 false
받는 쪽에서는 "값이 0인지, 아예 설정 안 한 건지" 구분할 수 없다. 그래서 코드 생성기는 모든 필드를 optional로 만들어서 "값이 없을 수 있음"을 표현한다.
message 타입은 더 복잡하다. message는 default value가 없어서 **"빈 객체 vs 아예 없음"**을 구분해야 한다. 그래서 message 타입 필드는 항상 nullable이다.
불편한 코드
결과적으로 TypeScript에서는 매번 이런 코드를 써야 한다:
const response = await client.getPost({ id: "1" });
// user는 required인데도 ?. 필요
console.log(response.post?.user?.name ?? "");
// category는 optional이니 ?. 당연
console.log(response.post?.category?.name ?? "");
user는 무조건 있는 필드인데도 ?.를 써야 한다. required와 optional 구분이 안 되니 불편하다.
ts-proto 설정으로 해결
이 문제는 ts-proto의 useOptionals=none 옵션으로 해결할 수 있다:
# buf.gen.ts.yaml
version: v2
managed:
enabled: true
plugins:
- local: protoc-gen-ts_proto
out: ../gen/ts
opt:
- esModuleInterop=true
- outputServices=nice-grpc
- outputServices=generic-definitions
- useExactTypes=false
- snakeToCamel=true
- forceLong=string
- oneof=unions
- useOptionals=none # 핵심 옵션!
useOptionals=none을 쓰면 생성되는 코드가 달라진다:
// useOptionals=none으로 생성된 코드
interface Post {
id: string; // primitive - required
title: string; // primitive - required
user: User | undefined; // message, optional 없음 - required
category?: Category | undefined; // message, optional 있음 - optional
}
interface GetPostResponse {
post: Post | undefined; // message는 여전히 | undefined
}
차이점
- primitive 타입: 무조건 required
- message 타입 (optional 없음):
| undefined는 붙지만?는 없음 - message 타입 (optional 있음):
?도 붙고| undefined도 붙음
실제 proto와 생성된 TypeScript를 비교해보면:
message Category {
int64 id = 1;
string name = 2;
optional int64 parent_id = 3; // optional 명시
}
interface Category {
id: string; // required
name: string; // required
parentId?: string | undefined; // optional - ?가 붙음!
}
아직 남은 문제
useOptionals=none으로 primitive 타입은 해결됐지만, message 타입은 여전히 | undefined다:
interface GetPostResponse {
post: Post | undefined; // 여전히 undefined 가능
}
API 응답에서 post가 무조건 있다는 걸 알고 있는데, 매번 if문으로 체크해야 한다.
Protobuf<T> 유틸리티 타입으로 해결
유틸리티 타입을 만들어서 | undefined를 제거할 수 있다:
// undefined 제거
type NonUndefined<T> = T extends undefined ? never : T;
// Required 키만 추출
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
// Optional 키만 추출
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
// 재귀적으로 undefined 제거
type Protobuf<T> = {
[K in RequiredKeys<T>]: NonUndefined<T[K]>;
} & {
[K in OptionalKeys<T>]?: NonUndefined<T[K]>;
};
이 타입이 하는 일:
field: T | undefined(required) →field: Tfield?: T | undefined(optional) →field?: T
적용
HTTP 클라이언트에서 응답을 파싱할 때 Protobuf<T>를 적용한다:
export function parseJson<T>(msgFns: MessageFns<T>, json: unknown): Protobuf<T> {
return msgFns.fromJSON(json) as Protobuf<T>;
}
// 사용
const response = await client.get(GetPostResponse, "/v1/posts/1");
// response 타입: Protobuf<GetPostResponse>
// response.post 타입: Post (undefined 아님!)
최종 코드
이제 if문 없이 바로 접근할 수 있다:
const response = await client.get(GetPostResponse, `/v1/posts/${id}`);
// required 필드 - 바로 접근
console.log(response.post.title);
console.log(response.post.user.name); // user는 required라 바로 접근!
// optional 필드 - 체크 필요
if (response.post.category) {
console.log(response.post.category.name); // category는 optional이라 체크
}
proto에서 optional로 명시한 필드만 체크하면 된다. 나머지는 타입 시스템이 보장한다.
Go 서비스 간 gRPC 통신
Go에서는 generated 코드를 그대로 사용하면 된다. TypeScript처럼 별도의 유틸리티 타입이 필요 없다.
클라이언트 생성
import (
postv1 "github.com/repo/idl/gen/go/post/v1"
"google.golang.org/grpc"
)
conn, _ := grpc.Dial("post-service:50051", grpc.WithInsecure())
client := postv1.NewPostServiceClient(conn)
서비스 호출
resp, err := client.GetPost(ctx, &postv1.GetPostRequest{Id: 1})
if err != nil {
return err
}
// required 필드 - 바로 접근
fmt.Println(resp.Post.Title)
fmt.Println(resp.Post.User.Name)
// optional 필드 - nil 체크
if resp.Post.Category != nil {
fmt.Println(resp.Post.Category.Name)
}
Go는 포인터로 optional을 표현하므로 nil 체크만 하면 된다. TypeScript보다 단순하다.
Go는 왜 Generated 파일을 커밋하나?
IDL이 main에 머지되면:
- TypeScript: npm에 publish (
@repo/idl) - Go:
gen/go/가 Git에 커밋됨
왜 다르게 처리할까?
Go Modules의 동작 방식
Go modules는 go get으로 Git 저장소에서 직접 소스를 가져온다:
// post 서비스의 go.mod
require github.com/repo/idl v1.0.0
// post 서비스에서 import
import postv1 "github.com/repo/idl/gen/go/post/v1"
go get을 실행하면 Git 저장소를 클론해서 해당 버전의 소스를 가져온다. npm처럼 별도의 registry나 빌드 단계가 없다.
그래서 다른 서비스에서 import하려면 generated 파일이 저장소에 있어야 한다.
TypeScript는 다르다
TypeScript는 npm에 빌드 결과물을 올린다:
// package.json
{
"name": "@repo/idl",
"main": "gen/ts/index.js",
"files": ["gen/ts"],
"scripts": {
"prepublishOnly": "npm run generate:ts"
}
}
npm publish 할 때 prepublishOnly가 실행되면서 코드가 생성되고, 생성된 결과물이 npm에 올라간다. 소스 저장소엔 generated 파일이 필요 없다.
CI에서 자동화
GitHub Actions로 main 머지 시 자동 배포:
# .github/workflows/publish.yml
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Go 코드 생성 및 커밋
- name: Generate Go code
run: |
buf generate
git add gen/go
git commit -m "chore: regenerate go code" || true
git push
# npm publish
- name: Publish to npm
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
마무리
Protobuf와 gRPC를 사용한 MSA 통신 방식의 핵심은 인터페이스 우선 개발과 공동 소유권이다.
Proto 파일을 먼저 정의하면:
- Frontend와 Backend가 동시에 개발을 시작할 수 있다
- 타입 안전한 코드가 자동 생성된다
- IDL 저장소를 통해 팀 간 협업이 자연스러워진다
기술적 포인트:
useOptionals=none: TypeScript에서 불필요한 optional 제거로 더 깔끔한 코드- Go는 generated 파일 커밋: Go modules가 Git에서 직접 소스를 가져오기 때문
- TypeScript는 npm publish: 빌드 결과물만 npm에 올림
REST API에 익숙하다면 처음엔 낯설 수 있지만, 한번 세팅해두면 서비스가 늘어날수록 그 가치를 체감하게 된다.