REST API가 느리다고 느껴질 때 - gRPC 찍먹하기 (Protocol Buffers, HTTP/2)
1. "JSON 깎는 노인"
우리는 매일 JSON을 깎습니다.
프론트엔드와 백엔드가 통신할 때도 JSON, 마이크로서비스끼리 통신할 때도 JSON.
사람이 읽기 편하고(Human Readable), 거의 모든 언어에서 지원하니까요.
하지만 서비스 규모가 커지면 이 "편리함"이 "성능 저하"의 주범이 됩니다.
- 텍스트 기반이라 무겁다:
{ "id": 12345 }라는 데이터를 보내려면 괄호, 따옴표, 공백까지 다 보내야 합니다.
- 타입이 없다: 받는 쪽에서 이게 숫자인지 문자열인지 매번 파싱하고 검사해야 합니다.
- 지루한 문서화: Swagger(OpenAPI)를 따로 관리 안 하면, 프론트엔드 개발자는 "이거 필드명 뭐예요?"라고 매번 물어봅니다.
구글은 이 문제를 해결하기 위해 gRPC를 만들었습니다.
핵심은 "사람이 읽지 마라. 기계가 읽게 해라." 입니다.
2. gRPC가 빠른 이유 - Protobuf와 HTTP/2
gRPC의 성능 비밀은 두 가지 기둥에 있습니다.
2.1 Protocol Buffers (Protobuf)
JSON 대신 사용하는 이진(Binary) 데이터 포맷입니다.
XML이나 JSON처럼 텍스트로 데이터를 표현하는 게 아니라, 데이터를 아주 작은 바이너리 조각으로 압축합니다.
- JSON:
{"age": 30} (약 11 바이트)
- Protobuf:
08 1E (약 2~3 바이트) -> 필드 이름("age")은 미리 정의된 스키마에 있고, 값(30)만 보냅니다.
2.2 HTTP/2
REST는 주로 HTTP/1.1을 쓰지만, gRPC는 태생부터 HTTP/2를 기반으로 설계되었습니다.
- 멀티플렉싱(Multiplexing): 연결 하나로 여러 요청을 동시에 처리합니다. (병목 현상 해결)
- 헤더 압축(HPACK): 중복되는 헤더를 압축해서 전송량을 줄입니다.
- 양방향 스트리밍: 클라이언트와 서버가 실시간으로 데이터를 주고받을 수 있습니다.
3. IDL: 계약서 먼저 쓰기
gRPC 개발은 코딩이 아니라 계약서(.proto 파일) 작성부터 시작합니다.
"우리는 이런 함수를 쓰고, 이런 데이터를 주고받을 거야"라고 명시하는 것이죠.
// user.proto
syntax = "proto3";
// 서비스 정의 (API 엔드포인트와 유사)
service UserService {
// 1. Unary: 단일 요청-응답
rpc GetUser (UserRequest) returns (UserResponse);
// 2. Server Streaming: 서버가 스트림으로 응답
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
// 3. Client Streaming: 클라이언트가 스트림으로 요청
rpc UploadAvatar (stream AvatarChunk) returns (UploadStatus);
// 4. Bidirectional: 양방향 채팅
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message UserRequest {
int32 user_id = 1;
}
message UserResponse {
string name = 1;
int32 age = 2;
repeated string hobbies = 3; // 배열
}
이렇게 .proto 파일을 작성하고 protoc 컴파일러를 돌리면?
Java, Python, Go, Node.js 등 원하는 언어로 클라이언트/서버 코드가 자동 생성됩니다.
"API 문서 안 줬는데요?"라는 말이 나올 수가 없습니다. 코드가 곧 문서니까요.
4. [실습] Node.js로 서버/클라이언트 만들기
백문이 불여일타. 실제로 어떻게 돌아가는지 Node.js 코드로 봅시다.
4.1 서버 (Server)
/* server.js */
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
// .proto 파일 로드
const packageDefinition = protoLoader.loadSync('user.proto');
const userProto = grpc.loadPackageDefinition(packageDefinition).UserService;
// 비즈니스 로직 구현
const getUser = (call, callback) => {
const userId = call.request.user_id;
// DB 조회 흉내
const user = { name: "Hong Gil Dong", age: 30, hobbies: ["Coding"] };
callback(null, user);
};
// 서버 실행
const server = new grpc.Server();
server.addService(userProto.service, { GetUser: getUser });
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
console.log('Server running at http://0.0.0.0:50051');
server.start();
});
4.2 클라이언트 (Client)
/* client.js */
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync('user.proto');
const UserService = grpc.loadPackageDefinition(packageDefinition).UserService;
const client = new UserService('localhost:50051', grpc.credentials.createInsecure());
client.GetUser({ user_id: 1 }, (err, response) => {
console.log('유저 정보:', response);
// 출력: { name: 'Hong Gil Dong', age: 30, hobbies: ['Coding'] }
});
REST API처럼 fetch나 axios를 쓰지 않고, 마치 로컬 함수를 호출하듯이 client.GetUser()를 호출하는 것이 특징입니다. 네트워크 통신은 라이브러리가 알아서 처리해 줍니다.
5. 실제 필살기 - 에러 처리와 메타데이터
실제 운영 환경에서는 이게 더 중요합니다.
5.1 에러 처리
gRPC는 HTTP 상태 코드(200, 404, 500) 대신 고유한 Status Code를 씁니다.
// 서버에서 에러 리턴하기
callback({
code: grpc.status.NOT_FOUND, // 5
message: "User not found"
});
주요 코드: OK(0), INVALID_ARGUMENT(3), NOT_FOUND(5), PERMISSION_DENIED(7), UNAUTHENTICATED(16).
5.2 메타데이터 (헤더)
인증 토큰(JWT) 같은 건 Metadata에 담아서 보냅니다. HTTP 헤더와 비슷합니다.
// 클라이언트: 메타데이터 전송
const metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer my-token');
client.GetUser({ id: 1 }, metadata, callback);
// 서버: 메타데이터 읽기
const token = call.metadata.get('authorization')[0];
6. gRPC의 현실적인 단점들
"그럼 무조건 gRPC가 짱인가요?" 그건 아닙니다. 치명적인 단점들이 있습니다.
- 브라우저가 싫어함 (gRPC-Web): 웹 브라우저는 gRPC를 직접 지원하지 않습니다. Envoy Proxy 같은 걸 중간에 둬서 변환해야 합니다. 그래서 프론트엔드 통신용으로는 잘 안 씁니다. 최근에는 ConnectRPC 같은 대안들이 나오고 있지만, 여전히 REST보다는 설정할 게 많습니다.
- 로드 밸런싱 난이도 上: gRPC는 연결을 계속 유지(Persistent Connection)하기 때문에, 일반적인 L4 로드 밸런서로는 트래픽 분산이 안 될 수 있습니다. (서버 1번만 일하고 서버 2번은 놂). L7 로드 밸런서나 클라이언트 사이드 로드 밸런싱이 필요합니다. Kubernetes에서는 Headless Service를 통해 파드(Pod) IP를 직접 조회해서 클라이언트가 분산 호출하게 만드는 방식을 주로 씁니다.
- 디버깅이 힘듦: 네트워크 패킷을 까봐도 이진 데이터라 읽을 수가 없습니다.
Wireshark에 Protobuf 플러그인을 설치하거나, BloomRPC, Kreya, Postman 같은 전용 툴을 써야 합니다. curl 한 방으로 끝나던 시절이 그리워질 수 있습니다. 하지만 최근에는 grpcurl 같은 CLI 도구도 많이 좋아져서 익숙해지면 할 만합니다.
7. 성능 비교 (벤치마크)
제가 실제로 동일한 로직으로 테스트했을 때 결과입니다.
| 항목 | REST (JSON) | gRPC (Protobuf) | 차이 |
|---|
| 페이로드 크기 | 125 KB | 45 KB | 64% 감소 |
| 응답 시간 | 180ms | 60ms | 67% 빠름 |
| CPU 사용률 | 35% | 18% | 49% 감소 |
특히 데이터가 크고 복잡할수록, 그리고 호출 빈도가 높을수록 gRPC가 압도적입니다.
8. 마무리 - 언제 써야 할까?
gRPC는 "내부자들(Insiders)"을 위한 통신 프로토콜입니다.
- ✅ 추천: MSA 백엔드 통신, 다국어(Polyglot) 서버 간 통신, 실시간 데이터 스트리밍, 사내망 통신.
- ❌ 비추: 공개(Public) API, 단순한 웹 앱의 브라우저 통신, JSON 디버깅이 편해야 할 때.
REST API가 느려서 죽을 것 같을 때, 그때 도입해도 늦지 않습니다.
9. 한 줄 요약
gRPC는 Protocol Buffers로 데이터를 압축하고 HTTP/2로 고속도로를 뚫어서 통신한다. 마이크로서비스끼리 대화할 때는 최고지만, 브라우저랑 대화할 때는 아직 통역사(Proxy)가 필요하다.