LLM 채팅 UI에서 240 FPS를 추구하기
Source: Dev.to
TL;DR
React UI에서 스트리밍 LLM 응답을 테스트하기 위한 벤치마크 스위트를 만들었습니다. 주요 요점:
- 먼저 올바른 상태를 구축하고, 나중에 렌더링을 최적화하세요. 가능하면 상태를 React 외부(예: Zustand)에 두고 필요에 따라 연결합니다.
- React 재렌더링이나 훅에 집중하지 말고 윈도잉에 우선순위를 두세요.
memo,useTransition,useDeferredValue등으로 얻는 이득은 윈도잉 기법에 비해 미미합니다. - Critical Rendering Path(CRP)를 CSS로 최적화하세요.
content-visibility: auto와contain: content같은 속성을 사용합니다. 애니메이션을 쓰는 경우will-change를 추가하세요. - 가능한 경우 LLM이 원시 마크다운을 반환하지 않도록 하세요. 마크다운을 파싱하고 렌더링하는 비용이 큽니다. 반드시 필요하다면 응답을 일반 텍스트와 마크다운 구간으로 나누고 마크다운 부분만 파싱합니다.
- 네트워크 속도가 병목이 아니라면 스트림을 스로틀링하세요. 단어 사이에 5–10 ms 정도의 짧은 지연을 두면 FPS를 더 높게 유지할 수 있습니다.
How LLM Streaming Works
LLM은 전체 답변을 한 번에 보내는 것이 아니라 Server‑Sent Events(SSE)를 통해 청크 단위로 스트리밍합니다.
// Basic LLM stream handling with fetch
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
if (streamDone) {
done = true;
break;
}
const chunk = new TextDecoder().decode(value);
// Process the chunk (can be one or multiple words)
console.log(chunk);
}
}
Handling the Chunk in React – Why It Lags
가장 단순한 접근법은 들어오는 청크마다 React 상태를 업데이트하는 것입니다.
import { useState } from "react";
const [response, setResponse] = useState("");
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
if (streamDone) {
done = true;
break;
}
const chunk = new TextDecoder().decode(value);
setResponse(prev => prev + chunk); // Update state with each chunk
}
}
채팅 기록이 커질수록 각 상태 업데이트가 전체 컴포넌트 트리를 다시 렌더링하게 되고, FPS가 급격히 떨어집니다(몇 초 안에 0 FPS까지 떨어지는 경우가 많음).

Optimizations
벤치마크 스위트에는 다음이 포함됩니다:
- 청크를 일정 지연과 함께 스트리밍하는 최소 Node + TypeScript 서버(LLM 스트림을 시뮬레이션).
- 다양한 최적화를 구현한 React + Vite + TypeScript 프론트엔드.
- 스트리밍 중 FPS로 측정한 성능; 최적화마다 RAM 사용량이 달라집니다.
RAF Batching
들어오는 청크를 버퍼링하고 requestAnimationFrame(RAF)당 한 번씩 상태에 플러시합니다.
import { useRef, useState } from "react";
const [response, setResponse] = useState("");
const bufferRef = useRef("");
async function streamLLMResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
bufferRef.current += new TextDecoder().decode(value); // Collect chunk
requestAnimationFrame(() => {
if (bufferRef.current) {
setResponse(prev => prev + bufferRef.current); // Flush once per frame
bufferRef.current = "";
}
});
}
}
Result: Min FPS ≈ 15 after 90 seconds—noticeable improvement but still not ideal.

React 18 startTransition
startTransition은 업데이트를 비긴급으로 표시해 React가 긴급 UI 작업(예: 사용자 입력)을 스트리밍 텍스트 업데이트보다 우선 처리하도록 합니다.
import { startTransition, useState } from "react";
function handleInputChange(e) {
const value = e.target.value;
setInputValue(value); // Urgent
startTransition(() => {
setFilteredList(filterList(value)); // Non‑urgent
});
}
Caveat: 청크가 React가 양보할 수 있는 속도보다 빨리 들어오면 메인 스레드가 여전히 포화될 수 있어 startTransition의 이점이 제한됩니다.
Additional Tips
-
Windowing / Virtualization: 채팅 기록 중 화면에 보이는 부분만 렌더링하세요(예:
react-window또는react-virtualized사용). 이렇게 하면 DOM 크기와 재페인팅 비용이 크게 감소합니다. -
CSS Optimizations:
.chat-message { content-visibility: auto; contain: content; }애니메이션 요소에는
will-change를 사용해 브라우저에 곧 일어날 변화를 알려줍니다. -
Chunk Throttling: 청크 처리 사이에 인위적인 짧은 지연(5–10 ms)을 두면 렌더링 스파이크를 부드럽게 만들 수 있으며, 인지된 반응성에는 거의 영향을 주지 않습니다.
-
State Management Outside React: Zustand 같은 라이브러리를 사용하면 변형 가능한 스토어를 유지하고 React가 덜 자주 읽게 할 수 있어, 상태 업데이트에 따른 재렌더링 횟수를 줄일 수 있습니다.
RAF 배칭, 윈도잉, CRP‑중심 CSS, 그리고 신중한 상태 관리를 결합하면 안정적인 60 FPS 경험에 접근할 수 있으며, 성능이 좋은 하드웨어에서는 LLM 채팅 UI에서 240 FPS까지도 끌어올릴 수 있습니다.