시리즈: Codex SDK 한계와 app-server 모드
이 시리즈는 OpenAI Codex TS SDK로 LLM 응답 streaming을 구현하려다 구조적 한계에 봉착한 과정을 분석하고, 동일 바이너리의 app-server 모드를 대안으로 채택하여 실제 구현까지 도달하는 과정이다.
| 편 | 내용 | 핵심 |
|---|---|---|
| 1편 | TS SDK 한계 분석 | exec 모드는 delta를 emit하지 않는다 — app-server가 대안 |
| 2편 (본문) | app-server 구현 설계 | JSON-RPC 프로토콜 직접 구현으로 전체 기능 확보 |
- app-server 전환은 택시를 바꾸는 것이 아니라 택시를 부르는 방법만 바꾸는 것
@openai/codex-sdk패키지를 제거하고,@openai/codex바이너리를app-server모드로 직접 spawn- Adapter 인터페이스(
send,stream,interrupt,close)는 그대로 유지 — 상위 레이어 변경 없음
해당 개념이 필요한 이유
- 1편에서 TS SDK의 구조적 한계(delta streaming 불가, system prompt 불가)를 확인했다
- 대안으로 app-server 모드를 선택했지만, SDK가 해주던 역할을 직접 구현해야 한다
- JSON-RPC 프로토콜의 lifecycle과 메시지 구조를 이해해야 올바르게 구현할 수 있다
AS-IS: SDK가 택시를 호출해주는 구조
sequenceDiagram autonumber participant App as Application participant SDK as codex-sdk<br/>(카카오택시 앱) participant Bin as codex binary<br/>(택시) App->>SDK: thread.runStreamed(msg) SDK->>Bin: spawn exec mode + stdin Bin-->>SDK: JSONL stdout: item.completed SDK-->>App: yield ThreadEvent Note over App: 전체 텍스트 한 번에 도착
TO-BE: 직접 택시를 호출하는 구조
sequenceDiagram autonumber participant App as Application participant Bin as codex binary<br/>(택시) App->>Bin: spawn app-server + JSON-RPC Bin-->>App: notify: delta "Hello" Bin-->>App: notify: delta ", I'll" Bin-->>App: notify: delta " help" Bin-->>App: notify: completed Note over App: 토큰 단위 실시간 수신
| AS-IS (TS SDK) | TO-BE (app-server) | |
|---|---|---|
| 택시 (Rust 바이너리) | 동일 | 동일 |
| 택시 호출 방법 | 카카오택시 앱 (SDK) | 직접 전화 (JSON-RPC) |
| 결과 받는 방식 | 도착 알림만 (completed) | 실시간 위치 (delta) |
| 행선지 지정 | 앱이 허용하는 옵션만 | 모든 옵션 직접 지정 |
구조적 교체 범위
교체는 adapter 인터페이스 아래에서만 발생한다. 상위 레이어는 adapter가 바뀐 것을 알 수 없다.
graph TB subgraph UNCHANGED["변경 없음"] FE[Frontend SSE] Runner[Stream Runner] Factory[Adapter Factory] Types["Adapter Interface<br/>send / stream / interrupt / close"] end subgraph REPLACED["교체 범위"] Adapter["Adapter 내부 구현<br/>(재작성)"] Peer["JsonRpcPeer<br/>(신규)"] end subgraph SAME_BINARY["동일 바이너리"] Bin["@openai/codex<br/>app-server 모드"] end FE --> Runner --> Factory --> Types --> Adapter Adapter --> Peer --> Bin style UNCHANGED fill:#f5f5f5,stroke:#999 style REPLACED fill:#fff3cd,stroke:#f0ad4e style SAME_BINARY fill:#e8f5e9,stroke:#4caf50
| 구분 | 변경 |
|---|---|
| Frontend, Stream Runner, Factory, Types | 변경 없음 |
adapter.ts | 재작성 (SDK 호출 → JSON-RPC 호출) |
jsonrpc-peer.ts | 신규 (stdin/stdout 통신 레이어) |
@openai/codex-sdk | 제거 |
@openai/codex | 유지 (바이너리 패키지) |
app-server JSON-RPC lifecycle
sequenceDiagram autonumber participant App as Application participant Srv as codex app-server rect rgb(240, 240, 240) Note over App,Srv: Phase 1-3: 초기화 (한 번) App->>Srv: spawn process App->>Srv: initialize Srv-->>App: result: userAgent App->>Srv: thread/start (model, config) Srv-->>App: result: threadId end rect rgb(230, 245, 255) Note over App,Srv: Phase 4: Turn (반복) App->>Srv: turn/start (threadId, input) Srv-->>App: notify: turn/started Srv-->>App: notify: item/started Srv-->>App: notify: item/agentMessage/delta Srv-->>App: notify: item/agentMessage/delta Srv-->>App: notify: item/agentMessage/delta Srv-->>App: notify: item/completed Srv-->>App: notify: turn/completed end
JSON-RPC 메시지 분류
stdout에서 수신하는 메시지는 3가지 타입이다:
graph TD MSG["stdout 라인 수신"] MSG --> HAS_ID{id 존재?} HAS_ID -->|Yes| HAS_METHOD{method 존재?} HAS_ID -->|No| NOTIF["Notification<br/>delta, turn/completed 등"] HAS_METHOD -->|Yes| REQ["Request from Server<br/>approval 요청 등"] HAS_METHOD -->|No| RESP["Response<br/>initialize, thread/start 결과"] style NOTIF fill:#e3f2fd,stroke:#1976d2 style REQ fill:#fff3e0,stroke:#f57c00 style RESP fill:#e8f5e9,stroke:#388e3c
Notification 이벤트 매핑
| app-server notification | Adapter event | 설명 |
|---|---|---|
item/agentMessage/delta | text(delta) | delta가 이미 분리되어 도착 |
item/plan/delta | thinking(delta) | reasoning delta |
item/started (mcpToolCall) | toolStart(name, id) | tool 시작 |
item/completed (mcpToolCall) | toolEnd(id, output) | tool 완료 |
turn/completed | usage() + completion() | 턴 종료 |
핵심 차이: SDK에서는 fullText.slice(prev.length)로 delta를 계산했지만, app-server에서는 delta가 이미 분리되어 도착하므로 그대로 전달하면 된다.
핵심 코드 변환
constructor
// AS-IS
this.codex = new Codex({ config: { mcp_servers: {...} } });
this.thread = this.codex.startThread({ model, approvalPolicy: "never" });
// TO-BE
this.child = spawn("npx", ["@openai/codex", "app-server"]);
this.peer = new JsonRpcPeer(this.child);
await this.peer.request("initialize", { clientInfo: { name: "my-app" } });
const res = await this.peer.request("thread/start", { model, approvalPolicy: "never" });
this.threadId = res.thread.id;send() + stream()
// AS-IS
const { events } = await this.thread.runStreamed(message);
for await (const event of events) { yield mapEvent(event); }
// TO-BE
await this.peer.request("turn/start", { threadId, input: [{ type: "text", text: message }] });
for await (const notif of this.peer.notifications()) {
if (notif.method === "item/agentMessage/delta") yield textEvent(notif.params.delta);
if (notif.method === "turn/completed") { yield completionEvent(); break; }
}참고 문서
codex-rs/app-server/README.md— app-server 전체 프로토콜sdk/typescript/src/thread.ts— TS SDK 비교 대상- vibe-kanban Codex Executor — Rust 구현 참고