B
Boaz2026. 5. 2.

Claude Code 까보니 React 앱이었습니다

2026년 3월, Anthropic이 npm 패키지에 실수로 Claude code 의 source map 파일을 포함해서 배포했습니다. Claude Code의 TypeScript 원본 소스 약 51만 줄이 며칠간 노출됐어요. 여러 개발자들이 소스 코드를 분석했는데요.

Claude code 가 어떻게 동작하는지 궁금했던 저는, 직접 자료들을 읽고 분석하면서 재밌는 사실을 발견했어요. Claude Code 소스에 .tsx 파일이 꽤 많이 있단 거에요. 단순히 "React를 쓴다" 수준을 넘어서 Claude Code는 사실상 React 앱이었습니다. 정확히 어떤 부분이 React 인지, 왜 React를 선택했는지, 사용자가 프롬프트를 입력하고 Enter를 누른 순간 React 안에서 무슨 일이 일어나는지, 공부한 내용을 정리해 봤어요.

(이 글은 5개 팀이 각자 정리한 자료를 바탕으로 합니다. shareAI-lab의 난독화 해제 분석, Yuyz0112의 mitmproxy 캡처, Alex Kim의 source leak 분석, Karan Prasad의 51만 줄 리버스 엔지니어링, Kir Shatrov의 internals 글 등을 교차 검증했습니다.)


1. Claude Code 아키텍처 큰 그림부터

먼저 전체 그림부터 살펴볼게요. Claude Code는 4개 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/>queryLoop()<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:#e0e7ff,stroke:#6366f1,color:#1f2937
    style TOOLS fill:#dcfce7,stroke:#22c55e,color:#1f2937
    style PERM fill:#fef3c7,stroke:#f59e0b,color:#1f2937
  • UI layer — 사용자가 보고 입력하는 부분. 터미널 화면, 입력창, spinner, 토큰 카운터.
  • Agent loop layer — 모델 호출과 도구 실행을 무한 반복하는 핵심 엔진. query.tsqueryLoop()이 여기 해당해요.
  • Tools layer — Bash, Read, Edit, Write, Glob, Grep, Task 등 15개 built-in 도구와 MCP 서버 연결.
  • Permission layerdefault / acceptEdits / plan / bypassPermissions 등 6가지 모드로 도구 실행을 통제하는 시스템.

이 4개 layer 중에 React가 맡는 건 UI layer입니다. 나머지 3개는 일반 TypeScript로 작성돼 있어요. UI 와 나머지가 깔끔하게 레이어로 분리되어 있다는 게 Claude Code 설계의 핵심입니다.


2. 터미널 앱인데 React를 썼다고? 어떻게?

먼저 React에 대해 한가지 짚고 넘어가야 합니다. React는 DOM 라이브러리가 아니에요.

React 자체는 "state가 바뀌면 어떤 tree를 그려라" 라는 추상화만 제공합니다. 실제로 어디에 그릴지는 별도의 renderer가 결정해요. 이 분리가 React 설계의 핵심입니다.

이게 이름이 헷갈리는 이유예요. 우리가 보통 "React"라고 부르는 건 사실 두 부분의 합입니다.

  • React (reconciler) — JSX를 받아서 tree diff를 계산하는 엔진
  • renderer — 그 diff 결과를 실제 출력 매체로 옮기는 어댑터

renderer만 바꾸면, 같은 React tree를 전혀 다른 곳에 그릴 수 있습니다.

%%{init: {"flowchart": {"subGraphTitleMargin": {"top": 12, "bottom": 12}}}}%%
flowchart LR
    REACT["React (reconciler)<br/>JSX + tree diff"]

    subgraph DOM["react-dom"]
        D1["브라우저 DOM"]
        D2["웹 페이지"]
    end
    subgraph NATIVE["react-native"]
        N1["네이티브 view<br/>UIView, ViewGroup"]
        N2["iOS / Android 앱"]
    end
    subgraph INK["ink ★"]
        I1["character grid<br/>+ ANSI escape"]
        I2["CLI 도구<br/>(Claude Code)"]
    end

    REACT --> DOM
    REACT --> NATIVE
    REACT --> INK

    style REACT fill:#e0e7ff,stroke:#6366f1,color:#1f2937
    style DOM fill:#f3f4f6,stroke:#9ca3af,color:#1f2937
    style NATIVE fill:#f3f4f6,stroke:#9ca3af,color:#1f2937
    style INK fill:#dbeafe,stroke:#3b82f6,color:#1f2937
Renderer 출력 매체 사용처
react-dom 브라우저 DOM 웹 페이지
react-native 네이티브 view (UIView, ViewGroup) iOS, Android 앱
ink 터미널 character + ANSI escape CLI 도구 ★

같은 <Box flex={1}>를 써도 react-dom에선 <div style="flex:1">이 되고, react-native에선 iOS의 UIView로 매핑되고, Ink에선 터미널 character grid의 한 영역으로 계산됩니다. JSX, hooks, component composition은 모두 동일하게 작동해요. 렌더링 형태만 다르게 될 뿐입니다.

Claude Code가 쓰는 건 vadimdemedes/ink라는 라이브러리입니다. 이게 하는 일은 아래와 같아요.

<Box flexDirection="column">
  <Text color="green">●</Text>
  <Spinner />
</Box>
flowchart LR
    A["JSX 트리<br/>Box, Text,<br/>Spinner ..."] -->|"입력"| B["Yoga 엔진<br/>Flexbox 레이아웃<br/>character grid 계산"]
    B -->|"중간 처리"| C["ANSI escape<br/>sequence<br/>stdout 출력"]
    C -->|"출력"| D["터미널 화면"]

    style A fill:#dbeafe,stroke:#3b82f6,color:#1f2937
    style B fill:#e0e7ff,stroke:#6366f1,color:#1f2937
    style C fill:#fef3c7,stroke:#f59e0b,color:#1f2937
    style D fill:#dcfce7,stroke:#22c55e,color:#1f2937

이 JSX를 받아서 → Yoga(Facebook의 Flexbox C++ 엔진)로 터미널 character grid 위에 Flexbox 레이아웃을 계산 → ANSI escape sequence로 stdout에 출력 까지를 담당해요.

<Box flex={1}>이라고 쓰면 진짜로 가용 너비만큼 늘어나요. 마치 웹 flexbox처럼요. 다만 출력은 픽셀이 아니라 character입니다.

핵심 정리: React는 트리 추상화 엔진이고, renderer가 어디에 그릴지를 결정합니다. Claude Code는 react-dom 대신 Ink를 renderer로 쓴 것뿐이에요.


3. 왜 React를 썼는가 — declarative UI가 streaming에 유리한 지점

이제 터미널에서도 "React를 쓸 수 있다"는 건 알았습니다. 그런데 왜 굳이 React인가요? console.log로도 터미널에 글자는 찍히는데요.

그 이유는 streaming 과 연관이 있어요.

LLM 응답은 한 번에 오지 않아요. Server-Sent Events (SSE) chunk로 100ms 마다 나눠서 옵니다.

data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":" world"}}
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"!"}}

이 chunk가 도착할 때마다 화면을 리렌더링 합니다. 그런데 단순히 추가만 하면 되는 게 아니에요. 같은 줄을 반복해서 다시 그려야 하는 상황이 생깁니다.

● Tool: Bash                ← 도구 실행 중 표시
  ⎿ ls -la
⠋ Thinking...               ← spinner (애니메이션 매 프레임 갱신)
[2,679 / 200,000 tokens]    ← 토큰 카운터 (매 chunk마다 누적)
> 사용자 입력▮              ← 입력창

화면 안에 여러 UI 요소가 동시에 독립적으로 업데이트되고 있어요. spinner는 100ms마다 회전하고, 토큰 카운터는 chunk 도착할 때마다 누적되고, assistant 응답은 매 chunk마다 같은 위치에 추가됩니다.

이걸 명령형으로 짜는 걸 상상해 볼게요.

// imperative 방식 — chunk 도착 콜백
function onChunk(delta) {
  process.stdout.write("\x1b[1A"); // 커서 한 줄 위로
  process.stdout.write("\x1b[2K"); // 줄 지우기
  accumulated += delta;
  process.stdout.write(accumulated); // 다시 그리기
  // 근데 그 사이에 spinner가 같은 줄을 다시 그렸다면?
  // 토큰 카운터는 어디 줄에 있지?
  // race condition 지옥...
}

선언형으로 짜면 한 줄입니다.

// declarative 방식
const [text, setText] = useState("");
function onChunk(delta) {
  setText((prev) => prev + delta);
}
// 끝. Ink가 알아서 변경된 줄만 ANSI escape으로 다시 그림.
명령형 (imperative) 선언형 (declarative)
코드 줄 수 5+ 줄 (커서 이동 / 줄 지움 / 덮어쓰기) 1 줄 (setText)
race 관리 직접 (spinner·토큰 카운터와 충돌 위험) reconciler 가 자동 diff
ANSI escape 직접 작성 Ink 가 자동 처리
다중 UI 요소 각자 위치 추적 필요 각 컴포넌트 state 만 갱신

state를 한 줄 바꾸면, React reconciler가 이전 tree와 새 tree를 diff하고, 변경된 노드만 Ink에게 "이 줄 다시 그려"라고 명령합니다. spinner는 spinner대로, 토큰 카운터는 토큰 카운터대로, 각자 자기 state만 갱신하면 충돌 없이 잘 돌아가요.

batched re-render도 중요한 이유입니다. SSE chunk가 너무 빨리 오면(예: 16ms마다 한 번씩) 매번 re-render할 때 60fps를 못 맞춰서 터미널이 깜빡일 수 있어요. React 18부터는 자동 batching이 들어가서, 짧은 시간 내에 여러 setState가 일어나면 한 번에 묶어서 re-render합니다. 명령형으로 직접 throttle을 짜야 했을 일을 React가 알아서 처리하는 거예요.

결국은 왜 React 인가? declarative 이기 때문이에요. streaming UI는 declarative로 표현하면 훨씬 수월합니다. "지금 이 순간 화면이 어떤 모습이어야 하는가"를 state로 관리하고, "어떻게 그 모습으로 만들 것인가"는 reconciler가 맡는 구조. 이게 100ms 단위 chunk가 계속 오는 상황에서, 명령형보다 더 적합합니다.


4. 다른 선택지보다 React가 적합한 이유

물론 React+Ink만이 답은 아닙니다. CLI를 만드는 다른 선언적인 방법도 있었어요. Claude Code 팀이 왜 굳이 이 조합을 골랐는지, 다른 선택지와 비교해보면 명확해집니다.

선택지 장점 한계
console.log + ANSI escape 직접 의존성 zero, 가볍다 streaming race condition 직접 다뤄야 함. 유지보수 어려움
blessed (Node TUI) 풍부한 위젯, 성숙한 라이브러리 명령형 API. 상태 관리 직접. hook 생태계 없음
Go + bubbletea Elm-style declarative, 빠름 TypeScript 생태계 단절. MCP·SDK 코드 공유 불가
React + Ink declarative, hook 모듈화, 웹 코드 재사용 의존성 무거움 (React 전체 끌고 옴)

Anthropic이 React + Ink를 고른 데는 조직적 이유도 있어요. claude.ai 웹앱이 Next.js (즉 React)이고, TypeScript SDK가 first-class입니다. CLI를 Go나 Rust로 짰다면 인력과 도구체인이 분리됐을 거예요. JS로 통일하면 useInputBuffer 같은 hook을 웹 버전과 거의 그대로 공유할 수 있습니다.

기술적 적합성과 조직적 일관성이 맞물린 결정이라고 볼 수 있어요. blessed도 충분히 쓸 수 있겠지만, 명령형 API로 streaming UI의 race condition을 처음부터 다시 푸는 건 비효율이었을 거예요. bubbletea는 declarative지만 언어 스택이 다르구요.


5. Claude Code 안에서 React가 맡은 영역

이제 본격적으로 소스 코드를 까볼게요. 주요 .tsx 파일들이에요.

src/
├── replLauncher.tsx           ← Ink render() 진입점
├── screens/
│   └── REPL.tsx               ← 메인 화면 (3000줄+)
├── components/
│   ├── App.tsx                ← 루트 컴포넌트
│   ├── PromptInput/
│   │   └── PromptInput.tsx    ← 입력창 (1000줄+)
│   ├── Messages.tsx           ← 메시지 목록
│   ├── AssistantMessage.tsx   ← AI 응답 렌더링
│   ├── ToolMessage.tsx        ← 도구 호출 표시
│   ├── Spinner.tsx            ← 로딩 표시
│   └── StatusBar.tsx          ← 토큰 카운터 등
└── hooks/
    ├── useInputBuffer.ts
    ├── useArrowKeyHistory.ts
    ├── useTypeahead.ts
    └── useKeybinding.ts

전부 우리가 웹에서 보던 패턴 그대로입니다. 컴포넌트, hook, props, state. 차이는 출력이 DOM이 아니라 터미널이라는 것뿐.

실제 컴포넌트 트리는 이렇게 생겼어요.

%%{init: {"flowchart": {"subGraphTitleMargin": {"top": 12, "bottom": 12}}}}%%
flowchart TB
    APP["App"] --> REPL["REPL"]
    REPL --> STATIC
    REPL --> BOX

    subgraph STATIC["Static — immutable 영역"]
        S1["Message"]
        S2["Message"]
        S3["한 번 그리고<br/>다시 안 그림"]
    end

    subgraph BOX["Box — dynamic 영역"]
        B1["StreamingText"]
        B2["Spinner"]
        B3["StatusBar"]
        B4["PromptInput<br/>TextInput<br/>SuggestionsDropdown"]
        B5["매 frame 갱신"]
    end

    style APP fill:#e0e7ff,stroke:#6366f1,color:#1f2937
    style REPL fill:#e0e7ff,stroke:#6366f1,color:#1f2937
    style STATIC fill:#dcfce7,stroke:#22c55e,color:#1f2937
    style BOX fill:#dbeafe,stroke:#3b82f6,color:#1f2937
<App>
  // 세션 전역 state, error boundary
  <REPL>
    // messages 배열, 모드 전환
    <Static items={completedMessages}>
      {(msg) => <Message data={msg} />} // 완료된 turn (한 번 그리고 고정)
    </Static>
    <Box flexDirection="column">
      // 동적 영역 (매 frame 다시 그림)
      <StreamingText /> // 현재 진행 중인 응답
      <Spinner /> // API 대기 중 표시
      <StatusBar /> // [2,679 / 200,000]
      <PromptInput>
        // 입력창
        <TextInput /> // ink-text-input
        <SuggestionsDropdown /> // 자동완성
      </PromptInput>
    </Box>
  </REPL>
</App>

여기서 **<Static>**이 중요합니다. Ink 고유의 컴포넌트인데, 한 번 렌더된 항목은 다시 그리지 않아요. stdout에 한 번만 쓰고 끝. 그래서 마우스로 위로 스크롤하면 옛날 메시지들이 그대로 살아 있습니다. 일반 react-dom에서는 매 frame마다 모든 노드를 diff하지만, Ink의 <Static>은 "이 부분은 immutable history다"라고 reconciler에게 명시적으로 알려주는 장치예요. 터미널 스크롤백을 보존하는 핵심 트릭입니다.

hook도 아래와 같은 역할들을 담당합니다. CLI 입력기에 들어가야 하는 기능들이 각각 독립된 hook으로 분리돼 있습니다.

Hook 역할
useInputBuffer 텍스트 버퍼와 커서 위치 관리
useArrowKeyHistory 위/아래 키로 history 탐색
useHistorySearch Ctrl+R 검색
useTypeahead 슬래시 커맨드 / 디렉토리 자동완성
usePromptSuggestion AI 제안 표시
useIdeAtMentioned IDE @ 멘션 처리
useCommandQueue 큐에 적재된 커맨드 관리
useKeybinding 키바인딩 등록

PromptInput 컴포넌트는 이 hook들을 가져다 조합하기만 합니다. 명령형으로 짰다면 코드 줄수가 더 길어지고 추상화도 비교적 어려웠을 거에요.

여기까지가 React 가 맡은 영역입니다. 바로 이어서 이 컴포넌트들이 실제로 어떻게 협력하는지, Enter 한 번 누르면 무슨 일이 일어나는지 코드 단위로 좀 더 자세히 살펴볼게요.


6. 프롬프트 입력 후 Enter를 누르면 일어나는 일

이제, API 호출 직전까지 React 가 하는 일을 순서대로 따라가 봅시다. SSE 파싱이나 도구 실행은 다른 layer 일이니 제외하고요.

sequenceDiagram
    participant U as User
    participant P as PromptInput
    participant R as REPL
    participant I as Ink/Renderer
    participant A as Anthropic API

    U->>P: Enter 키
    P->>P: 9가지 UX 가드
    P->>R: onSubmitProp(input)
    R->>I: setMessages([...prev, userMsg])
    I-->>U: user 메시지 화면 출력

    R->>I: setIsLoading(true)
    I-->>U: spinner 표시

    R->>A: POST /v1/messages?beta=true (stream:true)

    loop 매 SSE chunk
        A-->>R: content_block_delta
        R->>I: setStreamingText(prev + delta)
        I-->>U: in-place 덮어쓰기 (타이핑 효과)
    end

    A-->>R: message_stop
    R->>I: setMessages(완료) + setIsLoading(false)
    I-->>U: Static 으로 이동, idle 복귀

Step 1: stdin → Ink useInput → keypress 이벤트

사용자가 Enter를 누르면 Node.js의 process.stdin이 raw mode로 키 이벤트를 받습니다. Ink는 이걸 구독해서 keypress 객체로 정규화한 뒤 useInput hook의 콜백에 전달해요.

// PromptInput 내부 어딘가
useInput((input, key) => {
  if (key.return) {
    // Enter 키!
    onSubmit(currentBuffer);
  }
});

여기까지가 "React 이벤트 발생" 시점입니다. React app 입장에서 작업을 시작하는 순간이에요.

Step 2: onSubmit prop chain

이제 콜백이 컴포넌트 트리를 타고 위로 올라갑니다. 부모 → 자식으로 props가 내려가고, 이벤트는 콜백으로 위로 전파되는 React의 표준 패턴이에요.

PromptInput.tsx L984에 정의된 onSubmit은 7가지 UX 가드를 통과시킵니다.

const onSubmit = useCallback(async (inputParam, isSubmittingSlashCommand = false) => {
  inputParam = inputParam.trimEnd();

  // 1. footerSelection 활성 → return
  const state = store.getState();
  if (state.footerSelection && footerItems.includes(state.footerSelection)) return;

  // 2. agent 선택 모드 → return
  if (state.viewSelectionMode === 'selecting-agent') return;

  // * 이미지 첨부 확인
  const hasImages = Object.values(pastedContents).some(c => c.type === 'image');

  // 3. AI suggestion 자동 수락 (speculation)
  if (inputMatchesSuggestion && suggestionText && !hasImages) {
    if (speculation.status === 'active') { markAccepted(); return; }
  }

  // 4. @멤버 다이렉트 메시지 (Agent Swarms)
  if (isAgentSwarmsEnabled()) {
    const directMessage = parseDirectMemberMessage(inputParam);
    if (directMessage) { await sendDirectMemberMessage(...); return; }
  }

  // 5. 빈 입력 + 이미지 없음 → return
  if (inputParam.trim() === '' && !hasImages) return;

  // 6. 슬래시 커맨드 dropdown 떠있음 → return
  if (suggestionsState.suggestions.length > 0 && !isSubmittingSlashCommand) return;

  // 7. 활성 에이전트가 leader 아니면 위임
  const activeAgent = getActiveAgentForInput(store.getState());
  if (activeAgent.type !== 'leader' && onAgentSubmit) {
    await onAgentSubmit(inputParam, activeAgent.task, helpers);
    return;
  }

  // 본 세션이면 onSubmitProp 호출 → REPL.onSubmit
  await onSubmitProp(inputParam, { setCursorOffset, clearBuffer, resetHistory });
}, [...deps]);

이걸 모두 통과하면 onSubmitProp이 호출되고, 이게 REPL.tsx L3142의 onSubmit으로 이어집니다.

REPL.onSubmit(input, helpers)
  → repinScroll()
  → executeImmediateCommand()       // immediate 슬래시 커맨드 즉시 실행
  → idle-return 체크
  → addToHistory()                  // history에 push
  → isSlashCommand = input.startsWith('/')
  → 입력 클리어, setInputMode('prompt')
  → handleSpeculationAccept()
  → 로컬 모드: handlePromptSubmit() → processUserInput() → onQuery()

책임이 3계층으로 나눠져 있어요. TextInput은 키 이벤트만, PromptInput은 UX 가드만, REPL은 세션 로직만, 책임을 분리한게 핵심입니다.

Step 3: setMessages → React reconciler → Ink ANSI 출력

REPL.onSubmit 안에서 결정적인 한 줄을 실행합니다.

setMessages((prev) => [...prev, newUserMessage]);

이걸 호출하면 아래 6가지 과정을 시작합니다.

  1. React가 messages state 변경을 감지
  2. REPL 컴포넌트 re-render 스케줄링
  3. JSX tree 새로 생성 — <Static items={messages}>의 items가 한 개 늘어남
  4. React reconciler가 이전 tree와 diff (Virtual DOM)
  5. Ink renderer에게 "이 노드 추가해" 명령 전달
  6. Ink가 stdout에 ANSI escape으로 새 메시지 출력

사용자 화면에 자기가 입력한 메시지가 즉시 보입니다. API 호출은 아직 시작도 안 했어요. UI 리렌더링과 비즈니스 로직이 잘 분리돼 있단걸 알 수 있습니다.

Step 4: state가 두 갈래로 분리

onQuery()가 호출되면 두 가지 일이 동시에 시작돼요.

갈래 A — React 쪽 (UI 상태)

setIsLoading(true); // spinner 표시
setStreamingText(""); // 빈 응답으로 시작
setMode("streaming"); // 입력 포트를 비활성화

이 setState 3개로 Ink가 즉시 spinner를 그리고, 입력 포트를 disabled로 만듭니다.

갈래 B — Non-React 쪽 (API 호출)

const stream = client.beta.messages.create({
  model: "claude-sonnet-4-20250514",
  stream: true,
  // ...
});
for await (const event of stream) {
  // 이 부분은 React가 아님 — 그냥 async iterator
}

순수 TypeScript async generator입니다. React는 여기 관여 안 해요. 그냥 SDK가 SSE 스트림을 받아서 yield할 뿐입니다.

Step 5: SSE chunk → setStreamingText → throttled re-render

API 응답이 오기 시작하면, async generator에서 React state로 데이터를 다시 주입합니다.

for await (const event of stream) {
  if (
    event.type === "content_block_delta" &&
    event.delta.type === "text_delta"
  ) {
    // 여기서 React로 데이터 주입
    setStreamingText((prev) => prev + event.delta.text);
  }
}

setStreamingText 한 줄이 매 chunk마다 호출돼요. React 는 아래 과정을 이어서 수행합니다.

  1. state 변경 감지
  2. <StreamingText> 컴포넌트 re-render
  3. Ink가 "이 텍스트 노드 변경됨" 인식
  4. 터미널 커서를 이전 위치로 올리고 새 텍스트로 덮어쓰기 (ANSI escape)
  5. 사용자 화면에서 "타이핑" 효과

위에 3번에서 살펴본 declarative streaming 구현이 동작하는 과정입니다.

Step 6: 트릭으로 완료 메시지 영구 보존

스트리밍이 끝나면 (message_stop 이벤트 도착) 마지막 정리가 일어나요.

setMessages((prev) => [...prev, completedAssistantMessage]);
setStreamingText(""); // 동적 영역 비움
setIsLoading(false); // spinner 숨김

messages 배열에 한 개 추가됩니다. 이 배열은 <Static>의 items로 들어가요.

<Static items={messages}>{(msg) => <Message data={msg} />}</Static>

위에서 언급했던 <Static>의 장점을 여기서 확인할 수 있습니다. 한 번 렌더된 메시지는 다시 그리지 않아요. stdout에 한 번 쓰고 끝입니다. 그 결과, 사용자가 마우스로 위로 스크롤하면 이전 메시지를 그대로 볼 수 있어요.

일반 react-dom이었다면 매 re-render마다 모든 노드를 diff하면서 터미널 스크롤백이 불가능 했을거에요. Ink가 CLI 환경 특성에 맞춰 추가한 컴포넌트가 <Static>이고, Claude Code는 이걸 정확히 필요한 곳에 제대로 활용하고 있어요.


7. 다시 전체 아키텍처에서 React 위치 재조명

앞에서 설명한 코드는 처음에 살펴본 4개의 layer 중 어느 부분이었을까요? 답은 UI 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/>queryLoop()<br/>컴팩션"]
    TOOLS["<b>Tools</b><br/><br/>Bash, Read,<br/>Edit, Write,<br/>Task, MCP"]
    PERM["<b>Permission</b><br/><br/>6 모드<br/>+ Hooks"]

    UI -->|"onQuery() 경계선"| AGENT
    AGENT --> TOOLS
    TOOLS --> PERM

    style UI fill:#3b82f6,stroke:#1e40af,color:#ffffff,stroke-width:3px
    style AGENT fill:#f3f4f6,stroke:#9ca3af,color:#6b7280
    style TOOLS fill:#f3f4f6,stroke:#9ca3af,color:#6b7280
    style PERM fill:#f3f4f6,stroke:#9ca3af,color:#6b7280

onQuery() 콜백이 정확한 경계선이에요. 그 위로는 React, 그 아래로는 TypeScript입니다.

[UI layer — React 영역]
  TextInput → PromptInput → REPL → setMessages, setStreamingText
       │
       │  ── onQuery() 경계선 ──
       ↓
[Agent loop layer — TS]
  query.ts queryLoop() → 5단계 컴팩션 → SDK 호출
       ↓
[Tools layer — TS]
  Bash, Read, Edit 핸들러
       ↓
[Permission layer — TS]
  bypassPermissions, hooks 검증

각 layer 의 책임을 깔끔하게 분리하면 세 가지 장점이 있습니다.

첫째, 테스트 용이성. UI 없이 agent loop만 단독으로 테스트할 수 있습니다. SDK 모드와 headless 모드가 가능해져요. React layer를 빼고도 동일한 비즈니스 로직이 돌아갑니다.

둘째, layer별 독립 진화. Ink를 다른 terminal renderer로 교체해도 (예를 들어 Bun이 자체 TUI 엔진을 만들어서 더 빨라지면) agent loop는 한 줄도 안 바꿔도 돼요. 반대로 모델 호출 전략을 바꿔도 (예: 컴팩션 알고리즘 교체) UI 코드는 영향이 없습니다.

셋째, 재사용성. claude.ai 웹앱과 hook을 공유할 수 있어요. useInputBufferuseTypeahead는 renderer가 무엇이든 똑같이 작동합니다. Anthropic 입장에서 코드 자산을 두 배로 활용할 수 있는 구조예요.

Claude Code = React 앱이라는 말은 정확히는 **"UI layer는 React, 나머지는 plain TS, 둘은 onQuery 콜백으로만 연결됨"**을 의미합니다.


8. 여기까지, Product Engineer는 무엇을 배울수 있을까

만약 Anthropic 팀이 React를 "웹 UI 라이브러리" 로만 알고 있었다면, Ink라는 발상은 안 나왔을 거예요. 터미널 앱은 당연히 blessed나 readline으로 짜는 거라고 생각했을 테니까요.

React의 본질이 tree diff 알고리즘이라는 걸 이해했기 때문에, 같은 추상화를 터미널이라는 전혀 다른 출력 매체에 적용할 수 있었습니다. react-native가 모바일에 적용했고, react-three-fiber가 WebGL에 적용했고, Ink가 터미널에 적용한 것이 모두 같은 원리입니다.

저도 직접 Claude Code 소스를 따라가면서 다시 한번 깨달았습니다. 표면(API 사용법)이 아니라 본질(설계 철학)을 이해해야 응용할 수 있다는 것을요. "React는 useState 쓰는 라이브러리"라는 수준에서 멈추면 응용하기가 어렵습니다. "React는 reconciler + renderer로 분리된 추상화 엔진"이라는 수준에서 보면, 다음에 어떤 새로운 환경이 나와도 응용할 수 있어요.

비단 React만 그런 게 아닙니다. 어떤 도구든 표면적으로 활용만 하는 사람과 본질을 이해하는 사람은 다른 결과를 만들어 냅니다. SQL을 "데이터 조회 언어"로만 알면 SELECT-FROM-WHERE에서 멈추지만, "관계대수의 표현 형식"으로 이해하면 dataflow 언어로도 응용할 수 있어요. Git을 "버전 관리 도구"로만 알면 commit-push-pull에서 멈추지만, "DAG 기반 콘텐츠 주소 저장소"로 이해하면 Dolt 같은 데이터베이스를 떠올릴 수 있는 것처럼요.

Claude Code = React 앱이라는 발견 자체가, 깊이 있는 이해가 어떻게 새로운 응용을 만드는지 보여주는 사례입니다. 터미널과 React라는 멀어 보이는 두 세계를 reconciler 추상화로 연결하는 시도, 이게 Product Engineer가 갖춰야 할 시선이라고 생각해요.

직접 깊이 파고들어 본 도구는 다른 영역에서도 응용 가능해집니다. 그게 이 까보기 시리즈를 계속 하는 이유이기도 해요. 다음 글에서는 Claude Code의 SSE 파서가 어떻게 fine-grained-tool-streaming beta와 결합해서 도구 실행 latency를 줄이는지 — agent loop 본체를 더 깊게 들어가 보려 합니다.

이런 깊이 있는 분석을 함께 나누고 싶다면, Product Engineer Community에 합류해 주세요. 직접 써보고 발견한 것들을 이곳에서 나누고 있습니다.