프론트엔드 통신 방식 (4): gRPC와 gRPC-Web, Protocol Buffers

Development

지금까지의 통신 방식 — REST, SSE, WebSocket — 은 공통점이 있었습니다. 사람이 읽을 수 있는 텍스트(주로 JSON)를 주고받고, 브라우저가 기본으로 지원한다는 점입니다.

gRPC는 그 전제를 거의 다 뒤집습니다. 바이너리로 주고받고, 스키마에서 코드를 생성하며, HTTP/2를 깔고, 결정적으로 브라우저에서 그냥은 안 됩니다. 그래서 gRPC는 "최신이고 빠른 기술"이라는 인상과, "프론트엔드에서 쓰기 까다롭다"는 평판을 동시에 가집니다.

이번 편에서는 gRPC가 무엇을 풀려는 기술인지, 왜 브라우저에서 마찰이 생기는지, 그리고 프론트엔드 개발자가 실제로 마주하는 선택지(gRPC-Web, Connect)를 트레이드오프 중심으로 정리합니다.

이 글은 프론트엔드 통신 방식 시리즈의 4편입니다.

한눈에 보면

  • gRPC는 RPC(원격 함수 호출) 패러다임입니다. 자원(resource)이 아니라 함수를 호출하는 모델이라 REST와 사고방식이 다릅니다.
  • 메시지는 Protocol Buffers(protobuf) 라는 바이너리 포맷이고, .proto 파일이 계약이자 코드 생성의 원천입니다.
  • gRPC는 HTTP/2를 전제로 멀티플렉싱과 4종 스트리밍(단항/서버/클라이언트/양방향)을 제공합니다.
  • 문제는 브라우저가 HTTP/2 프레임을 그 수준으로 제어하지 못해, 순정 gRPC를 직접 호출할 수 없다는 점입니다. 그래서 gRPC-Web + 프록시(Envoy 등) 가 필요합니다.
  • 프론트엔드 관점의 진짜 장점은 속도보다 스키마 기반의 강한 타입 계약과 코드 생성입니다.
  • 최근에는 이 마찰을 줄인 Connect 같은 대안이 브라우저 친화적 선택지로 떠올랐습니다.

RPC라는 다른 사고방식

먼저 패러다임의 차이를 잡아야 합니다. REST자원 중심입니다. "사용자 자원을 GET 한다"처럼 명사(자원)와 HTTP 동사로 표현합니다.

RPC(Remote Procedure Call)는 함수 중심입니다. "원격에 있는 함수를 마치 로컬 함수처럼 호출한다"가 핵심입니다.

// REST 사고방식: 자원에 동사를 적용
GET / users / 1;
PATCH / users / 1;
 
// RPC 사고방식: 함수를 호출
getUser({ id: 1 });
updateUserEmail({ id: 1, email: 'a@b.com' });

이 차이는 단순한 문법 차이가 아닙니다.

  • REST는 HTTP의 공통 의미(메서드, 상태 코드, 캐시)를 활용해 균일한 인터페이스를 지향합니다.
  • RPC는 도메인에 딱 맞는 함수 시그니처를 지향합니다. 그래서 "결제를 승인한다", "매치를 시작한다" 같은 행위 중심 API가 자연스럽습니다.

REST가 어색해지는 지점(복잡한 행위, 도메인 특화 연산)이 바로 RPC가 자연스러운 지점입니다. gRPC는 이 RPC 모델에 바이너리 직렬화와 HTTP/2를 결합한 구현체입니다.

계약의 원천: Protocol Buffers와 .proto

gRPC의 심장은 .proto 파일입니다. 여기에 데이터 구조(message)와 호출 가능한 함수(service) 를 함께 정의합니다.

syntax = "proto3";
package user.v1;
 
message GetUserRequest {
  int64 id = 1;
}
 
message User {
  int64 id = 1;
  string name = 2;
  string email = 3;
}
 
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  // 서버 스트리밍: 한 번 요청, 여러 응답
  rpc WatchUser(GetUserRequest) returns (stream User);
}

여기서 두 가지가 결정적으로 중요합니다.

1. 이것이 곧 계약이다

REST에서는 계약(어떤 필드가 오는지)이 보통 문서나 OpenAPI에 따로 있고, 코드와 어긋날 수 있습니다. gRPC에서는 .proto가 단일 진실 원천(single source of truth) 입니다. 프론트엔드와 백엔드가 같은 파일을 공유하므로, 한쪽이 필드를 바꾸면 다른 쪽 생성 코드가 곧바로 영향을 받습니다.

2. 여기서 코드가 생성된다

.proto에서 TypeScript 타입과 클라이언트 스텁(호출 함수)을 자동 생성합니다. 손으로 타입을 적지 않습니다.

// .proto에서 생성된 코드를 사용하는 모습 (개념적)
import { UserServiceClient } from './gen/user/v1/user_connect';
 
const client = new UserServiceClient(transport);
const user = await client.getUser({ id: 1n });
//    ^? User 타입이 자동으로 추론됨

프론트엔드 관점에서 이것이 gRPC의 진짜 매력입니다. 속도(바이너리가 JSON보다 작고 빠름)도 장점이지만, 대부분의 웹 화면에서 그 차이는 크지 않습니다. 반면 "백엔드가 바꾸면 내 빌드가 깨져서 알려준다" 는 타입 안정성은 매일 체감됩니다.

필드 번호와 호환성

= 1, = 2 같은 필드 번호는 protobuf의 핵심입니다. 바이너리 인코딩이 필드 이름이 아니라 이 번호를 기준으로 하기 때문입니다. 그래서:

  • 필드 이름은 바꿔도 호환되지만, 번호는 절대 재사용하면 안 됩니다.
  • 새 필드는 새 번호로 추가하면 구버전과 호환됩니다(전방/후방 호환).

이 규칙 덕분에 gRPC는 API를 점진적으로 진화시키는 데 강합니다.

바이너리가 작다는 건 정확히 무슨 뜻인가: 와이어 포맷 들여다보기

"protobuf는 바이너리라 작다"는 말은 자주 듣지만, 왜 작은지를 보면 트레이드오프가 분명해집니다.

JSON은 필드 이름을 매번 문자열로 같이 보냅니다.

{ "id": 1, "name": "Marco" }

여기서 "id", "name" 같은 키, 따옴표, 중괄호가 전부 바이트를 차지합니다. 같은 메시지를 100만 번 보내면 "name"이라는 글자도 100만 번 전송됩니다.

protobuf는 필드 이름을 보내지 않습니다. 대신 .proto의 필드 번호(태그)와 타입만 인코딩합니다. 위 GetUserRequest { id = 1 }에서 id: 1은 대략 이렇게 표현됩니다.

0x08 0x01
 │     └── 값: 1 (varint)
 └──────── 태그 바이트: (필드번호 1 << 3) | wire_type 0(varint) = 0x08

단 2바이트입니다. 핵심 기법은 두 가지입니다.

  • 태그 = 필드번호 + 와이어 타입을 한 바이트에 욱여넣습니다. 그래서 필드 번호가 계약의 키이고, 이름은 와이어에 존재하지 않습니다. (4절에서 "이름은 바꿔도 되지만 번호는 영구적"이라고 한 이유가 여기 있습니다.)
  • varint(가변 길이 정수): 작은 수는 1바이트, 큰 수만 여러 바이트를 씁니다. 그래서 id: 1은 1바이트지만 id: 300은 2바이트입니다.

이 설계가 만드는 실제 결과:

  • 반복·고빈도·작은 메시지에서 절감이 큽니다. 필드 이름 오버헤드가 사라지므로 텔레메트리, 시세 틱, 게임 상태처럼 작은 메시지를 초당 수천 번 보낼 때 차이가 누적됩니다.
  • 반대로 한 번에 큰 JSON 한 덩이를 받는 화면에서는 차이가 작습니다. gzip이 JSON의 반복 키를 잘 압축해 버리기 때문입니다. 실측하면 "압축된 JSON vs protobuf"의 크기 차이가 생각보다 작은 경우가 많습니다.
  • 사람이 못 읽습니다. 0x08 0x01을 네트워크 탭에서 디버깅할 수 없다는 비용을 직접 지불합니다(뒤 디버깅 절).

시니어의 결론: protobuf를 "무조건 빠르니까"로 정당화하면 안 됩니다. 반복·고빈도 트래픽이 아니라면 크기 이득은 작고 디버깅 비용만 남습니다. 효율은 부차적 이점이고, 본질적 가치는 계약과 코드 생성입니다.

gRPC가 HTTP/2를 전제하는 이유와 4종 스트리밍

gRPC는 HTTP/2 위에서 동작합니다. HTTP/2의 멀티플렉싱(하나의 연결로 여러 스트림 동시 처리) 덕분에 gRPC는 네 가지 통신 패턴을 제공합니다.

패턴 설명
Unary (단항) 1요청 → 1응답 (REST와 유사) 사용자 조회
Server streaming 1요청 → N응답 실시간 시세 구독
Client streaming N요청 → 1응답 대용량 업로드
Bidirectional N요청 ↔ N응답 채팅, 양방향 동기화

이 4종은 이전 편들에서 본 패턴들을 하나의 모델로 통합합니다. 서버 스트리밍은 SSE와, 양방향 스트리밍은 WebSocket과 역할이 겹칩니다. 차이는 gRPC는 이 모든 것을 하나의 타입 안전한 계약과 코드 생성 안에서 제공한다는 점입니다.

핵심 문제: 브라우저에서는 왜 그냥 안 되는가

여기가 프론트엔드에게 가장 중요한 부분입니다. 브라우저는 순정 gRPC를 직접 호출할 수 없습니다.

이유는 gRPC가 HTTP/2의 저수준 프레임(트레일러, 스트림 제어 등)을 직접 다루는데, 브라우저의 fetch/XHR는 그 수준의 제어를 노출하지 않기 때문입니다. 즉 gRPC는 서버-서버, 모바일-서버 통신에는 훌륭하지만 브라우저-서버에는 그대로 맞지 않습니다.

그래서 나온 것이 gRPC-Web입니다.

graph LR
  B["브라우저<br/>(gRPC-Web 클라이언트)"] -->|gRPC-Web<br/>over HTTP/1.1·2| P["프록시<br/>(Envoy 등)"]
  P -->|순정 gRPC<br/>over HTTP/2| S["gRPC 서버"]

gRPC-Web은 브라우저가 보낼 수 있는 형태로 프로토콜을 살짝 변형한 버전이고, 중간에 프록시(주로 Envoy) 가 이를 순정 gRPC로 번역해 백엔드에 전달합니다. 즉 gRPC를 브라우저에서 쓰려면 프록시 계층이 사실상 필수입니다.

여기에 더해 gRPC-Web에는 한동안 중요한 제약이 있었습니다. 클라이언트 스트리밍과 양방향 스트리밍을 브라우저에서 제대로 지원하지 못했습니다. 단항과 서버 스트리밍은 되지만, 브라우저에서 진짜 양방향이 필요하면 결국 WebSocket으로 돌아가게 되는 경우가 많았습니다.

이것이 1편에서 비교표에 gRPC를 "⚠️ 프록시 필요"로 표시한 이유입니다. 인프라 마찰이 기술 선택을 뒤집는 전형적 사례입니다.

디버깅이라는 또 다른 비용

REST·SSE·GraphQL은 브라우저 네트워크 탭에서 페이로드를 바로 읽을 수 있습니다. gRPC는 바이너리라서 그렇지 않습니다.

  • 네트워크 탭에서 요청 본문이 알아볼 수 없는 바이트로 보입니다.
  • 문제를 추적하려면 전용 도구(예: gRPC 지원 클라이언트, 프록시 로그)가 필요합니다.
  • "curl로 한번 찔러본다" 같은 빠른 검증이 어렵습니다.

이 디버깅 마찰은 표에는 잘 안 드러나지만, 장애 대응 속도와 신입 온보딩 비용에 실제로 영향을 줍니다. 시니어가 gRPC 도입을 망설이는 흔한 이유 중 하나입니다.

마찰을 줄인 대안: Connect

이런 브라우저 마찰을 의식하고 나온 것이 Connect(connect-es) 같은 도구입니다. 핵심 아이디어는 같은 .proto 계약과 코드 생성의 장점은 유지하되, 브라우저 친화적인 전송을 쓰는 것입니다.

  • 평범한 HTTP/1.1에서도 동작하고, 별도 프록시(Envoy) 없이 쓸 수 있는 구성을 제공합니다.
  • 같은 엔드포인트를 gRPC, gRPC-Web, 그리고 사람이 읽을 수 있는 JSON(Connect 프로토콜)로 동시에 노출할 수 있어, 디버깅 시 JSON으로 들여다보기가 가능합니다.
  • gRPC-Web 클라이언트와 호환되는 코드 생성을 제공합니다.
// Connect 클라이언트 사용 예 (개념적)
import { createConnectTransport } from '@connectrpc/connect-web';
import { createClient } from '@connectrpc/connect';
import { UserService } from './gen/user/v1/user_connect';
 
const transport = createConnectTransport({ baseUrl: '/api' });
const client = createClient(UserService, transport);
 
const user = await client.getUser({ id: 1n }); // 타입 안전 + 디버깅 가능

요점은 이렇습니다. "protobuf 계약 + 코드 생성"이라는 gRPC의 본질적 장점을 원하면서 브라우저 마찰은 줄이고 싶다면, 순정 gRPC-Web보다 Connect 계열이 프론트엔드에 더 현실적인 선택일 때가 많습니다.

REST에는 없던 1급 시민들: 데드라인, 취소, 에러 모델

gRPC를 진지하게 쓰면, REST에서는 라이브러리마다 제각각이던 것들이 프로토콜 차원의 1급 개념으로 들어옵니다. 이게 프론트엔드 실무에 실제로 영향을 줍니다.

데드라인(Deadline)은 타임아웃과 다르다

REST의 클라이언트 타임아웃은 "내가 N초 기다리다 포기한다"입니다. 서버는 이 사실을 모른 채 계속 일합니다. gRPC의 데드라인은 호출 체인을 따라 전파(propagation) 됩니다. 클라이언트가 "이 호출은 3시 0분 5초까지"라고 정하면, 그 호출을 받은 서버가 다시 다른 서비스를 부를 때 남은 시간만 물려줍니다. 데드라인이 지나면 체인 전체가 즉시 취소됩니다.

// Connect 클라이언트: 호출 단위 데드라인 + AbortSignal 취소
const user = await client.getUser(
  { id: 1n },
  {
    timeoutMs: 3000, // 이 호출의 데드라인
    signal: abortController.signal, // 사용자가 화면을 떠나면 취소
  }
);

프론트엔드 관점에서 중요한 점: 컴포넌트 언마운트 시 AbortController로 호출을 취소하면, 그 취소가 백엔드까지 전파되어 불필요한 DB 작업을 멈출 수 있습니다. REST에서는 보통 클라이언트만 포기하고 서버 작업은 계속됩니다. 이건 단순 편의가 아니라 서버 부하 절감으로 이어지는 구조적 차이입니다.

에러는 숫자가 아니라 코드다

REST는 HTTP 상태 코드(404, 500…)로 에러를 표현하는데, 도메인 에러를 거기에 욱여넣다 보면 애매해집니다("권한 없음인데 404를 줄까 403을 줄까"). gRPC는 전용 상태 코드 집합(NOT_FOUND, PERMISSION_DENIED, DEADLINE_EXCEEDED, UNAVAILABLE, RESOURCE_EXHAUSTED 등)을 가집니다.

import { ConnectError, Code } from '@connectrpc/connect';
 
try {
  await client.getUser({ id: 1n });
} catch (err) {
  if (err instanceof ConnectError) {
    switch (err.code) {
      case Code.NotFound:
        return showEmptyState();
      case Code.Unauthenticated:
        return redirectToLogin();
      case Code.DeadlineExceeded:
      case Code.Unavailable:
        return retryWithBackoff(); // 재시도가 의미 있는 코드
      default:
        return showError(err.message);
    }
  }
}

여기서 시니어가 챙기는 디테일: 어떤 코드가 재시도 가능한가입니다. UNAVAILABLE, DEADLINE_EXCEEDED는 재시도가 합리적이지만, INVALID_ARGUMENTNOT_FOUND는 재시도해도 똑같이 실패합니다. 이 분류가 명확해지는 것이 gRPC 에러 모델의 실질적 가치입니다.

인터셉터: 횡단 관심사를 한곳에서

gRPC/Connect에는 모든 호출을 감싸는 인터셉터(interceptor) 가 있습니다. 인증 토큰 주입, 로깅, 재시도, 메트릭 같은 횡단 관심사를 호출마다 반복하지 않고 한곳에 둡니다.

import { Interceptor } from '@connectrpc/connect';
 
// 모든 호출에 인증 토큰을 붙이는 인터셉터
const authInterceptor: Interceptor = (next) => async (req) => {
  req.header.set('Authorization', `Bearer ${getAccessToken()}`);
  return await next(req);
};
 
// 일시적 실패(UNAVAILABLE)만 제한적으로 재시도하는 인터셉터
const retryInterceptor: Interceptor = (next) => async (req) => {
  let lastErr: unknown;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await next(req);
    } catch (err) {
      lastErr = err;
      if (!(err instanceof ConnectError) || err.code !== Code.Unavailable) throw err;
      await sleep(2 ** attempt * 200); // 백오프
    }
  }
  throw lastErr;
};
 
const transport = createConnectTransport({
  baseUrl: '/api',
  interceptors: [authInterceptor, retryInterceptor],
});

이 구조는 Axios 인터셉터로 토큰을 주입하던 패턴과 발상이 같습니다. 차이는 타입 안전한 메서드 위에 얹힌다는 점입니다.

스트리밍을 실제 코드로: 서버 스트리밍 구독

4종 스트리밍을 말로만 듣는 것과 코드로 보는 것은 다릅니다. 브라우저에서 확실히 동작하는 서버 스트리밍(시세 구독 같은) 예제입니다. 생성된 클라이언트는 비동기 이터러블을 돌려줍니다.

// .proto: rpc WatchPrice(WatchRequest) returns (stream PriceTick);
 
async function subscribePrice(symbol: string, abort: AbortSignal) {
  try {
    // for-await로 서버가 흘려보내는 메시지를 순차 소비
    for await (const tick of client.watchPrice({ symbol }, { signal: abort })) {
      updateChart(tick); // tick은 PriceTick 타입으로 추론됨
    }
  } catch (err) {
    if (err instanceof ConnectError && err.code === Code.Canceled) return; // 정상 취소
    scheduleReconnect(); // 비정상 종료 → 재구독
  }
}

여기서 3편의 교훈이 그대로 돌아옵니다. 스트림이 끊기면 재구독은 직접 만들어야 하고, 끊긴 구간에 놓친 틱을 어떻게 메울지(마지막 수신 시점부터 다시 요청)는 애플리케이션 설계입니다. gRPC가 스트리밍을 1급으로 제공한다고 해서 재연결·동기화의 본질적 비용이 사라지지는 않습니다.

그리고 클라이언트 스트리밍·양방향 스트리밍은 브라우저(gRPC-Web)에서 일반적으로 막힙니다. 위 서버 스트리밍은 되지만, 브라우저에서 진짜 양방향이 필요하면 결국 WebSocket으로 돌아가게 되는 경우가 많다는 점을 다시 강조합니다.

계약을 코드로 바꾸는 파이프라인: buf

gRPC의 장점인 "코드 생성"은 공짜가 아니라 빌드 파이프라인입니다. 이 파이프라인을 팀이 운영할 수 있느냐가 도입 가능성을 좌우합니다. 요즘 사실상 표준은 buf입니다.

# buf.gen.yaml — .proto에서 TS 클라이언트를 생성하는 설정
version: v2
plugins:
  - remote: buf.build/bufbuild/es # protobuf 메시지 → TS 타입
    out: src/gen
  - remote: buf.build/connectrpc/es # service → 타입 안전 클라이언트
    out: src/gen
buf lint        # .proto 스타일/호환성 규칙 검사
buf breaking --against '.git#branch=main'  # 호환성 깨는 변경 차단(핵심!)
buf generate    # 클라이언트 코드 생성

여기서 buf breaking이 결정적입니다. 누군가 필드 번호를 재사용하거나 필드를 지우는 호환성 파괴 변경을 CI에서 자동으로 막습니다. 이것이 "강한 계약"의 실체입니다. 사람이 리뷰로 잡는 게 아니라 도구가 강제합니다. 다만 이 파이프라인 자체가 신입 온보딩과 모노레포 빌드의 복잡도를 더한다는 점도 비용으로 계산해야 합니다.

프론트엔드 관점의 트레이드오프 종합

시니어가 gRPC(-Web)를 저울에 올릴 때 보는 항목입니다.

장점

  • 강한 계약: .proto가 단일 진실 원천. 프론트-백 타입 불일치가 빌드 타임에 잡힙니다.
  • 코드 생성: 타입·클라이언트를 손으로 안 적습니다. 대규모 API에서 생산성이 큽니다.
  • 효율: 바이너리라 페이로드가 작고 직렬화가 빠릅니다(고빈도·대용량에서 유의미).
  • 스트리밍 모델 통합: 단항~양방향을 하나의 계약으로.
  • 운영 1급 개념: 데드라인 전파, 취소, 표준 에러 코드, 인터셉터가 프로토콜 차원에서 제공됩니다.

비용

  • 브라우저 마찰: 프록시 필요, 양방향 스트리밍 제약(순정 gRPC-Web 기준).
  • 디버깅 어려움: 바이너리라 네트워크 탭·curl이 막힙니다.
  • 캐시/CDN 비친화: HTTP 캐시 의미를 활용하기 어렵습니다.
  • 생태계·러닝커브: 빌드 파이프라인(코드 생성), 팀 학습 비용.

언제 합리적인가

  • 내부 마이크로서비스 간 통신(브라우저가 끼지 않음)에는 거의 항상 강력합니다.
  • 프론트엔드라면, API 표면이 크고 타입 계약이 핵심 가치이며, 팀이 코드 생성 파이프라인을 운영할 의지가 있을 때. 이 경우 Connect 계열을 우선 검토하세요.
  • 반대로 단순 CRUD 위주의 공개 웹이라면, gRPC의 비용이 이점을 넘는 경우가 많아 REST나 GraphQL이 더 합리적입니다.

도입 전에 짚어둘 함정들

여기서부터는 직접 운영하며 겪은 사례가 아니라, 명세와 자료에서 공통적으로 지적되는 함정들입니다. 말로 단정하는 대신, 가능한 부분은 바이트 수와 인코딩으로 그 자리에서 직접 따져보겠습니다.

함정 1: "빠를 줄 알았는데 더 느려질 수 있다"

공개 웹의 조회 API를 "성능"을 이유로 gRPC-Web으로 바꾸는 상황을 생각해 봅시다. 먼저 크기부터 직접 세어보겠습니다. 앞의 User 일부인 { id: 1, name: "Marco" }를 두 형식으로 표현하면:

JSON     : {"id":1,"name":"Marco"}              → 23바이트
protobuf : 08 01  12 05 4D 61 72 63 6F          → 9바이트
           (id=1) (name 길이5 + "Marco")

확실히 protobuf가 작습니다(9 vs 23). 하지만 여기서 멈추면 안 됩니다.

  • 이렇게 키 이름이 반복되는 JSON은 gzip이 잘 압축합니다. 같은 응답이 배열로 100개 들어 있으면 "id", "name"이 100번 반복되는데, 압축은 이 반복을 거의 공짜로 없앱니다. 그래서 압축 후 실측 차이는 위의 9 vs 23보다 훨씬 줄어듭니다.
  • 브라우저 ↔ 프록시 ↔ gRPC 서버로 홉이 하나 늘어 오히려 왕복 지연이 커질 수 있습니다.
  • GET 캐시를 잃어 CDN에 얹혀 있던 무료 캐시가 통째로 사라집니다.

즉 원시 크기 이득(9 vs 23)은 분명하지만, gzip·홉·캐시까지 넣고 보면 "효율" 하나만으로는 브라우저 gRPC를 정당화하기 어렵습니다. 캐시 친화적이던 공개 조회 API는 gRPC로 옮길 때 잃는 것이 더 많을 수 있습니다.

함정 2: 필드 번호 재사용으로 인한 조용한 데이터 오염

이것도 바이트로 따져보면 분명해집니다. int32 age = 5에 값 30이 들어가면 와이어에는 이렇게 인코딩됩니다.

0x28 0x1E
 │     └── 값 30 (varint)
 └──────── 태그: (필드번호 5 << 3) | 0(varint) = 0x28

이제 누군가 안 쓰는 age를 지우고, 같은 번호 5를 같은 와이어 타입(varint)의 다른 필드 bool isActive = 5로 재사용했다고 합시다. 구버전이 보낸 0x28 0x1E를 신버전 디코더는 "필드 5, varint 값 30"으로 읽고, bool에서는 0이 아닌 값이 true이므로 결과적으로 isActive = true가 됩니다. 타입 에러도 예외도 없이 age=30isActive=true로 둔갑합니다.

반대로 와이어 타입이 다른 타입으로 바꾸면(예: varint인 int32를 length-delimited인 string으로) 태그 바이트 자체가 0x28에서 0x2A로 달라져 보통 디코드 에러로 드러납니다. 정작 무서운 건 위처럼 에러조차 없이 의미만 조용히 바뀌는 경우입니다.

이것이 와이어 포맷이 번호 기반이라는 사실의 무게이고, buf breaking을 CI에 넣어 필드 번호 재사용 자체를 막아야 하는 이유입니다.

함정 3: 데드라인을 안 정하면 지연이 전파된다

이건 데드라인 전파 모델에서 논리적으로 따라 나옵니다. A가 B를, B가 C를 부르는 체인에서 아무도 데드라인을 정하지 않으면, C가 느려질 때 B의 호출도, A의 호출도 끝나지 못하고 그대로 쌓입니다. 보류된 호출이 워커를 점유하니, 하나의 느린 의존성이 호출 체인 전체를 끌어내립니다.

반대로 A가 "이 호출은 3초"라는 데드라인을 정하면 그 데드라인이 B·C로 전파되어, 시간이 지나는 즉시 체인 전체가 함께 취소됩니다. 그래서 데드라인은 "있으면 좋은 것"이 아니라 모든 호출에 기본으로 설정하는 안전장치로 보는 편이 안전합니다. REST에서 타임아웃을 빼먹는 것보다 파급이 큰데, 전파되는 만큼 멈추는 범위도 넓기 때문입니다.

정리된 판단 기준

상황 권장
내부 서비스 간(브라우저 없음) gRPC ✅ 강력
큰 API 표면 + 타입 계약이 핵심 + 코드젠 운영 가능 Connect 우선 검토
캐시 친화적 공개 조회 API REST 유지
화면마다 데이터 모양이 크게 다름 GraphQL(5편)
브라우저 양방향 스트리밍 필요 WebSocket(3편)

자주 하는 오해

1. gRPC는 REST보다 발전된 상위 호환이다

아닙니다. gRPC는 RPC(함수 중심) 패러다임이고 REST는 자원 중심입니다. 푸는 문제와 사고방식이 다릅니다. 더 새롭다고 더 우월한 게 아닙니다.

2. gRPC를 브라우저에서 바로 쓸 수 있다

아닙니다. 순정 gRPC는 브라우저에서 직접 호출되지 않습니다. gRPC-Web + 프록시가 필요하고, 양방향 스트리밍은 제약이 있습니다. Connect가 이 마찰을 줄여줍니다.

3. gRPC를 쓰는 이유는 빠르기 때문이다

부분적으로만 맞습니다. 대부분의 웹 화면에서 직렬화 속도 차이는 작습니다. 프론트엔드가 실제로 얻는 핵심 가치는 속도보다 강한 타입 계약과 코드 생성입니다.

4. protobuf 필드 번호는 그냥 순서다

아닙니다. 번호는 바이너리 인코딩의 키입니다. 재사용하면 호환성이 깨집니다. 이름은 바꿔도 되지만 번호는 영구적입니다.

정리하면

gRPC는 텍스트·자원 중심의 웹 기본값에서 벗어난, 바이너리·함수 중심·계약 우선의 통신 방식입니다.

짧게 정리하면:

  • RPC 패러다임: 자원이 아니라 함수를 호출합니다. 도메인 특화 행위에 자연스럽습니다.
  • .proto가 계약이자 코드 생성의 원천입니다. 프론트엔드가 얻는 핵심 가치는 속도보다 타입 안정성입니다.
  • 브라우저에서는 그대로 안 되며, gRPC-Web + 프록시가 필요하고 양방향 스트리밍에 제약이 있습니다.
  • 이 마찰을 줄인 Connect 계열이 프론트엔드에는 더 현실적인 선택일 때가 많습니다.

지금까지 본 모든 방식 — REST, 폴링/SSE, WebSocket, gRPC — 은 각자 다른 약점을 메웠습니다. 마지막 5편에서는 또 다른 각도에서 REST의 약점(오버페칭/언더페칭)을 푸는 GraphQL을 다루고, 지금까지의 모든 방식을 하나의 의사결정 가이드로 종합합니다.

같이 보면 좋은 글