주성진·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)