시리즈: 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

codex-rs/app-server/README.md

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 notificationAdapter event설명
item/agentMessage/deltatext(delta)delta가 이미 분리되어 도착
item/plan/deltathinking(delta)reasoning delta
item/started (mcpToolCall)toolStart(name, id)tool 시작
item/completed (mcpToolCall)toolEnd(id, output)tool 완료
turn/completedusage() + 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; }
}

참고 문서