ConnectRPC + TypeScript: Protocol Buffers 기반 타입 안전 RPC를 HTTP/1.1·HTTP/2 모두에서 운영하기
마이크로서비스를 한동안 운영하다 보면 REST API의 한계가 서서히 드러납니다. OpenAPI 스펙을 손으로 동기화하다가 프론트엔드 타입과 어긋난 경험, fetch로 받아온 응답을 any로 캐스팅하던 기억... 저도 그걸 반복하다 결국 gRPC를 검토했는데, 막상 도입하려니 Envoy 프록시 설정, HTTP/2 강제, 브라우저 클라이언트 지원 복잡성이 예상보다 훨씬 컸습니다.
ConnectRPC는 그 균형점에 있습니다. Buf 팀이 개발한 이 프레임워크는 Protobuf의 이진 직렬화와 코드 자동 생성 이점을 그대로 가져오면서, gRPC의 고질적인 약점인 HTTP/2 전용 바이너리 프레이밍과 Envoy 의존성을 걷어냅니다. HTTP/1.1과 HTTP/2를 동시에 지원하고, curl이나 Postman으로 unary RPC를 바로 디버깅할 수 있습니다. Connect-ES 2.0이 정식 출시되면서 Fastify 5, Express 5, Next.js 15 App Router 공식 지원이 갖춰졌고, 프로덕션에서의 검증도 이어지고 있습니다.
이 글에서는 .proto 스키마 작성부터 Fastify 서버 구현, 타입 안전 클라이언트 연결, REST에서 ConnectRPC로의 점진적 전환 전략까지 실제 적용 경험을 정리했습니다. 모든 코드 예시는 Connect-ES v2 API 기준이며, v1과 달리 메시지가 클래스 인스턴스가 아닌 순수 TypeScript 객체(plain object)로 다뤄집니다.
핵심 개념
gRPC의 벽, ConnectRPC가 넘는 방식
gRPC는 강력합니다. Protobuf 직렬화, 양방향 스트리밍, 다국어 코드 생성 — 마이크로서비스 간 통신에서 원하는 건 거의 다 됩니다. 그런데 웹 브라우저에서 직접 gRPC 서버에 연결하려는 순간 벽이 생깁니다. HTTP/2 트레일링 헤더를 브라우저가 지원하지 않기 때문에, gRPC-Web이라는 별도 프로토콜과 Envoy 프록시가 필요해지죠. 인프라 비용과 운영 복잡성이 순식간에 늘어납니다.
ConnectRPC는 이 문제를 프로토콜 레이어에서 해결합니다. 세 가지 프로토콜을 동시에 지원하는데, 그중 Connect Protocol은 단순한 HTTP POST 기반이라 HTTP/1.1 환경에서도 문제없이 동작합니다.
| 프로토콜 | 전송 방식 | HTTP 버전 | 특징 |
|---|---|---|---|
| Connect Protocol | POST | HTTP/1.1·HTTP/2 모두 | JSON·바이너리 양쪽 지원, 기본 선택지 |
| gRPC Protocol | POST | HTTP/2 전용 | 표준 gRPC 서버·클라이언트 완전 호환 |
| gRPC-Web Protocol | POST | HTTP/1.1·HTTP/2 모두 | Envoy 없이 브라우저에서 gRPC-Web 직접 사용 |
Connect 서버는 세 프로토콜을 동시에 처리합니다. 클라이언트가 헤더로 어떤 프로토콜을 쓸지 선언하면 서버가 맞춰 응답합니다. 기존 gRPC 클라이언트와 새로 만드는 Connect TypeScript 클라이언트가 같은 서버 엔드포인트를 공유할 수 있습니다.
Connect-ES 2.0의 핵심 변화: 메시지가 클래스에서 순수 객체로
Connect-ES v1에서는 Protobuf 메시지가 클래스 인스턴스였습니다. new SayHelloRequest({ name: "World" })처럼 생성하고 .toBinary()로 직렬화했죠. v2에서는 메시지가 **순수 TypeScript 객체(plain object)**가 됩니다. React 상태로 넘기거나 JSON으로 직렬화할 때 훨씬 자연스럽고, 불필요한 클래스 계층 없이 타입만 남습니다.
// v1 스타일 — 클래스 인스턴스 (더 이상 권장하지 않음)
const req = new SayHelloRequest({ name: "World" });
// v2 스타일 — 순수 객체
import { create } from "@bufbuild/protobuf";
import { SayHelloRequestSchema } from "./gen/greet/v1/greet_pb.js";
const req = create(SayHelloRequestSchema, { name: "World" });
// req는 그냥 { name: "World" } 형태의 일반 객체코드 생성 워크플로우도 크게 단순해졌습니다. v1에서는 메시지 타입과 서비스 정의를 별도 플러그인(protoc-gen-connect-es)으로 나눠 생성했는데, v2에서는 protoc-gen-es 하나로 통합됩니다. 메시지 타입과 서비스 정의가 동일한 *_pb.ts 파일에 함께 생성되고, _connect.ts 파일과 protoc-gen-connect-es 플러그인은 v2에서 폐기되었습니다.
실전 적용
1. 프로젝트 셋업: .proto 정의에서 타입 생성까지
코어 패키지를 설치합니다. 프레임워크별 패키지(@connectrpc/connect-fastify, @connectrpc/connect-express, @connectrpc/connect-web)는 사용하는 환경에 맞게 추가합니다. Buf CLI는 전역 설치하거나 npx buf로 실행합니다.
npm install @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf
npm install -D @bufbuild/protoc-gen-es서비스 계약을 담는 .proto 파일을 작성합니다. REST로 치면 OpenAPI 스펙에 해당하는 계약 문서입니다.
// proto/greet/v1/greet.proto
syntax = "proto3";
package greet.v1;
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
string greeting = 1;
}
message SayHelloStreamRequest {
string name = 1;
int32 count = 2;
}
service GreetService {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
rpc SayHelloStream (SayHelloStreamRequest) returns (stream SayHelloResponse);
}코드 생성 설정 파일을 루트에 작성합니다. v2에서는 TypeScript 쪽에 플러그인 하나면 충분합니다.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/bufbuild/es:v2
out: src/gennpx buf generate를 실행하면 src/gen/greet/v1/greet_pb.ts 파일 하나가 생성되고, 이 파일에 메시지 타입과 서비스 정의가 모두 담깁니다.
npx buf generateCI/CD에는 이 단계를 묶어두면 스키마 변경 시 타입 재생성이 자동으로 이루어집니다.
npx buf lint && npx buf generate && git diff --exit-code src/gen/2. Fastify 서버 구현
Fastify 5 플러그인을 사용하면 연결이 매우 간단합니다. v2부터는 GreetService를 비롯한 서비스 정의가 greet_pb.js에서 직접 임포트됩니다. 서비스 구현 함수에서 요청과 응답이 모두 생성된 TypeScript 타입으로 추론되고, 반환값은 평범한 객체 리터럴입니다.
// src/server.ts
import { fastify } from "fastify";
import { fastifyConnectPlugin } from "@connectrpc/connect-fastify";
import { ConnectRouter } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const routes = (router: ConnectRouter) => {
router.service(GreetService, {
async sayHello(req) {
// req.name은 TypeScript가 string으로 추론
return { greeting: `Hello, ${req.name}!` };
},
async *sayHelloStream(req) {
for (let i = 0; i < req.count; i++) {
yield { greeting: `${req.name}에게 ${i + 1}번째 인사` };
// 데모용 지연 — 프로덕션에서는 실제 비동기 작업으로 교체
await new Promise((r) => setTimeout(r, 500));
}
},
});
};
// http2: false 로도 완전히 동작 — Envoy 불필요
const server = fastify({ http2: false });
await server.register(fastifyConnectPlugin, { routes });
await server.listen({ port: 3000 });서버를 실행한 뒤 curl로 바로 확인할 수 있다는 점이 gRPC 대비 확실한 장점입니다. 별도 gRPC 클라이언트 없이 개발 중에 바로 디버깅이 됩니다.
curl -X POST http://localhost:3000/greet.v1.GreetService/SayHello \
-H "Content-Type: application/json" \
-d '{"name": "ConnectRPC"}'
# 응답: {"greeting":"Hello, ConnectRPC!"}3. 타입 안전 클라이언트 연결
브라우저 클라이언트 (React / Next.js)
// src/client.ts
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const transport = createConnectTransport({
baseUrl: "http://localhost:3000",
});
const client = createClient(GreetService, transport);
// IDE에서 완전한 자동완성·타입 추론 동작
const res = await client.sayHello({ name: "World" });
console.log(res.greeting); // string으로 추론서버 스트리밍 수신 — HTTP/1.1에서도 동작
for await (const chunk of client.sayHelloStream({ name: "World", count: 3 })) {
console.log(chunk.greeting);
}스트리밍 응답을 for await...of로 소비할 수 있어서 WebSocket이나 SSE를 별도로 구성할 필요가 없습니다.
Node.js 서비스 간 통신
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const transport = createConnectTransport({
baseUrl: "http://greet-service:3000",
httpVersion: "2", // 내부 서비스 간에는 HTTP/2로 성능 최적화
});
const client = createClient(GreetService, transport);외부 클라이언트는 HTTP/1.1, 내부 서비스 간 통신은 HTTP/2 — 서버 코드는 그대로 두고 클라이언트 설정만 바꾸면 됩니다.
4. REST에서 ConnectRPC로 점진적 전환
가장 현실적인 전환 방식은 REST와 Connect 엔드포인트를 병행 운영하면서 서비스별로 이동하는 겁니다. 전체를 한 번에 바꾸려 하면 반드시 어딘가 터집니다.
Express 서버에서 두 방식을 병행할 때 주의할 점이 있습니다. Connect 미들웨어는 자체적으로 요청 body를 파싱하므로, express.json()을 전역으로 먼저 등록하면 body가 중복 소비되어 충돌이 생깁니다. Connect 미들웨어를 body 파서보다 먼저 등록하고, JSON 파서가 필요한 REST 라우트에는 라우트 단위로 미들웨어를 지정하세요.
// src/app.ts — Express 5 + Connect 병행 운영
import express from "express";
import { expressConnectMiddleware } from "@connectrpc/connect-express";
import { ConnectRouter } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const app = express();
// Connect 미들웨어를 body 파서보다 먼저 등록
app.use(
expressConnectMiddleware({
routes(router: ConnectRouter) {
router.service(GreetService, {
async sayHello(req) {
return { greeting: `Hello, ${req.name}!` };
},
});
},
})
);
// REST 라우터 — Connect가 처리하지 않은 요청에만 도달
app.get("/api/greet/:name", (req, res) => {
res.json({ greeting: `Hello, ${req.params.name}!` });
});
// REST POST처럼 body 파싱이 필요한 경우 라우트 단위로 지정
// app.post("/api/something", express.json(), handler);
app.listen(3000);클라이언트 팀이 새 Connect 클라이언트로 전환을 완료한 뒤 REST 라우터를 제거하면 됩니다. 서비스 단위로 순차 진행하면서 위험을 분산할 수 있습니다.
5. Go 백엔드 + TypeScript 프론트엔드 다국어 구성
.proto 파일 하나로 Go 서버 타입과 TypeScript 클라이언트 타입을 동시에 생성하는 게 ConnectRPC 다국어 조합의 핵심 가치입니다. Go는 v2에서도 connectrpc/go 플러그인을 별도로 사용하고, TypeScript는 bufbuild/es:v2 하나로 처리합니다.
# buf.gen.yaml — Go + TypeScript 동시 생성
version: v2
plugins:
# Go 서버 코드
- remote: buf.build/protocolbuffers/go
out: go/gen
- remote: buf.build/connectrpc/go
out: go/gen
# TypeScript 클라이언트 코드 (v2에서는 protoc-gen-es 하나로 통합)
- remote: buf.build/bufbuild/es:v2
out: ts/src/genAPI 계약을 .proto로 먼저 리뷰하고 머지한 뒤 각 언어 코드를 생성하는 워크플로우를 CI에 넣으면, "Go 쪽 응답 필드가 추가됐는데 TypeScript 타입이 아직 업데이트 안 됐어요" 같은 상황이 원천적으로 차단됩니다. Buf Schema Registry(BSR)를 중앙 저장소로 사용하면 팀 간 .proto 버전을 일관되게 관리할 수 있습니다.
장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 프록시 불필요 | HTTP/1.1 직접 지원으로 Envoy 없이 브라우저↔서버 연결 가능 |
| 표준 HTTP 도구 호환 | curl, Postman, 브라우저 DevTools로 unary RPC 즉시 디버깅 |
| End-to-End 타입 안전 | .proto → TypeScript 자동 생성으로 런타임 타입 불일치 원천 차단 |
| gRPC 역방향 호환 | 기존 gRPC 서버에 Connect 클라이언트, 반대 방향도 가능 |
| 멀티 프레임워크 지원 | Fastify 5, Express 5, Next.js 15 App Router 공식 플러그인 제공 |
| 서버 스트리밍 | HTTP/1.1 환경에서도 Envoy 없이 서버 스트리밍 운영 가능 |
| 다국어 단일 계약 | Go, Rust, Python, TypeScript 등 .proto 하나로 팀 간 타입 동기화 |
| Protobuf 적합성 | protobuf-es는 Protobuf 명세 적합성 테스트를 통과한 JS/TS 구현체 |
단점 및 고려사항
| 항목 | 설명 |
|---|---|
| Protobuf 스키마 선투자 | .proto 작성과 buf 빌드 파이프라인 셋업에 초기 비용 발생 |
| 학습 곡선 | Protobuf IDL, 코드 생성 워크플로우, 스트리밍 패턴 습득 필요 |
| 코드 생성 의존성 | 스키마 변경 시 반드시 buf generate 재실행 — CI 통합 필수 |
| 스트리밍 제약 | HTTP/1.1에서는 unary와 서버 스트리밍만 지원, 클라이언트·양방향 스트리밍은 HTTP/2 필수 |
| TypeScript 단일 프로젝트 대비 초기 비용 | .proto 스키마 선투자가 필요해 TypeScript 단일 프로젝트에서는 tRPC 대비 초기 비용이 높음 |
| REST 대비 미들웨어 | 모니터링·로깅 미들웨어 생태계는 REST 대비 아직 작음 |
실무에서 자주 마주치는 함정
v1 스타일 인스턴스 생성을 v2에서 그대로 쓰는 경우
Connect-ES v2로 업그레이드했는데 여전히 new SayHelloRequest()를 쓰면 컴파일 오류 대신 런타임에서 조용히 이상 동작할 수 있습니다. v2에서는 반드시 create(SayHelloRequestSchema, {...})를 사용해야 합니다. 마이그레이션 후 임포트 경로도 _connect.js에서 _pb.js로 전부 바뀌므로, 코드베이스 전체를 한 번 검색해보세요.
buf generate를 CI에서 빼먹는 경우
.proto 파일을 수정하고 커밋했는데 buf generate를 돌리지 않으면 생성된 타입 파일이 스키마와 어긋납니다. buf generate 후 git diff --exit-code src/gen/을 CI 스텝에 추가해두면 이 상황을 미리 잡을 수 있습니다. 저도 처음엔 이걸 빠뜨려서 "왜 서버 응답이 타입이랑 다르지?" 하고 한참 헤맸습니다.
HTTP/1.1 환경에서 클라이언트·양방향 스트리밍을 기대하는 경우
서버 스트리밍은 HTTP/1.1에서 동작하지만, 클라이언트 스트리밍과 양방향 스트리밍은 HTTP/2가 반드시 필요합니다. CDN이나 일반 리버스 프록시를 앞에 두는 환경이라면 bidi 스트리밍 사용 전에 HTTP/2 end-to-end 지원 여부를 먼저 확인해야 합니다.
Protobuf 기본값과 undefined 혼동
v2에서 메시지가 plain object가 됐기 때문에 React 상태로 그대로 쓰는 게 가능합니다. 단, Protobuf는 숫자 필드 기본값이 0, 문자열은 빈 문자열("")입니다. REST JSON에서 null이나 undefined로 처리하던 로직이 있다면 UI 단에서 예상치 못한 빈 화면을 만날 수 있습니다.
마치며
모든 프로젝트에 ConnectRPC가 맞는 건 아닙니다. TypeScript 모노리포에서 프론트엔드↔백엔드만 연결한다면 tRPC가 더 빠른 선택입니다. 공개 API를 만들거나 외부 개발자를 위한 클라이언트 SDK가 필요하다면 REST가 여전히 강력합니다.
ConnectRPC가 적합한 사용 사례는 분명합니다 — 다국어 마이크로서비스 간 통신, Envoy 없이 브라우저에서 Protobuf RPC를 직접 쓰고 싶은 상황, 기존 gRPC 서버에 브라우저 클라이언트를 붙이고 싶을 때. .proto 스키마를 단일 진실 소스로 삼아 Go·TypeScript·Rust 팀이 API 계약을 공유하고, 타입 불일치 오류를 빌드 타임에 잡는 개발 흐름은 REST 시절의 OpenAPI 수동 동기화와 꽤 다른 경험입니다.
지금 시작한다면 이 순서로 해보시면 좋습니다:
-
Buf CLI + 작은
.proto파일 하나로 시작 — 전체 서비스를 바꾸지 않아도 됩니다. 하나의 내부 서비스에 먼저 적용해보면서 워크플로우를 익히는 것이 자연스럽습니다. -
Fastify 또는 Express에 Connect 미들웨어를 기존 REST 라우터와 병행 등록 — 팀이 새 클라이언트로 전환하는 동안 REST 엔드포인트를 그대로 유지할 수 있습니다.
-
CI/CD에
buf lint && buf generate && git diff --exit-code추가 — 스키마와 생성 코드의 동기화를 자동으로 보장하면, 이후 팀원이.proto를 수정할 때 별도 안내 없이도 파이프라인이 알아서 잡아줍니다.
참고 자료
공식 문서 및 저장소
- ConnectRPC 공식 문서
- ConnectRPC Protocol Reference
- ConnectRPC Node.js 시작 가이드
- ConnectRPC v2 마이그레이션 가이드
- connect-es GitHub 저장소
- protobuf-es GitHub 저장소
- Buf CLI GitHub 저장소
Buf 블로그
사례 및 외부 참고 (비공식)
- OpenStatus: zod-openapi에서 ConnectRPC로 마이그레이션 사례
- Why Connect RPC is a great choice for building APIs — Mark Wolfe's Blog
- ConnectRPC, a great extension for gRPC — Medium
- gRPC vs Connect-RPC vs tRPC 2026 비교 — APIScout (비공식 비교)
- gRPC-Web vs REST vs Connect-RPC for Frontend 2026 — APIScout (비공식 비교)
- ConnectRPC vs gRPC — alamrafiul.com (비공식)