
【#3】 OpenClaw 해독하기 — 제어 평면(Control Plane) Gateway 프로토콜
요약
OpenClaw 시스템의 핵심인 Gateway 프로토콜의 제어 평면(Control Plane) 통신 구조를 분석합니다. WebSocket 기반의 JSON-RPC 스타일 프레임 구조와 TypeBox를 활용한 스키마 정의 방식을 다룹니다.
핵심 포인트
- WebSocket 기반의 JSON-RPC 스타일 프레임 구조 사용
- TypeBox를 통한 타입 정의 및 런타임 유효성 검사 통합
- 버전 협상을 통한 하위 호환성 유지 설계
- 비동기 이벤트 및 상태 추적을 위한 시퀀스 관리
본 기사의 코드는 OpenClaw main 브랜치의 cee2aca409 (version 2026.6.10) 시점을 참조합니다. 행 번호는 업데이트에 따라 어긋날 수 있습니다.
연재 「OpenClaw 해독하기」
OpenClaw의 중심에는 Gateway (제어 평면 (Control Plane))가 있으며, CLI · Web UI · 모바일 노드와 같은 다양한 클라이언트가 하나의 **Gateway 프로토콜 (Gateway Protocol)**로 대화합니다. 이번에는 packages/gateway-protocol/과 src/gateway/를 읽고, 「제어 평면의 통신 규약」을 해부합니다.
프로토콜의 토대는 3가지 프레임 형식입니다 (packages/gateway-protocol/src/schema/frames.ts).
// RequestFrame: 클라이언트 → 서버의 요청
{ type: "req", id: string, method: string, params?: unknown }
// ResponseFrame: id로 대응되는 서버 응답
...
형태로 보면 WebSocket 상의 JSON-RPC 스타일입니다. req에 고유한 id를 부여하고, res가 동일한 id로 반환됩니다. 서버로부터의 비동기 통지는 event로 흐르며, seq / stateVersion을 통해 클라이언트는 상태를 추적할 수 있습니다. 하트비트(Heartbeat)는 TickEventSchema ({ ts }), 정지 예고는 ShutdownEventSchema ({ reason, restartExpectedMs? })로 정의되어 있습니다.
스키마 정의에는 TypeBox (Type.Object / Type.Literal 등)가 사용되며, packages/gateway-protocol/src/schema/protocol-schemas.ts가 약 270개의 프로토콜 스키마 이름을 일괄 등록하고 있습니다. 타입과 런타임 유효성 검사(Validation)가 동일한 정의로부터 도출되는 것으로, AGENTS.md의 「외부 경계는 zod / 기존 스키마 헬퍼로 고정한다」는 방침을 구현한 것입니다.
연결 시, Gateway는 먼저 connect.challenge 이벤트로 nonce를 발행하며, 클라이언트는 이를 사용한 인증 정보와 함께 ConnectParams (frames.ts:30 부근)로 자신의 능력 및 대응 프로토콜 범위를 신고합니다.
export const ConnectParamsSchema = Type.Object({
minProtocol: Type.Integer({ minimum: 1 }),
maxProtocol: Type.Integer({ minimum: 1 }),
...
서버는 대응 가능한 버전을 선택하여 HelloOk로 반환합니다. 현재의 버전 상수는 명확합니다 (packages/gateway-protocol/src/version.ts).
export const PROTOCOL_VERSION = 4 as const;
export const MIN_CLIENT_PROTOCOL_VERSION = 4 as const;
export const MIN_PROBE_PROTOCOL_VERSION = 4 as const;
min/maxProtocol을 교환한 후 합의된 버전을 확정하는 설계이므로, 신구 클라이언트가 혼재되어도 파손되지 않습니다.
AGENTS.md의 아키텍처 규약은 프로토콜 변경에 대해 엄격한 선을 긋고 있습니다.
Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
Protocol version bumps: explicit owner confirmation only; never automatic/generated.
즉, 「먼저 하위 호환이 되는 추가 방식으로 해결한다. 비호환 변경은 버전 관리(Versioning) + 문서화 + 클라이언트 추종이 필수다. 버전 번호의 상향은 소유자(Owner)의 확인이 필수이며, 자동 또는 생성 방식으로는 절대 수행하지 않는다」는 뜻입니다. PROTOCOL_VERSION = 4라는 한 줄이 무거운 의미를 갖는 이유가 바로 이것입니다.
에러는 자유 문자열(freeform string)이 아니라, 닫힌 코드(closed code)와 재시도 정보를 가집니다 (schema/error-codes.ts).
export const ErrorCodes = {
NOT_LINKED, NOT_PAIRED, AGENT_TIMEOUT,
INVALID_REQUEST, APPROVAL_NOT_FOUND, UNAVAILABLE,
...
retryable / retryAfterMs를 구조로 가지므로, 클라이언트는 "재시도해도 되는지·언제 할지"를 추측이 아닌 타입을 통해 판단할 수 있습니다. 이는 AGENTS.md의 "runtime branching은 discriminated union / closed code로. freeform string의 semantic sentinel을 피할 것"이라는 코드 규약 그 자체입니다.
src/gateway/ 측에서는 메서드가 레지스트리(registry)에서 관리됩니다 (methods/registry.ts). createGatewayMethodRegistry()가 메서드 기술자(descriptor)를 정규화 및 검증하며, 이름의 유일성·스코프 할당·소유자 추적을 강제합니다. 레지스트리는 getHandler() / listMethods() / getScope() / isControlPlaneWrite() 등의 뷰(view)를 반환합니다.
요청 처리의 골격은 다음과 같습니다 (src/gateway/server-methods.ts:638 부근).
export async function handleGatewayRequest(opts) {
// 1. 인가 체크
const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry);
...
주목할 점은 두 가지입니다. 첫째, "제어 평면 쓰기(control-plane write)"에만 속도 제한(rate limit)을 적용한다는 것입니다. 상태를 변경하는 메서드와 읽기 전용 메서드를 레지스트리가 구분하고 있으므로, 쓰기 작업만 보호할 수 있습니다. 둘째, 핸들러 그룹(agentHandlers, channelsHandlers, cronHandlers 등)이 **지연 로드(lazy load)**된다는 점입니다. 기동 시 모든 메서드 구현을 읽어들이지 않는, #02에서 보았던 "지연 초기화"가 여기에서도 일관되게 적용됩니다.
src/gateway/boot.ts의 runBootOnce()는 워크스페이스의 BOOT.md를 읽어 내부 런타임 컨텍스트(runtime context)의 경계로 감싸고, 격리된 세션 내에서 기동 체크를 실행합니다. 성공 또는 실패에 따라 세션 매핑을 스냅샷/복원하는 구조로 되어 있어, 운영 세션을 오염시키지 않고 "기동 시의 건전성 확인"을 수행할 수 있습니다.
클라이언트 구현은 packages/gateway-client/src/client.ts입니다. WebSocket 클라이언트로, gateway-protocol의 RequestFrame / ResponseFrame / EventFrame 타입을 그대로 사용합니다. 디바이스 인증(device-auth.ts), 토큰 로테이션, 페어링 플로우를 내포하며, OpenClaw 고유의 상태(디바이스 키·토큰 저장·로그)는 GatewayClientHostDeps로서 외부에서 주입됩니다.
프로토콜 정의(packages/gateway-protocol)와 통신 구현(packages/gateway-client)이 별도의 패키지로 분리되어 있다는 점이 핵심입니다. 프로토콜은 "계약"이고, 클라이언트는 "계약을 사용하는 구현"입니다. AGENTS.md의 "재사용되는 순수 로직은 packages/*-core로 분리한다"는 역할 분담이 여기서도 효과를 발휘합니다.
src/gateway/AGENTS.md는 핫 패스(hot path)의 가드레일을 정의합니다. "테스트나 기동 시 불필요하게 번들 플러그인의 런타임을 실체화하지 않는다", "풀 채널(full channel) 플러그인 이전에 경량 아티팩트 리졸버(artifact resolver)를 우선한다" 등입니다. 나아가 src/gateway/server-methods/AGENTS.md는 세션 쓰기의 안전 규칙을 명문화합니다.
Never append raw
type: "message"
항목을 JSONL 쓰기를 통해 직접 추가하지 마십시오 (압축/이력(compaction/history)을 깨뜨립니다).
항상 SessionManager.appendMessage(...)를 사용하십시오.
트랜스크립트(Transcript)는 parentId를 통한 체인(Chain)/DAG 구조이며, 생(raw) JSONL 쓰기는 압축과 이력을 파괴합니다. 이 불변성(invariant)에 대해서는 #08 세션에서 심도 있게 다룹니다.
- 프로토콜은 WebSocket 기반의 JSON-RPC 스타일이며,
req/res/event의 3가지 프레임과 TypeBox 스키마를 사용합니다. - 버전 협상(
min/maxProtocol→HelloOk)과 엄격한 bump 규율(PROTOCOL_VERSION = 4, 소유자 확인 필수)을 따릅니다. - 에러는 종료 코드(closed code) + 재시도 메타데이터를 통해 전달되어 추측을 배제합니다.
- 서버는 **스코프별 레지스트리(scope-specific registry) + 지연 로드 핸들러(lazy-load handler)**를 통해 쓰기 작업에 대해서만 속도 제한(rate limiting)을 적용합니다.
- "계약(gateway-protocol)"과 "구현(gateway-client)"을 패키지로 분리합니다.
#04는 이 Gateway에 기능을 추가하는 유일한 창구인 **플러그인 SDK와 로더(loader)**입니다. 130개 이상의 플러그인이 어떻게 "코어를 오염시키지 않고" 능력을 주입하는지, 매니페스트(manifest), capability 레지스트리, 그리고 수많은 얇은(thin) SDK 엔트리 포인트(entry point)의 비밀을 파헤쳐 봅니다.
참고: packages/gateway-protocol/src/schema/frames.ts, .../version.ts, .../schema/error-codes.ts, src/gateway/server-methods.ts:638, src/gateway/methods/registry.ts, src/gateway/boot.ts, packages/gateway-client/src/client.ts, src/gateway/AGENTS.md
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기