본문 바로가기
C.W.K.
Stream
Lesson 07 of 07 · published

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
이제 앞에서 배운 조각을 cwkPippa의 스트리밍 채팅에 합쳐 보자. 초기 로딩, 실시간 토큰, 취소, 오류, 메시지 상태를 한 흐름으로 연결해.

하나의 화면을 여러 brain이 공유해

cwkPippa에서 사용자는 메시지를 보내고 들어오는 응답을 실시간으로 읽어. 진행 중인 응답을 멈출 수 있고, 이전 턴을 편집해 새 분기를 만들거나 다시 생성할 수도 있어. Sidebar는 대화 목록을, 본문은 선택한 대화를 보여 줘. Claude, Codex, Gemini, Ollama가 같은 UI를 사용하고 백엔드의 스트리밍 연결만 달라.

메시지 하나도 여러 단계로 완성돼

사용자 메시지는 서버가 확인하기 전에 화면에 먼저 나타날 수 있어. 어시스턴트 응답은 텍스트와 tool-use, thinking 블록이 여러 chunk로 도착해. 각 chunk는 화면에 반영되기 전에 영속 기록에 남아야 하고, 사용자가 중간에 멈추면 지금까지 받은 내용은 보존한 채 요청만 취소해야 해. 다시 연결하면 서버가 기록을 바탕으로 불완전한 턴을 복구하고 클라이언트는 최신 대화를 다시 읽어.

useChat이 경계를 맡아

커스텀 훅은 messages, send, regenerate, edit, stop, isStreaming, error를 제공해. 컴포넌트는 fetch URL과 parent ID, SSE 파싱, 영속 기록 방식까지 알 필요 없이 이 계약으로 화면을 그려.

각 책임을 제자리에 둬

  • 처음 대화를 열 때는 서버에서 메시지를 읽고 타입을 확인해 상태에 넣어.
  • 전송할 때는 사용자 메시지를 먼저 추가한 뒤 스트림을 열고, 도착한 토큰을 임시 어시스턴트 메시지에 이어 붙여.
  • Stop은 현재 스트림의 AbortController를 호출하고 부분 응답은 그대로 남겨.
  • 네트워크 오류는 훅의 error로 보여 주고 복구할 수 없는 렌더링 오류는 오류 경계가 맡아.
  • 불완전한 기록의 복구는 서버가 처리하고 프런트엔드는 다시 읽은 결과를 렌더링해.

Suspense는 첫 로드 경계에만 써

페이지 shell은 바로 보여 주고 메시지 목록은 초기 요청이 끝날 때까지 skeleton을 표시할 수 있어. 스트리밍이 시작된 뒤에는 같은 화면의 상태를 조금씩 갱신해. 토큰이 올 때마다 Suspense 대체 UI로 바뀌면 채팅 화면이 계속 깜빡이게 돼.

Suspense는 화면 경계를, 일반 상태는 진행 중 변화를 맡아. 사용자가 멈출 수 있는 스트림에는 AbortController가 필요하고, 영속 기록의 최종 진실은 서버가 소유해.

영속 기록은 화면보다 먼저 확정돼

cwkPippa의 각 chunk는 UI에 보이기 전에 durable JSONL 기록에 추가돼. SQLite는 빠르게 목록과 상태를 조회하기 위한 파생 저장소이고, 불완전한 턴을 복구할 때는 원본 이벤트 기록을 기준으로 다시 맞춰. 이 내구성 규칙 덕분에 앱이나 brain 연결이 중간에 끊겨도 받은 토큰까지 잃지 않아.

Chunk에는 텍스트만 오지 않아

스트림은 text delta 외에도 tool-use와 thinking 블록, 완료와 오류 이벤트를 전달할 수 있어. useChat은 이벤트 종류에 따라 현재 assistant message의 알맞은 part를 갱신하고, 완료 이벤트가 오면 임시 상태를 확정해.

편집과 재생성은 parent 관계를 바꿔

이전 턴을 편집하면 그 지점에서 새 branch가 생기고, regenerate는 같은 parent에서 새 assistant 응답을 시작해. 컴포넌트가 이 ID 계약을 직접 조립하지 않도록 훅과 백엔드 경계가 책임져.

Code

cwkPippa useChat 계약을 단순화한 예제·tsx
import { use, useState, useRef, useCallback } from "react";
import { fetchConversation } from "@/lib/api";
import type { Message } from "@/types";

// 모듈 레벨 캐시 — 같은 conversation id가 렌더링 간 같은 프로미스 반환
// (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()로 초기 데이터를 읽어. 첫 렌더링은 중단되고 이후에는 캐시를 사용해.
  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) => {
    // 진행 중인 스트림부터 취소해.
    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))
            );
            // 캐시 무효화 — 다음 마운트가 서버의 최신 상태를 다시 불러오게 해.
            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 [];
}
useChat을 사용하는 화면 컴포넌트·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

초기 메시지 load, chunk 누적, stop, 낙관적 사용자 메시지, 오류를 하나의 useChat 계약으로 묶어. 개발 서버에서 일정 간격으로 text와 tool event를 보내는 가짜 SSE를 만들고, 응답 중간에 stop했을 때 받은 부분은 남고 연결만 끝나는지 확인해. 대화를 빠르게 바꿨을 때 이전 stream의 chunk가 새 대화에 섞이지 않도록 AbortController도 검증해.
Hint
컴포넌트에는 messages, send, stop, isStreaming, error만 노출해. 초기 load에는 Suspense를 쓸 수 있지만 진행 중 chunk는 일반 state로 이어 붙이고, 두 stream 전환 시험에서 출력이 섞이면 이전 controller의 cleanup을 확인해.

Progress

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

댓글 0

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

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