LLM 스트리밍 UI, 60fps로 만드는 법

LLM 스트리밍 UI, 60fps로 만드는 법

토큰이 쏟아질 때마다 화면이 떨리는 이유—DOM 리플로우 제거와 Canvas 렌더링으로 AI 채팅 인터페이스를 완전히 다시 설계하는 실전 전략.

LLM 스트리밍 레이아웃 리플로우 Canvas 렌더링 Web Worker ZeroJitter Pretext 프론트엔드 성능 AI 채팅 UI
광고

ChatGPT를 열고 응답이 스트리밍되는 동안 스크롤바를 주시해보자. 미세하게 떨린다. 내용이 튄다. 기기가 조금만 느려도 프레임이 뚝뚝 끊긴다. 이건 버그가 아니라, 브라우저가 원래 이렇게 설계됐기 때문이다. 그리고 AI 모델이 빨라질수록 이 문제는 더 심각해진다.

왜 토큰마다 화면이 떨리는가

문제의 구조는 단순하다. 토큰 하나가 도착할 때마다 브라우저는 다음 연쇄를 실행한다: DOM 변이 → 스타일 재계산 → 레이아웃 리플로우 → 페인트 → 컴포짓. 초당 50개 토큰이 오면 초당 50번 이 전체 사이클이 돌아간다. 200개 DOM 노드가 있는 페이지라면 리플로우 한 번에 수십 개 노드가 재계산된다. 브라우저 레이아웃 엔진은 이런 쓰기 집약적 실시간 워크로드를 위해 설계된 게 아니다.

결과는 익숙하다. 스크롤바 지터, 콘텐츠 점프, 드롭된 프레임. GPT-4o가 초당 100 토큰을 스트리밍하는 지금도 이 문제가 있는데, 다음 세대 모델이 200 토큰/초를 넘어서면 DOM 렌더링은 구조적으로 무너진다.

핵심 인사이트: 측정과 렌더링을 분리하라

Dev.to에 공개된 ZeroJitter 라이브러리(원문)가 제시한 해법은 DOM을 우회하는 것이다. <canvas>fillText()는 컴포짓 스레드에서 직접 픽셀 연산을 수행한다. DOM 노드 없음, CSS 재계산 없음, 레이아웃 리플로우 없음. 순수한 수학 → 픽셀이다.

하지만 "그냥 캔버스 쓰면 되잖아"는 "어셈블리로 다시 짜면 되잖아"만큼 단순한 말이다. 텍스트 선택, 스크린 리더 접근성, 반응형 리플로우, 줄 바꿈, CJK·BiDi·태국어 지원—이 모든 걸 잃는다.

ZeroJitter의 진짜 기여는 이 트레이드오프를 해결한 아키텍처에 있다. 텍스트 레이아웃에서 비용이 큰 건 픽셀을 그리는 것이 아니라 텍스트를 측정하는 것이라는 인사이트다. 새 단어가 오면 브라우저는 이 단어가 현재 줄에 들어가는지, 다음 줄이 어디서 시작하는지, 컨테이너 높이는 얼마나 되는지를 계산해야 한다. 이 계산 비용이 리플로우를 유발한다.

Web Worker로 측정을 오프로드하다

ZeroJitter는 이 측정 작업 전체를 Web Worker로 옮긴다. Worker는 OffscreenCanvasmeasureText()로 각 세그먼트를 측정하고, 결과를 캐시하며("the"@16px Inter는 항상 같은 너비다), Intl.Segmenter로 CJK 문자 단위 줄바꿈, 태국어 단어 경계, 아랍어/히브리어 BiDi를 처리한다. 그 결과(줄 데이터, 높이, 너비)를 메인 스레드로 돌려보내면, 메인 스레드는 fillText()로 각 줄을 그리기만 한다.

접근성은 시각적으로 숨겨진 <div aria-live="polite">가 캔버스 텍스트를 미러링해서 확보한다. 스트리밍 중에는 300ms 디바운스로 스크린 리더가 토큰마다 인터럽트되지 않도록 한다. requestAnimationFrame으로 같은 프레임에 들어온 토큰을 배치 처리하고, 단조 증가하는 요청 ID로 순서가 뒤바뀐 Worker 응답을 무시한다.

수치는 명확하다: 토큰당 리플로우 0회, 레이아웃 시간 0.01ms 미만, 100 토큰/초에서 프레임 드롭 0, FPS 60 고정.

Pretext: 측정 엔진의 기반

ZeroJitter가 내부적으로 의존하는 텍스트 레이아웃 엔진이 바로 Pretext(GitHub)다. DOM 접근 없이 멀티라인 텍스트의 높이와 줄 배치를 계산하는 순수 TypeScript 라이브러리로, getBoundingClientRectoffsetHeight 같은 DOM 측정 API를 일절 사용하지 않는다.

API 설계가 실용적이다. prepare()가 텍스트를 전처리해 불투명 핸들을 반환하고, layout()이 캐시된 폭 데이터로 순수 산술 연산만으로 높이와 줄 수를 계산한다. 벤치마크에 따르면 500개 텍스트 기준 prepare() 약 19ms, layout() 약 0.09ms다. 리사이즈 시에는 prepare()를 다시 실행할 필요 없이 layout()만 호출하면 된다.

이 라이브러리가 흥미로운 또 다른 이유는 개발 방식이다. 저자는 Claude Code와 Codex에 브라우저의 ground truth 데이터를 학습시켜 여러 주에 걸쳐 반복 측정하며 구현했고, Cursor 에이전트가 대부분의 구현을 담당했다고 알려졌다. 브라우저별 이모지 측정 차이, Safari의 system-ui 폰트 이슈, 극도로 좁은 너비에서의 grapheme 단위 줄바꿈 같은 엣지 케이스들이 AI와의 반복 실험을 통해 해결됐다.

프론트엔드 DX에 주는 실질적 시사점

이 두 프로젝트가 맞닿아 있는 지점은 단순히 "캔버스로 렌더링하면 빠르다"가 아니다. 핵심은 브라우저 렌더링 파이프라인에서 비용이 가장 비싼 레이어를 정확히 식별하고, 그 레이어만 우회했다는 것이다.

DOM 리플로우를 제거하는 접근법은 이미 "브라우저를 제대로 이해해야 살아남는 프론트엔드 성능 전략"에서 다뤘지만, AI 스트리밍이라는 맥락은 이 문제를 새로운 차원으로 끌어올린다. 기존의 리플로우 최적화는 대부분 "최대한 배치 처리하라"는 수준의 권고였다. 하지만 초당 100번 이상의 DOM 변이가 설계상 불가피한 스트리밍 환경에서는 배치 처리만으로 근본적인 해결이 안 된다. 렌더링 레이어 자체를 바꿔야 한다.

실무 적용 관점에서 세 가지 판단 기준을 제안한다. 첫째, 스트리밍 토큰이 초당 30개 이하라면 지금 당장 마이그레이션 비용이 효익보다 클 수 있다. 둘째, 멀티턴 대화보다 장문 스트리밍이 주 사용 패턴이라면 효과가 가장 크다. 셋째, Markdown 렌더링이 포함된 경우 현재 ZeroJitter는 raw 텍스트에 최적화되어 있으므로 추가 레이어가 필요하다.

전망: 브라우저 표준이 해줄 것, 우리가 해야 할 것

Hacker News 토론에서 나온 지적이 날카롭다: "이런 기능은 브라우저 표준 API로 제공되어야 한다." Font Metrics API 제안이 이미 존재하지만, 브라우저 벤더들은 우선순위를 두지 않고 있다. Chrome은 AI 관련 API에 집중 중이다.

이 공백을 Pretext와 ZeroJitter 같은 라이브러리가 채우고 있다는 사실, 그리고 그 구현 자체가 AI 에이전트(Cursor, Claude Code)와의 협업으로 이뤄졌다는 사실은 의미심장하다. 표준화 이전에 실무 수요가 생태계를 먼저 만들고, AI가 그 복잡도를 감당하는 개발 패턴—이게 지금 프론트엔드가 움직이는 방식이다.

모델 속도는 계속 빨라진다. DOM 렌더링의 물리적 한계는 고정되어 있다. AI 채팅 인터페이스가 보편화되는 지금, 스트리밍 UI 성능은 더 이상 "나이스 투 해브"가 아니다. 부드러운 스트리밍은 인지된 지능이다—사용자는 떨리는 화면을 AI가 느리거나 불안정하다는 신호로 읽는다. 60fps는 UX 디테일이 아니라 제품 신뢰도의 문제다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요