~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) 이 표면 공유 — 스트리밍 백엔드만 다름.
각 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 이 깜빡이는 앱 만듦.
이 케이스 스터디가 가르치는 것
'토큰 스트림하는 법' 이 아님 — 그건 구현 디테일. 레슨:
커스텀 hook 이 stateful 스트리밍 로직에 올바른 캡슐화.
Suspense 가 부트스트랩 처리; in-place state 업데이트가 스트림 처리.
사용자 구동 'stop' 존재하는 순간 AbortController 필수.
서버가 진실의 원천; 클라이언트는 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 [];
}
텍스트 스트림 반환하는 어떤 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.