Cap'n Web은 순수 TypeScript로 구현된 새로운 RPC 프로토콜이자 라이브러리입니다. 객체-능력 모델을 기반으로 양방향 호출, 함수/객체의 참조 전달, 프로미스 파이프라이닝, 능력 기반 보안을 지원하며, 스키마 없이 JSON 직렬화로 동작합니다. HTTP, WebSocket, postMessage를 기본 지원하고 브라우저, Cloudflare Workers, Node.js 등에서 동작하며 TypeScript와도 자연스럽게 통합됩니다. 배치 모드, 체인 호출, 보안 패턴, GraphQL 대안으로서의 활용, 배열 처리(.map)와 프로토콜/직렬화 구현 세부까지 소개합니다.
2025-09-22
읽는 데 12분

Cap'n Web을 소개합니다. 순수 TypeScript로 구현된 RPC 프로토콜이자 구현체입니다.
Cap'n Web은 제가(케이튼) 10여 년 전에 만든 RPC 프로토콜 Cap'n Proto의 정신적 자매 프로젝트로, 웹 스택과 잘 어울리도록 설계되었습니다. 즉, 다음과 같습니다.
Cap'n Web은 객체-능력 RPC 모델을 구현하기 때문에 다른 거의 모든 RPC 시스템보다 더 표현력이 풍부합니다. 즉, 다음을 지원합니다.
RpcTarget를 상속한다면, 해당 클래스의 인스턴스는 참조로 전달되며, 메서드 호출은 객체가 만들어진 위치로 콜백됩니다.요컨대, Cap'n Web은 네트워크 지연을 인지하고 보완하면서도, 평소의 JavaScript API를 설계하듯 RPC 인터페이스를 설계할 수 있게 해줍니다.
가장 좋은 점은, Cap'n Web의 설정이 정말로 간단하다는 것입니다.
클라이언트는 이렇게 생겼습니다:
import { newWebSocketRpcSession } from "capnweb";
// 한 줄 설정.
let api = newWebSocketRpcSession("wss://example.com/api");
// 서버의 메서드를 호출!
let result = await api.hello("World");
console.log(result);
다음은 RPC 서버를 구현한 완전한 Cloudflare Worker입니다:
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
// 서버 구현.
class MyApiServer extends RpcTarget {
hello(name) {
return `Hello, ${name}!`
}
}
// 표준 Workers HTTP 핸들러.
export default {
fetch(request, env, ctx) {
// 라우팅을 위한 URL 파싱.
let url = new URL(request.url);
// `/api`에서 API 제공.
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new MyApiServer());
}
// 다른 엔드포인트를 여기서 제공할 수도 있습니다...
return new Response("Not found", {status: 404});
}
}
끝. 이게 곧 앱입니다.
MyApiServer에 더 많은 메서드를 추가하고, 클라이언트에서 호출할 수 있습니다.그냥 됩니다.
RPC(Remote Procedure Calls)는 네트워크를 통해 둘 이상의 프로그램이 통신하는 방식을 표현하는 방법입니다. RPC가 없다면 HTTP 같은 프로토콜을 사용해 통신하게 됩니다. 하지만 HTTP에서는 통신 내용을 REST 스타일로 설계된 HTTP 요청/응답 형태로 포맷하고 파싱해야 합니다. RPC 시스템은 통신을 일반 함수 호출처럼 보이게 하여, 마치 원격 서비스가 아니라 라이브러리를 호출하는 것처럼 느끼게 합니다. RPC 시스템은 클라이언트 측에 서버 측 실제 객체를 대신하는 "스텁" 객체를 제공합니다. 스텁에서 메서드를 호출하면, RPC 시스템이 파라미터를 직렬화해 서버로 전송하고, 서버에서 메서드를 실행한 뒤, 반환 값을 다시 전송하는 일을 처리합니다.
RPC의 장점은 오랫동안 많은 논쟁거리였습니다. RPC는 흔히 분산 컴퓨팅의 오류들을 저지른다고 비판받곤 했습니다.
하지만 이런 평판은 구시대적입니다. RPC가 처음 발명된 40여 년 전에는 비동기 프로그래밍이 거의 존재하지 않았습니다. Promise는커녕 async/await도 없었죠. 초기 RPC는 동기식이었고, 호출은 응답을 기다리며 호출 스레드를 블로킹했습니다. 좋게 봐야 지연으로 프로그램이 느려졌고, 최악의 경우 네트워크 장애로 프로그램이 멈추거나 크래시 났습니다. "망가졌다"고 평가된 것도 무리는 아니었습니다.
지금은 다릅니다. Promise와 async/await이 있고, 네트워크 장애 시 예외를 던질 수 있습니다. 심지어 연쇄 호출을 한 번의 네트워크 라운드 트립으로 처리하는 파이프라이닝 방법도 이해하고 있습니다. 여러분이 매일 쓰는 대규모 분산 시스템들 중 많은 부분이 RPC 위에 구축되어 있습니다. 잘 작동합니다.
사실 RPC는 우리가 익숙한 프로그래밍 모델에 꼭 맞습니다. 모든 프로그래머는 바이트 스트림 프로토콜이나 심지어 REST가 아니라, 함수 호출로 구성된 API 관점으로 사고하도록 훈련되어 있습니다. RPC를 사용하면 이런 정신적 모델 간의 번역 작업에서 벗어나 더 빠르게 움직일 수 있습니다.
Cap'n Web은 클라이언트-서버, 마이크로서비스 간 통신 등 네트워크를 통해 서로 대화하는 두 개의 JavaScript 애플리케이션이 있는 곳이라면 어디든 유용합니다. 특히 실시간 협업 기능을 갖춘 인터랙티브 웹 애플리케이션, 그리고 복잡한 보안 경계를 가로지르는 상호작용을 모델링하는 데 탁월합니다.
Cap'n Web은 아직 새롭고 실험적이므로, 지금은 약간의 첨단 모험심도 필요할 수 있습니다!
Cap'n Web으로 할 수 있는 것들을 더 소개합니다.
가끔은 WebSocket 연결이 다소 무겁게 느껴질 수 있습니다. 한 번만 빠르게 여러 호출을 묶어서 보내고, 지속적인 연결은 필요 없을 때가 있죠?
이를 위해 Cap'n Web은 HTTP 배치 모드를 지원합니다:
import { newHttpBatchRpcSession } from "capnweb";
let batch = newHttpBatchRpcSession("https://example.com/api");
let result = await batch.hello("World");
console.log(result);
(서버 코드는 앞서와 완전히 동일합니다.)
배치에서 어떤 RPC를 한 번이라도 await하면 그 배치는 끝나며, 그 배치를 통해 받은 모든 원격 참조는 끊어진다는 점에 주의하세요. 더 호출하려면 새 배치를 시작해야 합니다. 다만, 하나의 배치에서 여러 호출을 보낼 수는 있습니다:
let batch = newHttpBatchRpcSession("https://example.com/api");
// 여러 호출을 만들 수 있지만, 한꺼번에 await해야 합니다.
let promise1 = batch.hello("Alice");
let promise2 = batch.hello("Bob");
let [result1, result2] = await Promise.all([promise1, promise2]);
console.log(result1);
console.log(result2);
그리고 여기서 또 다른 기능으로 이어집니다…
이제 마법 같은 부분입니다.
배치 모드와 WebSocket 모드 모두에서, 다른 호출의 결과에 의존하는 호출을 첫 번째 호출이 끝나기를 기다리지 않고 시작할 수 있습니다. 배치 모드에서는 하나의 배치 안에서 메서드를 호출한 뒤, 그 결과를 다음 호출의 입력으로 사용할 수 있습니다. 전체 배치는 여전히 네트워크 라운드 트립 한 번만 필요합니다.
예를 들어, API가 다음과 같다고 합시다:
class MyApiServer extends RpcTarget {
getMyName() {
return "Alice";
}
hello(name) {
return `Hello, ${name}!`
}
}
다음과 같이 작성할 수 있습니다:
let namePromise = batch.getMyName();
let result = await batch.hello(namePromise);
console.log(result);
처음 호출한 getMyName()이 프로미스를 반환했지만, 이를 먼저 기다리지 않고 그 프로미스 자체를 hello()의 입력으로 사용했습니다. Cap'n Web에서는 이것이 그냥 됩니다. 클라이언트가 서버에 보내는 메시지는 이렇게 말합니다. "첫 번째 호출의 결과를 두 번째 호출의 파라미터 위치에 끼워 넣어 주세요."
혹은 첫 번째 호출이 메서드를 가진 객체를 반환할 수 있습니다. 이때도 첫 번째 프로미스를 await하지 않고 곧바로 그 메서드를 호출할 수 있습니다. 예를 들면:
let batch = newHttpBatchRpcSession("https://example.com/api");
// API 키를 인증해 Session 객체를 반환합니다.
let sessionPromise = batch.authenticate(apiKey);
// 사용자 이름을 가져옵니다.
let name = await sessionPromise.whoami();
console.log(name);
이게 가능한 이유는 Cap'n Web 호출이 반환하는 프로미스가 일반 프로미스가 아니기 때문입니다. 대신 JavaScript Proxy 객체입니다. 그 위에서 호출하는 어떤 메서드든, 최종 결과에 대한 추정적(speculative) 메서드 호출로 해석됩니다. 이 호출들은 즉시 서버로 전송되며, 서버에게 이렇게 말합니다. "조금 전에 보낸 호출이 끝나면, 그 반환값에 대해 이 메서드를 호출하세요."
방금 예시는 Cap'n Web의 객체-능력 모델이 가능하게 하는 중요한 보안 패턴을 보여줍니다.
authenticate() 메서드를 호출하면, 제공된 API 키를 검증한 뒤 인증된 세션 객체를 반환합니다. 클라이언트는 이후 이 세션 객체에 대해 해당 사용자 권한이 필요한 RPC를 수행할 수 있습니다. 서버 코드는 다음과 같을 수 있습니다:
class MyApiServer extends RpcTarget {
authenticate(apiKey) {
let username = await checkApiKey(apiKey);
return new AuthenticatedSession(username);
}
}
class AuthenticatedSession extends RpcTarget {
constructor(username) {
super();
this.username = username;
}
whoami() {
return this.username;
}
// ...인증이 필요한 다른 메서드들...
}
이 패턴이 성립하는 핵심은 다음과 같습니다: 클라이언트가 세션 객체를 "위조"하는 것은 불가능합니다. 세션 객체를 얻는 유일한 방법은 authenticate()를 호출해 성공적으로 반환받는 것입니다.
대부분의 RPC 시스템에서는 한 RPC가 이렇게 새로운 RPC 객체를 가리키는 스텁을 반환하는 방식이 불가능합니다. 대신 모든 함수가 최상위에 있고, 누구나 호출할 수 있습니다. 그런 전통적 RPC 시스템에서는 매 함수 호출마다 API 키를 다시 전달하고, 서버에서 매번 검증해야 합니다. 아니면 아예 RPC 바깥에서 인증/인가를 처리해야 합니다.
이는 특히 WebSocket에서 흔한 골칫거리입니다. 웹 API의 설계상 WebSocket에서는 일반적으로 헤더나 쿠키를 이용해 권한 부여를 할 수 없습니다. 대신 WebSocket 자체를 통해 인밴드(in-band) 메시지로 인증을 해야 하죠. 하지만 이는 RPC 프로토콜에 성가십니다. 인증 메시지가 연결의 상태 자체를 바꾸며 이후 호출에 영향을 주기 때문에, 추상화가 깨지기 때문입니다.
위의 authenticate() 패턴은 인증을 RPC 추상화 안에 자연스럽게 녹여줍니다. 게다가 타입 안정성도 있습니다. 인증이 필요한 메서드를 인증 없이 호출하는 실수를 할 수 없습니다. 호출할 객체 자체가 없을 테니까요. 타입 안전성 얘기가 나온 김에…
TypeScript를 사용한다면, Cap'n Web은 잘 어울립니다. RPC API를 한 번 TypeScript 인터페이스로 선언해 두고, 서버에서 구현하고, 클라이언트에서 호출할 수 있습니다:
// 공유 인터페이스 선언:
interface MyApi {
hello(name: string): Promise<string>;
}
// 클라이언트:
let api: RpcStub<MyApi> = newWebSocketRpcSession("wss://example.com/api");
// 서버:
class MyApiServer extends RpcTarget implements MyApi {
hello(name) {
return `Hello, ${name}!`
}
}
이제 끝단 간 타입 검사, 자동 완성된 메서드 이름 등을 누릴 수 있습니다.
주의할 점은, 언제나 그렇듯 TypeScript 타입 검사는 런타임에 일어나지 않는다는 것입니다. RPC 시스템 자체가 악의적인 클라이언트가 잘못된 타입의 파라미터로 RPC를 호출하는 것을 막아주지는 않습니다. 물론 이는 Cap'n Web만의 문제가 아니라, JSON 기반 API가 늘 갖고 있던 문제이기도 합니다. 이를 해결하려면 Zod 같은 런타임 타입 검사 도구를 사용할 수 있습니다. (한편, 향후에는 TypeScript 타입을 직접 기반으로 한 타입 검사를 추가하고자 합니다.)
GraphQL을 써본 적 있다면 몇 가지 유사점을 눈치챌 수 있습니다. GraphQL의 장점 중 하나는, 클라이언트가 한 번의 쿼리로 여러 데이터를 요청할 수 있도록 해서 전통적인 REST API의 “폭포수(waterfall)” 문제를 해결했다는 점입니다. 예컨대, 다음과 같이 순차적으로 세 번의 HTTP 호출을 하는 대신:
GET /user
GET /user/friends
GET /user/friends/photos
…하나의 GraphQL 쿼리로 한꺼번에 가져올 수 있습니다.
이는 REST 대비 큰 진전이지만, GraphQL에도 나름의 트레이드오프가 있습니다:
Cap'n Web은 새로운 언어나 생태계를 들여오지 않고도 폭포수 문제를 해결합니다. 그냥 JavaScript입니다. Cap'n Web은 프로미스 파이프라이닝과 객체 참조를 지원하기 때문에 다음과 같이 쓸 수 있습니다:
let user = api.createUser({ name: "Alice" });
let friendRequest = await user.sendFriendRequest("Bob");
내부적으로 무슨 일이 벌어질까요? 두 호출은 하나의 네트워크 라운드 트립으로 파이프라인됩니다:
이 모든 것이 JavaScript로 자연스럽게 표현됩니다. 스키마, 쿼리 언어, 특수 도구는 필요 없습니다. 다른 JavaScript 코드에서 하듯 메서드를 호출하고 객체를 전달하면 됩니다.
다시 말해, GraphQL은 REST의 폭포수를 평탄화하는 방법을 제공했습니다. Cap'n Web은 한 걸음 더 나아갑니다. 일반 프로그램에서 하듯 복잡한 상호작용을 정확히 모델링할 수 있는 힘을, 어긋남 없이 제공합니다.
지금까지 소개한 것만으로 Cap'n Web을 GraphQL의 진지한 대안으로 고려하려면 결정적인 퍼즐 조각이 하나 더 필요합니다. 바로 리스트 처리입니다. GraphQL은 종종 “이 쿼리를 실행하고, 결과의 각 항목마다 다른 쿼리를 실행하라”에 쓰입니다. 예: “사용자의 친구 목록을 나열하고, 각 친구에 대해 프로필 사진을 가져와라.”
요컨대, 추가 라운드 트립 없이 수행할 수 있는 array.map()이 필요합니다.
Cap'n Proto는 역사적으로 이런 기능을 지원하지 않았습니다.
하지만 Cap'n Web에서는 이 문제를 풀었습니다. 다음과 같이 할 수 있습니다:
let user = api.authenticate(token);
// 사용자의 친구 목록(배열)을 가져옵니다.
let friendsPromise = user.listFriends();
// 각 친구 레코드에 사진을 덧붙이기 위해 .map()을 수행합니다.
// 친구 목록에 대한 *프로미스* 위에서 동작하므로
// 라운드 트립을 추가하지 않습니다.
// (잠깐, 뭐라고요!?!?)
let friendsWithPhotos = friendsPromise.map(friend => {
return {friend, photo: api.getUserPhoto(friend.id))};
}
// 사진이 첨부된 친구 목록을 await — 라운드 트립 한 번!
let results = await friendsWithPhotos;
.map()은 콜백 함수를 받아 배열의 각 원소에 적용해야 합니다. 앞서 설명했듯이, 보통은 함수를 RPC에 전달하면 그 함수는 "참조로" 전달됩니다. 즉, 원격 측은 스텁을 받으며, 그 스텁을 호출하면 함수가 생성된 클라이언트 쪽으로 RPC가 되돌아갑니다.
하지만 여기서는 그렇지 않습니다. 그런 식이면 목적이 무색해집니다. 배열의 각 원소를 처리할 때마다 서버가 클라이언트로 왕복하도록 만들고 싶지 않습니다. 서버가 서버 쪽에서 변환을 수행하길 원합니다.
그 목적을 위해 .map()은 특별합니다. JavaScript 코드를 서버로 보내지 않지만, 그와 유사한 "코드"를 보냅니다. 다만 도메인 특화되고 튜링 완전하지 않은 언어로 제한됩니다. 이 “코드”는 서버가 배열의 각 원소에 대해 수행해야 할 지시(instruction) 목록입니다. 위 예시에서 지시는 다음과 같습니다.
api.getUserPhoto(friend.id)를 호출합니다.{friend, photo} 객체를 반환합니다. 여기서 friend는 원래의 배열 원소이고, photo는 1단계의 결과입니다.하지만 애플리케이션 코드는 JavaScript 메서드만 지정했습니다. 어떻게 이런 좁은 DSL로 변환할 수 있을까요?
정답은 기록-재생(record-replay)입니다. 클라이언트 측에서 콜백을 한 번 실행하되, 특수한 플레이스홀더 값을 인자로 전달합니다. 이 파라미터는 RPC 프로미스처럼 거동합니다. 하지만 콜백은 동기여야 하므로 실제로 이 프로미스를 await할 수는 없습니다. 할 수 있는 일은 프로미스 파이프라이닝을 사용해 추정적 호출을 만드는 것뿐입니다. 이 호출들은 구현이 가로채어 지시로 기록되며, 이후 서버로 전송되어 필요한 만큼 재생(replay)됩니다.
그리고 이 기록은 바로 RPC 프로토콜이 표현하도록 설계된 프로미스 파이프라이닝을 기반으로 하기 때문에, .map()에 전달되는 “지시”를 표현하는 "DSL"은 사실상 그 자체로 RPC 프로토콜 입니다. 🤯
Cap'n Web의 기반 프로토콜은 JSON을 사용합니다. 다만 특수 타입을 처리하기 위한 전처리 단계가 있습니다. 배열을 "이스케이프 시퀀스"처럼 취급해 다른 값을 인코딩할 수 있게 합니다. 예를 들어, JSON에는 Date 객체의 인코딩이 없지만 Cap'n Web에는 있습니다. 예컨대 다음과 같은 메시지를 볼 수 있습니다:
{
event: "Birthday Week",
timestamp: ["date", 1758499200000]
}
리터럴 배열을 인코딩하려면 []로 한 번 더 감싸면 됩니다:
{
names: [["Alice", "Bob", "Carol"]]
}
즉, 요소가 하나뿐이고 그 요소가 다시 배열인 배열은, 내부 배열 자체로 평가됩니다. 첫 번째 요소가 타입 이름인 배열은, 그 타입의 인스턴스로 평가되며 나머지 요소들은 타입의 파라미터가 됩니다.
지원되는 타입은 고정된 소수에 한정됩니다. 본질적으로는 "구조화 가능한(Structured clonable)" 타입과 RPC 스텁 타입입니다.
이 기본 인코딩 위에, Cap'n Proto에서 영감을 얻은 RPC 프로토콜을 정의하되, 훨씬 단순화했습니다.
Cap'n Web은 대칭 프로토콜이므로, 프로토콜 레벨에서 명확한 “클라이언트” 또는 “서버”는 없습니다. 연결 양 끝에 있는 두 당사자가 메시지를 교환할 뿐이며, 모든 종류의 상호작용은 양방향 모두에서 일어날 수 있습니다.
설명을 쉽게 하기 위해, 두 당사자를 “앨리스(Alice)”와 “밥(Bob)”이라고 부르겠습니다.
앨리스와 밥은 어떤 형태로든 양방향 메시지 스트림을 설정하는 것으로 연결을 시작합니다. WebSocket일 수도 있지만, Cap'n Web은 애플리케이션이 자체 전송을 정의하는 것도 허용합니다. 스트림의 각 메시지는 앞서 설명한 대로 JSON으로 인코딩됩니다.
앨리스와 밥은 각각 연결에 관한 상태를 유지합니다. 특히 서로 "내보낸 객체"(export table)와 "가져온 참조"(import table)를 관리합니다. 앨리스의 export는 밥의 import에 대응하고, 그 반대도 마찬가지입니다. export 테이블의 각 항목에는 참조를 위해 사용되는 부호 있는 정수 ID가 있습니다. 이를 POSIX의 파일 디스크립터에 비유할 수 있습니다. 다만 파일 디스크립터와 달리 ID는 음수가 될 수 있으며, 연결 수명 동안 재사용되지 않습니다.
연결 시작 시, 앨리스와 밥은 각각 자신의 export 테이블에 하나의 항목을 채웁니다. 번호는 0이고, “메인” 인터페이스를 나타냅니다. 보통 한쪽이 “서버” 역할을 할 때, 그쪽은 공개 RPC 인터페이스를 ID 0으로 export하고, “클라이언트”는 빈 인터페이스를 export합니다. 하지만 이는 애플리케이션의 선택입니다. 어느 쪽이든 원하는 것을 export할 수 있습니다.
그다음 새로운 export는 두 가지 방법으로 추가됩니다.
push 메시지를 보낸 후, 앨리스는 이어서 “pull” 메시지를 보내 밥에게 이렇게 지시할 수 있습니다. push의 평가가 끝나면 결과를 능동적으로 직렬화해 "resolve"(또는 "reject") 메시지로 돌려보내 달라고요. 하지만 이는 선택 사항입니다. 앨리스가 RPC의 반환값 자체엔 관심 없고, 파이프라이닝에만 쓰고 싶을 수도 있습니다. 실제로 Cap'n Web 구현은 애플리케이션이 반환된 프로미스를 정말로 await할 때에만 “pull” 메시지를 보냅니다.
종합하면, 다음과 같은 코드가:
let namePromise = api.getMyName();
let result = await api.hello(namePromise);
console.log(result);
다음과 같은 메시지 교환을 만들 수 있습니다:
// api.getMyName() 호출. `api`는 서버의 메인 export이므로 export ID 0입니다.
-> ["push", ["pipeline", 0, "getMyName", []]
// api.hello(namePromise) 호출. `namePromise`는 첫 번째 push의 결과를 가리키므로 ID 1입니다.
-> ["push", ["pipeline", 0, "hello", [["pipeline", 1]]]]
// 두 번째 push의 결과를 능동적으로 직렬화해 돌려달라고 요청합니다.
-> ["pull", 2]
// 서버 응답.
<- ["resolve", 2, "Hello, Alice!"]
프로토콜의 더 자세한 내용은 문서를 확인하세요.
Cap'n Web은 새롭고 아직 매우 실험적입니다. 손볼 버그가 있을 수 있습니다. 하지만 우리는 이미 오늘 쓰고 있습니다. Cap'n Web은 최근 출시된 Wrangler의 “원격 바인딩(remote bindings)” 기능의 기반이며, 로컬 테스트 인스턴스의 workerd가 프로덕션의 서비스와 RPC로 통신할 수 있게 해줍니다. 또한 다양한 프런트엔드 애플리케이션에도 실험적으로 도입하고 있으며 — 이에 관한 블로그 포스트도 앞으로 더 기대해 주세요.
어찌 됐든, Cap'n Web은 오픈 소스이고, 지금 바로 여러분의 프로젝트에서 사용할 수 있습니다.

Cloudflare의 커넥티비티 클라우드는 기업 네트워크 전체를 보호하고, 고객이 인터넷 규모의 애플리케이션을 효율적으로 구축하도록 돕고, 모든 웹사이트나 인터넷 애플리케이션을 가속하며, DDoS 공격을 방어하고, 해커의 위협을 차단하며, Zero Trust 여정을 지원합니다.
어떤 기기에서든 1.1.1.1에 방문해 인터넷을 더 빠르고 안전하게 만드는 무료 앱을 시작해 보세요.
더 나은 인터넷을 구축하려는 우리의 사명에 대해 더 알고 싶다면 여기서 시작하세요. 새로운 커리어를 찾고 계시다면, 채용 공고를 확인해 보세요.