ConnectRPC + Buf Schema Registry로 TypeScript gRPC 서비스 구축하기
마이크로서비스 팀 간에 API 계약을 안정적으로 공유하는 방법은 여러 가지가 있지만, 대부분은 문서 동기화나 "좀 더 꼼꼼한 커뮤니케이션"에 의존합니다. ConnectRPC와 Buf Schema Registry(BSR)는 이 문제를 구조적으로 접근합니다. .proto 파일 하나가 서버 핸들러 타입, 클라이언트 stub, 브라우저 훅을 동시에 만들어내고, 호환성을 깨는 변경은 레지스트리 레벨에서 자동 차단됩니다.
이 글에서는 왜 ConnectRPC가 브라우저 호환성 문제를 해결하는지, BSR의 자동 SDK 생성이 팀 간 협업을 어떻게 바꾸는지, 그리고 Node.js 서버부터 React 클라이언트까지 실제로 어떻게 연결하는지를 단계별로 다룹니다.
핵심 개념 이해하기
gRPC의 브라우저 문제와 ConnectRPC의 해법
gRPC는 HTTP/2 트레일러에 의존하는데, 브라우저가 이 트레일러를 JavaScript API로 노출하지 않습니다. 그래서 gRPC-Web이라는 우회 방식이 생겼지만, 이를 네이티브로 지원하는 서버가 없으면 Envoy 같은 프록시가 사실상 필수였습니다.
ConnectRPC는 서버 하나가 Connect·gRPC·gRPC-Web 세 가지 프로토콜을 동시에 수신하고, 브라우저는 표준 fetch API로 직접 서버를 호출합니다. Buf 자사 BSR 프런트엔드에서 Envoy를 걷어내고 Connect로 전환한 게 대표적인 프로덕션 사례입니다.
| 프로토콜 | 브라우저 직접 지원 | HTTP/1.1 | JSON 지원 | 프록시 필요 |
|---|---|---|---|---|
| gRPC | ✗ | ✗ | ✗ | ✓ 필수 |
| gRPC-Web | ✓ | ✓ | △ | ✓ 필수 |
| Connect | ✓ | ✓ | ✓ | ✗ |
Connect 프로토콜의 기본 인코딩은 JSON입니다. 개발 중 브라우저 DevTools 네트워크 탭에서 페이로드를 바로 읽을 수 있고, curl로 직접 호출해볼 수도 있습니다.
Buf Schema Registry — npm처럼 쓰는 Protobuf 레지스트리
BSR의 핵심은 자동 SDK 생성에 있습니다. .proto 파일을 BSR에 push하면 TypeScript, Go, Python 등 다국어 SDK를 즉시 빌드합니다. 중요한 점은 BSR이 npmjs.com에 패키지를 올리지 않고 자체 npm 호환 레지스트리(npm.buf.build)를 운영한다는 것입니다. B팀이 npm install @buf/...를 실행하기 전에 .npmrc 설정이 필요합니다.
2024년 후반부터 BSR Governance Workflow도 추가됐습니다. 호환성을 깨는(breaking) 변경이 포함된 push를 자동으로 차단하고, 코드 오너가 검토·승인·거부할 수 있는 리뷰 플로우입니다.
두 설정 파일 구분: buf.yaml과 buf.gen.yaml
처음 접하면 두 파일이 헷갈립니다. 역할이 완전히 다릅니다.
buf.yaml: Protobuf 모듈 자체를 정의합니다. 모듈 이름, 의존성, lint 규칙, breaking change 감지 레벨을 선언합니다. BSR에 push할 때 이 파일이 기준이 됩니다.buf.gen.yaml: 코드 생성을 제어합니다. 어떤 플러그인을 써서 어느 경로에 파일을 만들지를 지정합니다.buf generate를 실행할 때 읽히는 파일입니다.
쉽게 기억하면: buf.yaml은 "이 모듈은 무엇인가", buf.gen.yaml은 "이 모듈에서 무엇을 만들어낼 것인가"입니다.
코드 생성에 쓰는 패키지들
| 패키지 | 역할 |
|---|---|
@bufbuild/protobuf |
Protobuf 메시지 런타임 |
@bufbuild/protoc-gen-es |
TypeScript 타입 코드 생성 플러그인 |
@connectrpc/connect |
서버/클라이언트 코어 |
@connectrpc/protoc-gen-connect-es |
서비스 stub 코드 생성 플러그인 |
@connectrpc/connect-web |
브라우저용 전송 계층 |
@connectrpc/connect-query |
TanStack Query 통합 훅 |
코드 생성은 buf.gen.yaml 하나로 제어합니다.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/bufbuild/es
out: src/gen
- remote: buf.build/connectrpc/es
out: src/gen
inputs:
- module: buf.build/myorg/paymentsbuf generate를 실행하면 src/gen/ 아래에 *_pb.ts(메시지 타입)와 *_connect.ts(서비스 인터페이스)가 생성됩니다.
실전 구축 단계
사전 준비: buf CLI 설치
# macOS
brew install bufbuild/buf/buf
# 또는 공식 설치 스크립트
curl -sSL https://github.com/bufbuild/buf/releases/latest/download/buf-$(uname -s)-$(uname -m) -o buf
chmod +x buf && mv buf /usr/local/bin/설치 후 buf --version으로 정상 동작을 확인합니다.
1단계: 프로젝트 구조와 .proto 파일 작성
전체 디렉토리 구조를 먼저 잡고 시작합니다.
my-payments-app/
├── proto/
│ └── payments/
│ └── v1/
│ └── payments.proto
├── src/
│ ├── gen/ # buf generate 출력 (gitignore 가능)
│ │ └── payments/
│ │ └── v1/
│ │ ├── payments_pb.ts
│ │ └── payments_connect.ts
│ ├── server.ts
│ └── client/
│ ├── providers.tsx
│ ├── App.tsx
│ └── PaymentForm.tsx
├── buf.yaml # 모듈 정의
├── buf.gen.yaml # 코드 생성 설정
└── buf.lock서비스 정의를 작성합니다.
// proto/payments/v1/payments.proto
syntax = "proto3";
package payments.v1;
message CreatePaymentRequest {
string user_id = 1;
int64 amount_cents = 2;
string currency = 3;
}
message CreatePaymentResponse {
string payment_id = 1;
string status = 2;
}
service PaymentsService {
rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}모듈을 정의하는 buf.yaml입니다.
# buf.yaml
version: v2
name: buf.build/myorg/payments
lint:
use:
- DEFAULT
breaking:
use:
- FILEbreaking: use: - FILE은 파일 수준 호환성을 검사합니다. 필드 제거, 타입 변경, RPC 메서드 삭제처럼 기존 클라이언트가 깨질 수 있는 변경을 감지합니다. WIRE(직렬화 호환성만 검사)보다 엄격하고 대부분의 팀에 권장되는 수준입니다. 이 설정이 BSR의 push 차단 기준으로 연동됩니다.
buf.lock은 package-lock.json과 같은 역할이므로 반드시 git에 커밋합니다.
2단계: 로컬 코드 생성 확인
BSR 계정이 없어도 로컬에서 먼저 코드 생성을 확인할 수 있습니다.
buf generatesrc/gen/payments/v1/ 아래에 payments_pb.ts와 payments_connect.ts가 생성됩니다. 생성된 파일을 git에 커밋할지, .gitignore에 추가하고 CI에서 생성할지는 팀 합의에 따릅니다. 어느 쪽이든 buf.yaml과 buf.gen.yaml은 반드시 버전 관리합니다.
3단계: Node.js + Express 서버 구현
// src/server.ts
import express from "express";
import { expressConnectMiddleware } from "@connectrpc/connect-express";
import { PaymentsService } from "./gen/payments/v1/payments_connect";
import type { ConnectRouter } from "@connectrpc/connect";
function routes(router: ConnectRouter) {
router.service(PaymentsService, {
async createPayment(req) {
// 실제 결제 처리 로직 자리
const paymentId = `pay_${crypto.randomUUID()}`;
return {
paymentId,
status: "succeeded",
};
},
});
}
const app = express();
app.use(expressConnectMiddleware({ routes }));
app.listen(3000, () => console.log("서버 실행 중: http://localhost:3000"));TypeScript가 req.userId, req.amountCents, req.currency를 자동으로 추론합니다. proto 필드 이름은 camelCase로 변환되고, 잘못된 필드에 접근하거나 반환 타입이 맞지 않으면 컴파일 에러가 납니다.
4단계: BSR에 push하고 npm SDK 설치
서버 구현이 확인됐으면 스키마를 BSR에 올립니다.
buf pushpush가 성공하면 BSR이 TypeScript SDK를 자동으로 빌드합니다. 이 SDK는 BSR 자체 npm 호환 레지스트리(npm.buf.build)에서 제공됩니다. 소비하는 팀 프로젝트에 .npmrc를 추가해야 합니다.
# .npmrc
@buf:registry=https://npm.buf.build이제 B팀은 buf generate 없이 타입 안전한 클라이언트를 바로 쓸 수 있습니다.
# B팀이 A팀 결제 서비스 클라이언트를 사용하는 방법
pnpm add @buf/myorg_payments.connectrpc_es팀 간 협업 플로우 전체를 보면 이렇습니다.
A팀이 CreatePaymentRequest에서 필드를 제거하거나 타입을 바꾸면 BSR이 push를 차단하고 리뷰 플로우가 시작됩니다. "더 꼼꼼한 커뮤니케이션"이 아니라 레지스트리 레벨의 구조적 방어입니다.
5단계: React 클라이언트 + TanStack Query 통합
pnpm add @buf/myorg_payments.connectrpc_es @connectrpc/connect-query @connectrpc/connect-web @tanstack/react-query// src/client/providers.tsx
import { createConnectTransport } from "@connectrpc/connect-web";
import { TransportProvider } from "@connectrpc/connect-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const transport = createConnectTransport({
baseUrl: "http://localhost:3000",
});
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<TransportProvider transport={transport}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</TransportProvider>
);
}앱 최상위에서 Providers로 감싸줍니다.
// src/client/App.tsx (Next.js라면 app/layout.tsx)
import { Providers } from "./providers";
import { PaymentForm } from "./PaymentForm";
export default function App() {
return (
<Providers>
<PaymentForm />
</Providers>
);
}// src/client/PaymentForm.tsx
import { useMutation } from "@connectrpc/connect-query";
// BSR npm SDK 설치 후 실제 경로는 패키지 내부 생성 파일에서 확인하세요.
// connect-query 플러그인 버전에 따라 경로 형식이 달라질 수 있습니다.
import { createPayment } from "@buf/myorg_payments.connectrpc_es/payments/v1/payments_connectquery";
export function PaymentForm() {
const { mutate, data, isPending, isError, error } = useMutation(createPayment);
const handleSubmit = () => {
mutate({
userId: "user_123",
amountCents: BigInt(9900),
currency: "KRW",
});
};
return (
<div>
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "처리 중..." : "결제하기"}
</button>
{isError && <p style={{ color: "red" }}>에러: {error.message}</p>}
{data && <p>결제 완료: {data.paymentId}</p>}
</div>
);
}query key 관리도, 캐시 무효화 로직 작성도 필요 없습니다. useMutation이 서비스 메서드에서 타입과 query key를 자동으로 추론하고, 에러도 타입 안전하게 전달됩니다.
장단점 분석
장점
| 항목 | 실제 효과 |
|---|---|
| 브라우저 직접 호환 | Envoy/nginx 프록시 없이 브라우저 fetch로 gRPC 서버 직접 호출 |
| 단일 타입 원천 | .proto 하나로 서버·클라이언트·브라우저 타입 전체 동기화 |
| 멀티 프로토콜 단일 서버 | 동일 핸들러가 Connect·gRPC·gRPC-Web 요청을 동시 처리 |
| curl/DevTools 디버깅 | 기본 JSON 인코딩으로 네트워크 탭 직접 확인 가능 |
| gRPC 역호환성 | Connect 클라이언트 ↔ gRPC 서버 통신 가능, 기존 인프라 재활용 |
| BSR npm 자동 SDK | push 즉시 npm 패키지 생성, 소비 팀 codegen 불필요 |
| Breaking Change 자동 방어 | 레지스트리에서 호환성 파괴 push 사전 차단 |
고려사항
| 항목 | 실무 영향 |
|---|---|
| 브라우저 클라이언트 스트리밍 제한 | 클라이언트 스트리밍은 제한적 지원, 서버 스트리밍은 정상 동작 |
| Express는 순수 gRPC 불가 | HTTP/2 미지원, Connect + gRPC-Web만 서빙 가능 |
| BSR npm SDK 첫 설치 지연 | lazy generation으로 최초 install 시 느릴 수 있음 |
| NestJS 네이티브 미지원 | NestJS 트랜스포터가 ConnectRPC를 직접 지원하지 않아 별도 통합 필요 |
| 학습 곡선 | Protobuf IDL + buf CLI + BSR + connect-es 스택 전체를 익혀야 함 |
| 생태계 성숙도 | gRPC 대비 서드파티 미들웨어·플러그인이 아직 적음 |
흔한 실수 네 가지
1. Express에서 gRPC 클라이언트 연결 시도
@connectrpc/connect-express는 HTTP/2를 지원하지 않습니다. Go나 Python의 표준 gRPC 클라이언트와 통신이 필요하다면 Express 대신 node:http2 서버나 Fastify를 선택해야 합니다.
2. .npmrc 없이 @buf/... 패키지 설치 시도
BSR은 npmjs.com이 아니라 npm.buf.build에서 패키지를 제공합니다. .npmrc에 @buf:registry=https://npm.buf.build가 없으면 npm install이 404를 반환합니다. 프로젝트 루트 .npmrc 또는 ~/.npmrc에 설정해야 합니다.
3. buf.lock 없이 운영
buf.lock은 package-lock.json과 같은 역할입니다. 이 파일을 git에 커밋하지 않으면 CI 환경과 로컬 환경의 의존성이 달라질 수 있습니다.
4. HTTP 타임아웃을 짧게 설정하고 스트리밍 사용
HTTP 서버의 ReadTimeout과 WriteTimeout이 스트리밍 RPC 전체 시간에 적용됩니다. 스트리밍 메서드를 쓴다면 타임아웃 설정을 충분히 늘리거나, 스트리밍 전용 서버를 분리하는 걸 고려해볼 수 있습니다.
마치며
.proto 파일 하나가 서버 핸들러 타입, 클라이언트 stub, 브라우저 훅, 팀 간 공유 SDK를 모두 자동 생성합니다. 브라우저에서 프록시 없이 직접 gRPC 서버를 호출할 수 있고, 호환성 파괴 변경은 레지스트리 레벨에서 자동 차단됩니다.
지금 시작해보고 싶다면 이 순서로 접근해보세요.
- buf CLI 설치 후 로컬
buf generate부터 — BSR 계정 없이도buf.gen.yaml+ 로컬 플러그인으로 TypeScript 코드 생성을 먼저 경험해볼 수 있습니다. - Express + connect-express로 단일 서비스 구현 — 기존 Express 앱에 미들웨어 한 줄 추가로 ConnectRPC 엔드포인트를 붙여볼 수 있습니다.
- BSR에 push 후 npm SDK로 소비 —
buf push로 BSR에 올리고, 다른 프로젝트에서.npmrc설정 후pnpm add @buf/...로 타입 안전 클라이언트를 설치해보면 팀 간 협업이 어떻게 달라지는지 체감됩니다.
참고 자료
- ConnectRPC 공식 문서
- connect-es GitHub (TypeScript 구현)
- protobuf-es GitHub (Protobuf TypeScript 런타임)
- Buf Schema Registry 공식 문서
- BSR Generated SDKs — npm 사용 가이드
- buf.gen.yaml v2 설정 레퍼런스
- Buf Breaking Change 감지 문서
- connect-query-es GitHub (TanStack Query 통합)
- Buf 블로그: Connect — A Better gRPC
- Buf 블로그: Node.js + TypeScript gRPC 마이크로서비스 구축
- Buf 블로그: BSR Governance Workflow
- BSR Generated SDKs: Give clients pre-built native libraries