전체 내역

·article·2026.09.16·10 min read·조회수476

채팅형 AI 비서 화면을 만들며 겪은 스트리밍 응답 처리

답변이 끝날 때까지 기다리면 10초 넘게 빈 화면을 봐야 합니다. fetch 스트림 파싱부터 렌더링 성능, 중단 버튼, 자동 스크롤, 스크린 리더 대응까지 스트리밍 응답을 다루며 정리한 내용입니다.

AI AGENT ONE의 채팅형 AI 비서 화면을 만들 때 가장 손이 많이 간 게 스트리밍 응답이었습니다. 답변 전체를 다 받은 다음 한 번에 보여 주면, 긴 답변은 10초 넘게 아무것도 안 보입니다. 반면 토큰이 만들어지는 대로 보여 주면 1초 안에 첫 글자가 뜹니다. 체감 속도가 완전히 다릅니다.

그런데 "받은 만큼 보여 준다"는 단순한 요구를 제대로 구현하는 데 생각보다 함정이 많았습니다. 이 글에서는 그 함정들과 어떻게 넘었는지를 코드와 함께 정리해 보겠습니다.

1. 서버는 SSE로 보내 줍니다

LLM API의 스트리밍 응답은 대부분 SSE(Server-Sent Events) 형식입니다. data:로 시작하는 줄이 이벤트 하나이고, 빈 줄로 이벤트를 나눕니다.

data: {"delta":"안녕"}
 
data: {"delta":"하세요"}
 
data: {"delta":". 무엇을"}
 
data: [DONE]
 

브라우저에 EventSource가 있긴 하지만, GET 요청만 되고 인증 헤더를 붙이기도 불편합니다. 그래서 fetch로 POST 요청을 보내고, 응답 본문 스트림을 직접 읽었습니다.

2. 스트림을 읽고 파싱하기

export async function* streamChat(body: unknown, signal: AbortSignal) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
    body: JSON.stringify(body),
    signal,
  });
  if (!res.ok || !res.body) throw new Error(`요청 실패: ${res.status}`);
 
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
 
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
 
    // 네트워크 청크는 이벤트 경계와 상관없이 잘려서 온다. 빈 줄(\n\n)이 나올 때까지 모았다가 처리한다.
    let boundary: number;
    while ((boundary = buffer.indexOf("\n\n")) !== -1) {
      const event = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);
      for (const line of event.split("\n")) {
        if (!line.startsWith("data:")) continue;
        const data = line.slice(5).trim();
        if (data === "[DONE]") return;
        yield (JSON.parse(data) as { delta: string }).delta;
      }
    }
  }
}

가장 흔하게 빠지는 함정이 청크 경계입니다. 네트워크에서 한 번에 들어오는 덩어리에 이벤트가 반 개만 있을 수도 있고, 세 개가 한꺼번에 있을 수도 있습니다. 처음엔 받자마자 JSON.parse를 했다가 가끔 에러가 났는데, 재현이 잘 안 돼서 한참 헤맸습니다. 버퍼에 모아 두고 이벤트 구분자(\n\n)가 나올 때만 파싱해야 합니다.

한글도 조심해야 합니다. 한글은 UTF-8에서 3바이트라 바이트 단위로 잘리면 글자가 깨집니다. TextDecoderStream을 쓰면 경계에 걸린 바이트를 다음 청크와 이어서 제대로 디코딩해 줍니다.

3. 토큰마다 렌더링하면 버벅입니다

토큰은 초당 수십 개씩 들어옵니다. 토큰이 올 때마다 setState를 부르면, 긴 답변에서 렌더링이 밀리고 마크다운 렌더러까지 매번 다시 돌아서 화면이 버벅입니다. 그래서 requestAnimationFrame 단위로 모았다가 한 프레임에 한 번만 반영했습니다.

function useChatStream() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState<"idle" | "streaming" | "done" | "error" | "aborted">("idle");
  const controller = useRef<AbortController | null>(null);
 
  const send = useCallback(async (body: unknown) => {
    controller.current?.abort();
    const ac = new AbortController();
    controller.current = ac;
    setText("");
    setStatus("streaming");
 
    let pending = "";
    let raf = 0;
    const flush = () => {
      raf = 0;
      const chunk = pending;
      pending = "";
      setText((t) => t + chunk);
    };
 
    try {
      for await (const delta of streamChat(body, ac.signal)) {
        pending += delta;
        if (!raf) raf = requestAnimationFrame(flush); // 프레임당 한 번만 반영
      }
      if (raf) cancelAnimationFrame(raf);
      flush();
      setStatus("done");
    } catch (e) {
      if (raf) cancelAnimationFrame(raf);
      flush(); // 중간에 끊겨도 받은 데까지는 보여 준다
      setStatus(ac.signal.aborted ? "aborted" : "error");
    }
  }, []);
 
  const stop = useCallback(() => controller.current?.abort(), []);
  useEffect(() => () => controller.current?.abort(), []); // 화면을 떠나면 요청도 끊는다
 
  return { text, status, send, stop };
}

4. 멈출 수 있어야 합니다

답변이 엉뚱한 방향으로 가면 사용자는 바로 멈추고 싶어 합니다. AbortController로 fetch를 끊으면, 서버가 지원하는 경우 연결 종료를 감지해서 생성도 멈춥니다. 토큰 비용도 아낄 수 있고요.

<button
  type="button"
  onClick={status === "streaming" ? stop : submit}
  aria-label={status === "streaming" ? "응답 생성 중지" : "보내기"}
>
  {status === "streaming" ? <Square className="size-4" /> : <ArrowUp className="size-4" />}
</button>
{status === "aborted" && <p className="text-sm text-muted">응답 생성을 중지했습니다.</p>}

5. 위로 올려서 읽는 중에는 끌어내리지 않기

답변이 길어지면 화면 아래로 자동 스크롤해 줘야 합니다. 그런데 사용자가 앞부분을 다시 읽으려고 위로 올렸는데 계속 아래로 끌어내리면 정말 불편합니다. 그래서 사용자가 맨 아래 근처에 있을 때만 따라가게 했습니다.

function useStickToBottom(dep: unknown) {
  const ref = useRef<HTMLDivElement>(null);
  const stick = useRef(true);
 
  useEffect(() => {
    const el = ref.current!;
    const onScroll = () => {
      stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48; // 바닥에서 48px 안쪽
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);
 
  useLayoutEffect(() => {
    const el = ref.current!;
    if (stick.current) el.scrollTop = el.scrollHeight;
  }, [dep]);
 
  return ref;
}

6. 스크린 리더가 쉬지 않고 읽지 않게

토큰마다 aria-live로 읽어 주면 스크린 리더가 쉬지 않고 떠듭니다. 그래서 생성 중에는 "답변을 작성하고 있습니다"만 알리고, 다 끝나면 한 번만 알리게 했습니다.

<div aria-live="polite" className="sr-only">
  {status === "streaming" ? "답변을 작성하고 있습니다." : status === "done" ? "답변이 완료되었습니다." : ""}
</div>

마치며

스트리밍은 빨라 보이는 데서 끝나지 않았습니다. 중간에 멈출 수 있어야 하고, 읽는 사람을 방해하지 않아야 하고, 화면을 보지 않는 사람도 쓸 수 있어야 비로소 쓸 만해졌습니다.

비슷한 화면을 만드신다면 파싱은 처음부터 버퍼 방식으로 짜 두시길 권합니다. 재현이 잘 안 되는 버그라서, 나중에 잡으려면 꽤 오래 걸립니다.

Comments (0)