B
Boaz2026. 5. 8.

Claude Code 까보니 반복문이었습니다.

이전 글 Claude Code 까보니 React 앱이었습니다에서 Claude Code의 4 layer 중 UI layer가 React/Ink로 만들어졌다는 걸 봤어요. 한 글자씩 터미널에 찍히는 스트리밍 응답, spinner, 토큰 카운터 — 그게 다 React 컴포넌트였죠.

그렇다면 모델이 만들어내는 토큰을 어떻게 React 컴포넌트의 props로 전달한 걸까요? 그 과정에 있는 게 오늘 까볼 Agent Loop이에요.

Claude Code에서 사용자가 Enter를 누르면 결국 한 함수가 호출됩니다 — onQuery. 그 안쪽에서 무슨 일이 벌어지는지를 따라가 보려고 해요.

(이 글도 이전 글과 마찬가지로 5팀의 source leak 분석을 교차 검증한 내용을 바탕으로 합니다 — shareAI-lab의 난독화 해제 분석, Yuyz0112의 mitmproxy 캡처, Alex Kim의 source leak 분석, Karan Prasad의 51만 줄 리버스 엔지니어링, Kir Shatrov의 internals 등.)


1. 큰 그림 — onQuery가 호출되기 전과 후

1-A. 4-layer 아키텍처에서의 위치

queryLoop 함수는 Agent Loop layer 안쪽에 위치해 있습니다.

%%{init: {"themeVariables": {"fontSize": "16px"}, "flowchart": {"padding": 14, "nodeSpacing": 30, "rankSpacing": 50}}}%%
flowchart LR
    UI["<b>UI Layer (React)</b><br/><br/>PromptInput<br/>StreamingText<br/>Static"]
    AGENT["<b>Agent Loop</b><br/><br/><b>queryLoop()</b> ★<br/>5단계 컴팩션<br/>SDK + SSE"]
    TOOLS["<b>Tools</b><br/><br/>Bash, Read,<br/>Edit, Write,<br/>Glob, Grep,<br/>Task, MCP"]
    PERM["<b>Permission</b><br/><br/>6 모드<br/>+ PreToolUse<br/>+ PostToolUse"]

    UI -->|"onQuery"| AGENT
    AGENT -->|"tool_use"| TOOLS
    TOOLS -->|"검증"| PERM

    style UI fill:#dbeafe,stroke:#3b82f6,color:#1f2937
    style AGENT fill:#fde68a,stroke:#f59e0b,color:#1f2937,stroke-width:3px
    style TOOLS fill:#dcfce7,stroke:#22c55e,color:#1f2937
    style PERM fill:#fef3c7,stroke:#f59e0b,color:#1f2937

지난 시간 살펴본것처럼, React의 역할은 아래와 같아요.

  • StreamingText 컴포넌트가 모델 응답을 한 글자씩 터미널에 찍어줌
  • Ink renderer가 character grid + ANSI escape로 실제 터미널 화면을 그림
  • 사용자 입장에선 "ChatGPT처럼 글자가 한 자씩 찍히는 효과"

근데 React/Ink는 그리는 역할이에요. 그릴 내용을 주는 역할queryLoop 가 맡습니다. 바로 오늘 까볼 반복문의 정체입니다.

1-B. onQuery 안에서 queryLoop()까지

사용자가 Enter를 누르고 나서 queryLoop이 호출되기까지 아래 단계를 거칩니다.

flowchart TB
    A["PromptInput.tsx<br/>(Ink 컴포넌트)"]
    B["REPL.onSubmit<br/>handlePromptSubmit"]
    C["createUserMessage<br/>messages 배열 완성"]
    D["onQueryImpl<br/>시스템 프롬프트<br/>도구 카탈로그<br/>옵션 셋업"]
    E["<b>queryLoop(messages, options)</b>"]

    A -->|"Enter → onSubmit(text)"| B
    B --> C
    C --> D
    D -->|"호출 한 번"| E

    style E fill:#fde68a,stroke:#f59e0b,color:#1f2937,stroke-width:3px
  1. PromptInput.tsx (Ink 컴포넌트) — 키 입력 받음, Enter 시 onSubmit(text) 호출
  2. REPL.onSubmit / handlePromptSubmit — 텍스트를 SDK 메시지 객체로 감쌈, 메시지 큐에 push
  3. onQueryImpl — 시스템 프롬프트 어셈블, 도구 카탈로그 결정, 옵션 객체 구성
  4. queryLoop(messages, options) 호출

여기서 핵심: queryLoop 호출 자체는 한 번이에요. 그 한 번이 generator 객체를 만들고, 그 generator 안에서 사용자가 다시 입력할 수 있을 때까지 모든 준비를 마칩니다.

이에 관한 디테일은 다음 회로 미루고, 오늘은 queryLoop 에 집중해서 살펴볼게요.


2. queryLoop 의 정체와 선택 배경

2-A. queryLoop 수도코드

async function* queryLoop(messages) {
  while (true) {
    await compact(messages);
    const stream = await api(messages);
    for await (const event of stream) {
      yield event;
      if (event.type === "tool_use") {
        await dispatch(event);
      }
    }
    if (stop_reason !== "tool_use") return;
  }
}

이게 queryLoop의 정체예요.

  • 위치: src/query.ts (난독화 빌드에서는 nO라는 이름)
  • 타입: async function* — async generator
  • 소비: 외부에서 for await...of, 내부에서 while (true)

근데 이 모양이 처음 보면 좀 이상해요. 한 함수에 async도 있고, function*도 있고, while (true) 무한 루프 안에 또 for await이 있고.. 익숙한 모양은 아니죠.

이 모양이 왜 이렇게 생겼는지 이해하려면 while 문 안에 한 turn이 시간 순서로 어떻게 진행되는지를 봐야 합니다.

2-B. 한 turn의 타임라인

flowchart LR
    A["compact"] -->|"await"| B["api 호출"]
    B -->|"await"| C["delta yield"]
    C -->|"yield"| D["delta yield"]
    D -->|"yield"| E["..."]
    E -->|"yield"| F["tool_use"]
    F -->|"await"| G["dispatch"]
    G -->|"await"| H["compact"]
    H -->|"..."| I["다음 turn"]

    style A fill:#dbeafe,stroke:#3b82f6
    style H fill:#dbeafe,stroke:#3b82f6
    style B fill:#fef3c7,stroke:#f59e0b
    style G fill:#dcfce7,stroke:#22c55e
    style C fill:#e0e7ff,stroke:#6366f1
    style D fill:#e0e7ff,stroke:#6366f1
    style F fill:#fee2e2,stroke:#ef4444

한 turn 안에서 일어나는 일:

  1. 메시지 컴팩션 (비동기 — await)
  2. Anthropic API 호출 (비동기 — await)
  3. SSE delta가 도착할 때마다 UI에 흘려보냄 (즉시 — yield)
  4. tool_use 블록이 닫히면 도구 실행 (비동기 — await)

매 박스 사이의 화살표가 전부 await 아니면 yield라는 사실에 주목해주세요. 자연스럽게 세 가지 질문이 이어집니다.

  1. yield가 뭐길래? — 함수 안에서 값을 "흘려보낸다"는 게 무슨 뜻인지
  2. yield와 await가 한 함수에 같이 있다고? — 보통 async function에는 await만, function*에는 yield만 있는데
  3. 이걸 한 함수로 표현할 수 있는 문법이 뭐지? — 답은 async generator

세 질문을 차례대로 풀어볼게요.


3. 시작은 generator부터

잠깐 JavaScript 문법을 다시 짚어 볼게요. 이게 명확해야 다음 섹션의 코드를 이해할 수 있어서요.

3-A. Generator란 무엇인가

generator는 한 함수를 여러 번에 나눠서 실행하게 해주는 기능이에요. 중간에 멈췄다가 다시 이어서 실행할 수 있는 함수죠.

// 일반 함수 — 한 번에 끝까지 돈다
function normal() {
  return 1;
  return 2; // 도달 못 함
}
normal(); // → 1

// generator — yield에서 멈췄다 재개
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const g = gen();
g.next(); // → { value: 1, done: false }   ← 첫 yield까지 실행하고 멈춤
g.next(); // → { value: 2, done: false }   ← 다음 yield까지 재개
g.next(); // → { value: 3, done: false }
g.next(); // → { value: undefined, done: true }

핵심 포인트:

  • function* 으로 선언 (별표)
  • return 대신 yield 로 값을 내보냄
  • 호출하면 값이 아니라 generator 객체를 리턴
  • .next()를 부를 때마다 다음 yield까지만 실행

for...of는 사실 이 .next()를 자동으로 반복 호출해주는 문법이에요.

for (const v of gen()) console.log(v); // 1, 2, 3

짝수를 무한히 찍어내는 evens 라는 generator를 예로 들어볼게요.

function* evens() {
  let i = 0;
  while (true) {
    yield i;
    i += 2;
  }
}

const it = evens();
it.next(); // 0
it.next(); // 2
it.next(); // 4

while (true) 무한 루프지만 멈추지 않아요. 왜냐하면 generator는 소비자가 .next()를 호출할 때만 while 문 한 턴씩 진행하기 때문이에요. .next()를 호출하지 않으면 그대로 멈춰 있습니다.

3-B. 왜 generator인가 — for/while의 한계

같은 evens 요구사항을 genrator 가 아닌, 다른 방식으로 짜면 어떨까요? 4가지로 비교해 볼게요.

① while + 배열로 미리 다 만들기

function evens(n) {
  const out = [];
  let i = 0;
  while (out.length < n) {
    out.push(i);
    i += 2;
  }
  return out;
}
const nums = evens(1000000); // 100만 개 다 만들고 시작

문제: n을 미리 정해야 함. 무한 시퀀스 불가, 메모리 다 먹음.

② while + 콜백으로 외부에서 중단

function evens(callback) {
  let i = 0;
  while (true) {
    const stop = callback(i);
    if (stop) break;
    i += 2;
  }
}
evens((v) => {
  console.log(v);
  return v >= 10;
});

가능은 한데 제어가 역전되어요. 또한 "여러 소비자가 각자 페이스로" 받기도 어렵고, 중단했다 재개도 불가능합니다.

③ 수동 iterator 객체

function makeEvens() {
  let i = 0;
  return {
    next() {
      const v = i;
      i += 2;
      return v;
    },
  };
}
const it = makeEvens();
it.next(); // 0
it.next(); // 2

이건 사실 generator의 수동 구현이에요. 가능하지만 변수(i)를 클로저로 일일이 보존해야 하고, 분기가 복잡해지면 상태 머신을 직접 짜야 합니다. "지금 어느 단계에 있는지" 플래그 변수를 두고 if/switch로 분기 복원해야 해요.

핵심 정리:

요구사항 충분한 도구
단순 반복 for / while
결과를 한 번에 다 만들 수 있음 배열 + for
하나씩, 필요할 때, 멈췄다 재개, 무한 가능 generator

generator의 핵심 장점은 상태 머신을 컴파일러가 자동으로 짜준다는 거예요. yield 자리마다 컴파일러가 알아서 case 분기와 상태 변수를 관리합니다.

3-C. 왜 async generator인가

이제 요구사항을 살짝 바꿔볼게요. 짝수 대신 **"네트워크에서 청크 하나씩 받아 흘려보내기"**로요.

① 동기 generator로

function* stream() {
  while (true) {
    const chunk = ???;  // 200ms 뒤 도착
    yield chunk;
  }
}

JavaScript는 single-thread + non-blocking이에요. 네트워크 응답을 동기적으로 기다릴 수단이 없어요. yield 시점에 값이 손에 없으면 yield 자체가 막힙니다.

② Promise를 직접 yield

function* stream() {
  while (true) {
    yield fetchChunk(); // Promise를 yield
  }
}

for (const p of stream()) {
  const v = await p; // 소비자가 매번 풀어줌
  if (v.error) break;
}

가능은 한데 아래 한계들이 남게 됩니다.

  • 생산자 안에서 await 불가 → "이전 청크 결과 보고 다음 요청 모양 정하기" 같은 로직 불가
  • 에러 전파, 취소가 소비자 책임
  • generator/Promise/await가 뒤섞여 가독성 깨짐

③ 수동 AsyncIterator

function makeStream() {
  return {
    [Symbol.asyncIterator]() { return this; },
    async next() {
      const chunk = await fetchChunk();
      return { value: chunk, done: false };
    }
  };
}

for await (const v of makeStream()) { ... }

가능하지만 분기/루프가 복잡해지면 또 상태 머신을 손으로 짜야 합니다.

generator vs async generator의 차이를 정리하면:

차이점 Generator Async Generator
선언 function* async function*
yield 값 동기 Promise로 감싸짐
안에서 await
소비 for...of for await...of
적합한 데이터 메모리 안의 시퀀스 시간차로 도착하는 시퀀스

generator가 동기 상태 머신을 컴파일러에게 위임한 문법이라면, async generator는 비동기 상태 머신을 컴파일러에게 위임한 문법이에요.

즉, 위에 수동 AsyncInterator 에 이어 상태머신을 손으로 짠다면 이런 코드가 됩니다.

function step(state) {
  switch (state.phase) {
    case "compact":
      return compact(state).then(() => {
        state.phase = "fetch"; /* ... */
      });
    case "fetch":
      return api(state).then((r) => {
        state.phase = "stream"; /* ... */
      });
    case "stream":
      return next(state).then((d) => {
        state.phase = "dispatch"; /* ... */
      });
    case "dispatch":
      return tool(state).then(() => {
        state.phase = "compact"; /* ... */
      });
  }
}

3-D. 그래서 queryLoop은 async generator일 수밖에 없다

이제 1번 섹션의 그림을 다시 볼게요.

[compact] → [api 호출] → [delta yield] → [delta yield] → ... → [tool_use] → [dispatch] → [compact] → ...
  await       await         yield          yield                  yield        await        await

이 과정은 아래 네 가지 조건을 만족해야 해요.

  1. SSE 청크는 시간차로 도착 → await 필요
  2. 한 청크씩 즉시 UI로 → yield 필요
  3. 매 turn마다 도구 실행 비동기 대기 → await 필요
  4. 무한 turn 가능, 중간 종료 가능 → generator

Promise 체인 + 상태 변수 + 분기, 이걸 async function* 하나로 해결할 수 있습니다.

async function* stream() {
  while (true) {
    const chunk = await fetchChunk();
    if (chunk.done) return;
    yield chunk;
  }
}

for await (const v of stream()) { ... }

while (true) + await + yield가 한 함수 안에 있어요. 상태 보존 되고, 소비자도 for await으로 깔끔한 구조입니다.

queryLoop이 바로 이 구조예요.


4. 실제 코드로 들어가기 — queryLoop 본체 까보기

이제 진짜 코드를 봅니다. 한줄 한줄 자세히 살펴보려면 양이 꽤 되어서요. 이번 시간에는 queryLoop 본체 한 함수에 집중해서 살펴볼게요. 안에서 호출되는 compact / dispatch / h2A / hooks 내부는 다음 시간에 이어서 살펴봅니다.

4-A. queryLoop 시그니처

async function* queryLoop(
  messages: Message[],
  options: {
    model: string,
    max_tokens: number,
    tools: ToolSchema[],
    abortSignal: AbortSignal,
    // ...
  }
): AsyncGenerator<Event>

세 가지 짚을 점.

  • 입력은 messages: Message[] 한 배열. 시스템 프롬프트, 사용자 입력, 이전 assistant 응답, tool_result가 다 이 배열 안에 시간 순으로 쌓여 있음.
  • options에는 모델 이름, max_tokens, 사용 가능한 도구 목록, 그리고 **abortSignal**이 있어요. 이 abortSignal이 ESC 취소를 가능하게 합니다.
  • 반환 타입이 AsyncGenerator<Event>. Promise<Response>가 아니라 이벤트를 흘려보내는 generator 입니다.

4-B. while 문과 "한 turn"의 정체

while 문 안에 동작은 아래와 같아요.

async function* queryLoop(messages, options) {
  while (true) {
    // 1. 메시지 컴팩션
    await compactIfNeeded(messages);

    // 2. API 호출 시작
    const stream = await client.beta.messages.create({
      model: options.model,
      messages,
      tools: options.tools,
      stream: true,
      signal: options.abortSignal,
    });

    let stopReason;
    const assistantMessage = { role: "assistant", content: [] };
    const toolResults = [];

    // 3. SSE 이벤트 소비 + yield
    for await (const event of stream) {
      yield event;

      // SSE 이벤트 누적 → assistantMessage 완성
      accumulate(assistantMessage, event);

      // 4. tool_use 블록이 닫히면 도구 실행
      if (event.type === "content_block_stop" && isToolUse(event)) {
        const result = await dispatchTool(event, options);
        toolResults.push({
          type: "tool_result",
          tool_use_id: event.tool_use_id,
          content: result,
        });
      }

      if (event.type === "message_delta") {
        stopReason = event.delta.stop_reason;
      }
    }

    // 5. 다음 turn을 위한 messages 확장
    messages.push(assistantMessage);
    if (toolResults.length) {
      messages.push({ role: "user", content: toolResults });
    }

    // 6. 종료 판정
    if (stopReason !== "tool_use") return;
  }
}

이 코드를 자세히 읽기 전에 "한 turn" 정의부터 짚을게요.

한 turn은 보는 관점에 따라 그 정의가 조금씩 달라져서요.

관점 "한 turn"
사용자 "한 답변" — Enter 누른 뒤 다시 입력 가능해질 때까지
모델 "한 응답 생성" — 한 번의 SSE 스트림이 시작되고 끝나는 단위
루프 while 문 한 iteration[compact → api → for await → dispatch → tool_result push] 한 사이클

핵심: 한 답변 안에 여러 turn이 들어갈 수 있어요. 사용자가 "이 코드에서 버그를 찾아줘"라고 한 마디 던져도, 모델이 Read 도구로 파일 읽고 → Grep으로 검색하고 → 다시 답하면 iteration이 3번 도는 거예요.

이 정의를 알고 while 문을 다시보면

  • queryLoop 함수 호출은 딱 한 번 (사용자가 메시지 보낼 때)
  • 그 한 번이 generator 객체를 만들고
  • generator 안의 while (true)turn마다 한 iteration씩 돔
  • iteration 안에서 5단계 (compact → api → for await → dispatch → tool_result push)가 한 사이클

함수는 한 번 호출하지만, iteration은 여러 번 돕니다. generator라서 가능해요. 일반 async function이었다면 함수 호출당 한 번의 응답만 만들고 끝나게 되어요.

4-C. SSE를 yield로 흘려보내는 부분

whil 문 안에 가장 중요한 부분을 다시 봅니다.

for await (const event of stream) {
  yield event;
  // ...
}

이 두 줄이 1편에서 본 스트리밍 렌더링의 시작점이에요.

stream은 Anthropic SDK가 돌려주는 SSE async iterable 입니다. stream 을 await 하면 이벤트를 받을 수 있어요. 이벤트 타입은 아래 6가지에요.

message_start         ← 새 응답 시작
content_block_start   ← text 또는 tool_use 블록 열림
content_block_delta   ← text_delta 또는 input_json_delta
content_block_stop    ← 블록 종료
message_delta         ← stop_reason 확정
message_stop          ← 응답 완전 종료

queryLoop은 이 이벤트를 받자마자 yield event그대로 전달합니다. 그래서 외부 소비자(onQueryImplfor await 루프)는 SSE 이벤트를 순차로 받게 되고, 그걸 h2A 스티어링 버스에 넣고, h2A는 Ink의 StreamingText 컴포넌트로 토큰을 전달합니다. 그 결과 스트리밍 렌더링이 가능해집니다.(타자 치는 효과)

이 yield 한 이벤트가 어떻게 h2A로 전달되는지, h2A 안에서 어떻게 우선순위 큐로 분류되는지는 다음 시간에 봅니다.

좀 더 천천히 풀어볼게요. 위 두 줄이 어떻게 화면에 글자를 찍는지, 한 단계씩 따라갑시다.

text_delta가 정확히 뭔지

text_delta모델이 만들어낸 텍스트 한 조각이에요. 위 6종 이벤트 중 가장 자주 도착하는 종류이기도 합니다.

JSON 구조를 보면 이렇게 생겼어요.

{
  "type": "content_block_delta",
  "delta": {
    "type": "text_delta",
    "text": "안녕"
  }
}

이름이 "delta"인 이유는 전체가 아니라 "이전 대비 새로 추가된 조각"이라서요. 모델이 "안녕하세요"를 만든다고 하면 이렇게 쪼개져서 옵니다.

text_delta: "안"
text_delta: "녕"
text_delta: "하세"
text_delta: "요"

받는 쪽은 prev + delta.text로 누적하면 "안녕하세요" 완성. (참고: tool_use 블록일 땐 text_delta 대신 input_json_delta라는 다른 종류가 옵니다 — 도구 인자 JSON 한 조각.)

yield는 던지기, for await는 받기

yield event 한 줄은 단독으로 작동하지 않아요. 바깥쪽에 그걸 받아주는 짝이 있어야 합니다.

flowchart LR
    A["queryLoop<br/>(생산자)"] -->|"yield event"| B["onQueryImpl<br/>(소비자)"]
    B -->|"setStreamingText"| C["React state"]
    C -->|"reconciler"| D["Ink<br/>터미널 출력"]

    style A fill:#fde68a,stroke:#f59e0b,color:#1f2937
    style B fill:#dbeafe,stroke:#3b82f6,color:#1f2937
    style C fill:#e0e7ff,stroke:#6366f1,color:#1f2937
    style D fill:#dcfce7,stroke:#22c55e,color:#1f2937
  • queryLoop 안의 yield event — "이 이벤트 받아갈 사람?" 하고 던짐
  • 바깥쪽 onQueryImplfor await (const event of queryLoop(...)) — "네, 받았어요" 하고 집어감

받은 쪽은 그 이벤트를 보고 React state를 한 줄 바꿔요.

setStreamingText((prev) => prev + delta.text);

state가 바뀌면 1편에서 본 declarative streaming이 발동돼요. React reconciler → Ink → 터미널에 글자 한 개가 찍힙니다.

한 글자가 화면에 찍히기까지의 여행

flowchart TB
    A["모델이 토큰 1개 생성<br/>(예: '안')"] --> B["서버가 SSE 한 줄 전송"]
    B --> C["queryLoop의 for await가 받음"]
    C --> D["<b>yield event ★</b>"]
    D --> E["onQueryImpl의 for await가 받음"]
    E --> F["setStreamingText(prev + '안')"]
    F --> G["React re-render"]
    G --> H["Ink가 터미널에 '안' 찍음"]

    style A fill:#dbeafe,stroke:#3b82f6,color:#1f2937
    style D fill:#fde68a,stroke:#f59e0b,color:#1f2937,stroke-width:3px
    style H fill:#dcfce7,stroke:#22c55e,color:#1f2937

이 모든 단계가 수 ms 안에 일어나요. 그래서 "모델이 토큰 만든 순간"과 "화면에 글자 찍히는 순간"이 거의 동시인 거죠.

정리하면, text_delta 이벤트가 1개 도착할 때마다 화면에는 글자가 1개씩 늘어 있고, 그 둘 사이를 잇는 코드가 단 한 줄, yield event입니다. 만약 이 한 줄이 없다면? queryLoop 안에서 SSE를 다 받아 모은 다음 한꺼번에 return해야 해요. 모델이 응답을 다 만들 때까지 화면은 빈 채로 멈춰 있고, 타이핑 효과는 사라집니다.

4-D. tool_use 분기와 tool_result 추가

이벤트 흐름 중간에 한 가지 분기가 있어요. content_block_stop 시점에 그 블록이 tool_use였다면:

if (event.type === "content_block_stop" && isToolUse(event)) {
  const result = await dispatchTool(event, options);
  toolResults.push({
    type: "tool_result",
    tool_use_id: event.tool_use_id,
    content: result,
  });
}

짚고 넘어갈 지점: tool_use 인자도 SSE delta로 쪼개져서 들어와요. 한 글자씩 JSON이 누적되다가 content_block_stop에서 비로소 완성된 JSON을 JSON.parse()해서 도구 인자가 확정됩니다. 그래서 이 시점이 도구 실행 시작 시점이에요.

dispatchTool은 권한 훅(PreToolUse)을 거쳐 도구 엔진(MH1)을 호출합니다. 이 안쪽도 다음 시간에 살펴볼게요.

도구 실행이 끝나면 tool_result를 만들어서 임시 배열에 보관해 두고, 이 turn이 다 끝난 다음 messages에 추가해요.

messages.push(assistantMessage); // 이번 turn 모델 응답
if (toolResults.length) {
  messages.push({ role: "user", content: toolResults }); // 도구 결과
}

이게 self-feeding의 정체예요. 이번 turn의 출력이 다음 turn의 입력이 됩니다. 그리고 while (true)이 한 바퀴 돌면서 같은 messages 배열을 들고 다시 client.beta.messages.create를 호출. 모델 입장에선 "내가 이전에 도구를 불렀는데 그 결과가 이거다"라는 맥락으로 보임.

4-E. 종료 — stop_reason 분기

while 루프를 살아 있게 하는 단 하나의 신호가 stop_reason이에요.

if (stopReason !== "tool_use") return;

stop_reason은 4종.

의미 처리
end_turn 모델이 자발적으로 응답 종료 Stop hook → idle
tool_use 도구 호출 요청 루프 계속
max_tokens 출력 토큰 한도 도달 StopFailure hook
stop_sequence 지정 정지 문자열 감지 Stop hook

tool_use만이 루프를 살려두는 유일한 신호예요. 나머지 셋은 모두 return을 만나서 generator가 종료됩니다.

return을 만나면 generator의 마지막 .next(){ value: undefined, done: true }를 돌려주고, 이걸 받은 외부의 for await...of도 같이 종료돼요. 그 시점에 사용자 터미널에는 다시 입력 프롬프트가 뜨고(입력 가능), 한 답변이 끝납니다.

4-F. 취소 — AbortSignal 의 기능

마지막으로 짚을 게 ESC 취소예요. 사용자가 답변 중에 ESC를 누르면 다음 일이 일어납니다.

  1. options.abortSignal에 abort 시그널이 발행
  2. 진행 중인 client.beta.messages.create fetch가 즉시 중단
  3. for await (const event of stream)이 예외를 던짐
  4. generator가 예외를 받지 않으면 그 시점에 자연 종료
  5. 외부 for await...of도 함께 종료
  6. 그 시점까지 yield된 텍스트는 화면에 그대로 남아 있음

이게 async generator 를 활용했을 떄의 장점이에요. 별도의 "취소 상태 변수"를 두지 않고, abortSignal 로 fetch부터 generator까지 한 번에 정리할 수 있어요. 부분 응답은 보존하고, 진행 중이던 도구 실행도 (도구가 abortSignal을 받아주면) 같이 중단합니다.

만약 queryLoop이 일반 async function으로 짜였다면 이걸 다 직접 처리해야 했을 거에요. "어디까지 진행됐는지" 상태 변수, 부분 응답 보존, 도구 정리, fetch abort 전파 등을 전부 코드로 짜야 합니다. async generator는 이걸 언어(Javascript) 차원에서 해결합니다.


5. 마무리

5-A. 세 줄 요약

queryLoop은 src/query.tsasync function*이다. 외부에서 for await으로 소비되고, 내부에서 while (true)로 turn을 반복한다. SSE의 시간차 시퀀스 + 매 단계의 await + UI 스트리밍 + 취소 가능성 — 이 네 가지를 한 함수에 담을 수 있는 유일한 문법이 async generator였기 때문이다.

5-B. 이번 글의 핵심 인사이트

언어의 기능 하나가 아키텍처 전체를 결정하기도 합니다.

async generator라는 문법 선택 하나가 Claude Code에서 네 가지를 가능하게 했어요.

  • 스트리밍 — yield 한 줄로 토큰이 즉시 UI로 흘러감
  • 취소 — abortSignal 하나로 generator/fetch/도구 실행이 함께 정리됨
  • 백프레셔 — 소비자가 못 따라오면 자연스럽게 모델 호출도 멈춤
  • 상태 머신 제로 코드 — turn 진행 상태를 변수로 따로 관리할 필요 없음

이걸 일반 async function + Promise + 상태 변수 + 콜백으로 짰다면 코드가 몇 배로 길어졌을 거고, 사이드 이펙트나 버그도 그만큼 많아졌을 거에요.

AI 에이전트의 본질은 비동기 시퀀스를 다루는 것이에요. 모델은 토큰을 시퀀스로 만들고, 도구 호출도 시퀀스, 사용자 입력도 시퀀스이기 땜누입니다. async generator는 이걸 표현하기 위해 ECMAScript에 추가된 문법이고, AI 에이전트의 본질을 구현하기에 적합해요.

5-C. 1편과의 연결되는 레이어 아키텍쳐

1편에서 본 React/Ink의 분리: reconciler ↔ renderer. 같은 React tree를 DOM에도, 네이티브 view에도, 터미널 character grid에도 그릴 수 있는 분리.

2편에서 본 queryLoop의 분리: producer (queryLoop) ↔ consumer (for await). 같은 generator를 어떤 소비자(UI 렌더, 로그 수집, 테스트 스크립트)가 받아도 동작하는 분리.

Claude Code는 분리 가능한 곳을 적절하게 분리해서 설계 했어요. UI도, 루프도, 도구도, 권한도, 같은 원칙을 layer마다 반복합니다. 51만 줄의 거대한 코드베이스를 그나마 읽을 수 있는 이유가 여기에 있어요.

5-D. 다음 시리즈 예고

이번 글은 queryLoop이라는 컨테이너까지였어요. 다음 회는 그 컨테이너 안에서 await되는 컨텐츠를 봅니다.

  • 컴팩션 5단계 — 매 turn 직전에 메시지 배열을 압축하는 미니 파이프라인
  • 도구 디스패처 (MH1) — 읽기/쓰기 도구를 어떻게 동시성 제어하는지
  • 훅 시스템 — PreToolUse / PostToolUse가 어떻게 사용자 코드를 mid-loop에 끼워넣는지

특히 컴팩션은 별도 API 호출까지 만드는 비대칭 구조라서, queryLoop 설계 및 구현의 진수를 보여주는 부분이에요. 다음 글에서 만나요.