C.W.K.
Stream
Lesson 07 of 07 · published

Case Study — cwkPippa 의 스트리밍 채팅

~20 min · case-study, cwkpippa, streaming, sse, real-world

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
트랙의 모든 게 한꺼번에 land 하는 레슨. Async-suspense trio + 작은 커스텀 hook 이 진짜 프로덕션 데이터 처리 — 아빠가 스트리밍 HTTP 연결로 피파랑 대화, 메시지가 토큰 단위로 도착, edit 와 regeneration 이 발 아래의 conversation 트리 바꿈.

제품 표면

cwkPippa 는 채팅 앱. 타이핑, send, 어시스턴트가 응답 스트림 백. 응답 취소 가능. 과거 턴 편집 가능 (브랜치 생성). 어떤 턴이든 regenerate 가능. Sidebar 가 conversation 리스트; 메인 패널이 활성 거 보여줌. 네 brain 모두 (Claude, Codex, Gemini, Ollama) 이 표면 공유 — 스트리밍 백엔드만 다름.

현실 async 문제

'메시지' 가 단일 fetch 가 아님. 서버 전송 스트림 — 거기서:

  • 사용자 메시지가 서버 수락 전까지 로컬 전용 (optimistic append).
  • 어시스턴트 메시지가 chunk 로 도착 (텍스트 델타, tool_use 블록, thinking 블록).
  • 각 chunk 가 UI 보기 전에 JSONL 에 쓰여야 (내구성 invariant — `docs/PIPPA-ARCHITECTURE.md` 참조).
  • 사용자가 스트림 중간에 stop — in-flight 요청 abort 되지만 부분 컨텐츠 유지.
  • 재연결 시 conversation 이 SQLite (derived mirror) 에서 re-fetch + 불완전 turn 이 JSONL 에서 healing.

이 현실 표면에 async-suspense primitive 레이어링 — 이 케이스 스터디가 보여주는 것.

useChat 의 모양

커스텀 hook 하나가 conversation 계약 소유: messages, send, regenerate, edit, stop, isStreaming, error. 컴포넌트는 fetch URL, parent ID, JSONL 내구성, SSE 파싱 안 알아. useChat(conversationId) 호출하고 반환된 object 에서 렌더. Hook 이 앱 나머지와 아래의 스트리밍 현실 사이의 경계.

각 조각이 사는 자리

  • 초기 로드fetch(/api/conversations/:id), 타입 잡힌 Message[] 로 파싱, state 채움.
  • Send — 사용자 메시지 optimistic 로컬 append, 스트리밍 endpoint 에 POST, SSE 스트림 파싱, 어시스턴트 토큰을 state 의 draft 메시지에 append, `done` 이벤트에 finalize.
  • Stop — In-flight 스트림의 AbortController. 부분 메시지는 as-is 유지.
  • Error — Hook 의 error 필드로 surface, 페이지 root 의 error boundary 로도 (네트워크 에러).
  • Healing — 다음 GET 에 백엔드가 처리 (JSONL 델타에서 불완전 turn 재구축); 프런트엔드는 그냥 re-read.

이 케이스에 Suspense 가 어떻게 보이는지

초기 conversation 로드가 Suspense 사용 — 페이지 shell 즉시 렌더, 메시지 리스트 영역이 fetch resolve 까지 skeleton. 그 후 스트리밍은 in-place state 업데이트 — 추가 suspension 없음. 채팅이 매 send 마다 fallback 으로 깜빡 안 함; 그건 비참한 UX.

Suspense 는 경계용, 모든 state 변경용 아님. 네비게이션/초기-로드 경계에 사용. 진행 중 in-place 변경엔 평범한 state 업데이트 (또는 useTransition — Track 7) 사용. '모든 변경이 suspend' anti-pattern 이 깜빡이는 앱 만듦.

이 케이스 스터디가 가르치는 것

'토큰 스트림하는 법' 이 아님 — 그건 구현 디테일. 레슨:

  1. 커스텀 hook 이 stateful 스트리밍 로직에 올바른 캡슐화.
  2. Suspense 가 부트스트랩 처리; in-place state 업데이트가 스트림 처리.
  3. 사용자 구동 'stop' 존재하는 순간 AbortController 필수.
  4. 서버가 진실의 원천; 클라이언트는 derived 뷰 렌더. Healing 은 서버 사이드.

Code

useChat — 모양 (cwkPippa 실제 hook 단순화)·tsx
import { use, useState, useRef, useCallback } from "react";
import { fetchConversation } from "@/lib/api";
import type { Message } from "@/types";

// 모듈 레벨 캐시 — 같은 conversation id 가 render 간 같은 promise 반환
// (lesson 4 의 use() 기본 규칙).
const conversationCache = new Map<string, Promise<Message[]>>();
function getConversationPromise(id: string) {
  if (!conversationCache.has(id)) {
    conversationCache.set(id, fetchConversation(id));
  }
  return conversationCache.get(id)!;
}

export function useChat(conversationId: string) {
  // use() 통한 초기 로드 — 첫 번째 suspend, 반복엔 캐시.
  const initial = use(getConversationPromise(conversationId));

  const [messages, setMessages] = useState<Message[]>(initial);
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const ctrlRef = useRef<AbortController | null>(null);

  const send = useCallback(async (text: string) => {
    // In-flight 스트림 먼저 abort.
    ctrlRef.current?.abort();
    const ctrl = new AbortController();
    ctrlRef.current = ctrl;
    setError(null);
    setIsStreaming(true);

    // Optimistic append — 사용자 메시지 즉시 등장.
    const userMsg: Message = { id: `local-${Date.now()}`, role: "user", content: text };
    const draftAssistant: Message = { id: `draft-${Date.now()}`, role: "assistant", content: "" };
    setMessages((m) => [...m, userMsg, draftAssistant]);

    try {
      const response = await fetch(`/api/chat`, {
        method: "POST",
        body: JSON.stringify({ conversationId, text }),
        signal: ctrl.signal,
      });
      if (!response.body) throw new Error("no stream body");

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true });
        // SSE chunk 파싱, draft 어시스턴트 메시지에 누적.
        for (const event of parseSSE(chunk)) {
          if (event.type === "delta") {
            setMessages((m) =>
              m.map((msg) =>
                msg.id === draftAssistant.id
                  ? { ...msg, content: msg.content + event.text }
                  : msg
              )
            );
          } else if (event.type === "done") {
            // Draft id 를 서버 발급 거로 교체.
            setMessages((m) =>
              m.map((msg) => (msg.id === draftAssistant.id ? { ...msg, id: event.messageId } : msg))
            );
            // 캐시 무효화 — 다음 마운트가 권위 있는 state re-fetch.
            conversationCache.delete(conversationId);
          }
        }
      }
    } catch (e) {
      if ((e as Error).name !== "AbortError") setError(e as Error);
      // Partial draft 유지 — 서버 JSONL 가 같은 partial 캡처.
    } finally {
      setIsStreaming(false);
    }
  }, [conversationId]);

  const stop = useCallback(() => ctrlRef.current?.abort(), []);

  return { messages, send, stop, isStreaming, error };
}

function parseSSE(_chunk: string): Array<{ type: "delta"; text: string } | { type: "done"; messageId: string }> {
  // 진짜 파서는 partial line, chunk 당 여러 이벤트, JSON payload 처리.
  // 케이스 스터디 모양엔 여기서 단순화.
  return [];
}
소비하는 컴포넌트 — 깨끗·tsx
import { Suspense, useState } from "react";
import { useChat } from "@/hooks/useChat";

function ChatPanel({ conversationId }: { conversationId: string }) {
  const { messages, send, stop, isStreaming, error } = useChat(conversationId);
  const [draft, setDraft] = useState("");

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {messages.map((m) => (
          <div key={m.id} className={m.role === "user" ? "text-fg" : "text-brand"}>
            {m.content}
          </div>
        ))}
      </div>
      {error && <p className="text-danger text-sm px-4">{error.message}</p>}
      <form
        className="flex p-4 gap-2 border-t"
        onSubmit={(e) => {
          e.preventDefault();
          if (draft.trim()) { send(draft); setDraft(""); }
        }}
      >
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          className="flex-1 px-3 py-2 rounded border bg-bg text-fg"
          placeholder="Message Pippa…"
        />
        {isStreaming ? (
          <button type="button" onClick={stop} className="px-4 py-2 rounded bg-danger text-bg">
            Stop
          </button>
        ) : (
          <button type="submit" className="px-4 py-2 rounded bg-brand text-bg">
            Send
          </button>
        )}
      </form>
    </div>
  );
}

export function ChatPage({ conversationId }: { conversationId: string }) {
  return (
    <Suspense fallback={<div className="p-4 text-muted">Loading conversation…</div>}>
      <ChatPanel conversationId={conversationId} />
    </Suspense>
  );
}

External links

Exercise

텍스트 스트림 반환하는 어떤 URL 에서든 스트림 받는 useStream hook 빌드 (어떤 echo-stream 서비스 사용 또는 dev server 의 setTimeout 으로 fake 만들어). 패턴: 초기 state 엔 use(), 누적 스트림엔 useState, stop 엔 AbortController, 사용자 메시지엔 optimistic append. 단순 채팅-모양 UI 에 연결. 요점은 cwkPippa 의 정확한 API 매치가 아냐 — 관심사 일곱 (suspense, use, state, ref, abort, optimistic, error) 이 맞물리는 거 느끼는 것.
Hint
AbortController 스킵하면 두 스트림 사이 전환이 첫 번째 거가 본인 state 에 쓰는 동안 두 번째가 돔. Race 가 interleaved 출력 생성. AbortController 가 깔끔히 고침.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.