DevInsight

개발하며 겪은 판단을 적어요

AI
조회 0약 6분 읽기

토큰이 흐르는 동안 화면은 왜 산산조각나는가

SSE로 LLM 응답을 토큰 단위로 흘려보내며 화면에 그리는 순간, 마크다운 문법은 중간에 깨지고 중단 요청은 제대로 전달되지 않으며 토큰마다 렌더링이 버벅인다. 이 글은 스트리밍 UI에서 실제로 부딪히는 마크다운 파싱 붕괴, abort 처리와 에러 복구, 렌더링 성능 문제를 실패 사례 순서로 훑고, 부분 렌더링과 버퍼 축적을 최소화하는 백프레셔 방식 등 검증된 해결책을 정리한다.

DevInsight에서 발행해요

#SSE#스트리밍#LLM#마크다운 파싱#AbortController#렌더링 성능#React#에러 복구#토큰 렌더링#프론트엔드

LLM 응답을 토큰 단위로 흘려보내는 채팅 UI에서 화면이 먼저 부서지는 곳은 파싱이다. ##까지 내려온 순간 헤딩이 아니라 가로줄로 보이고, **만 도착한 시점에는 굵은 글씨가 아니라 별표 두 개가 그대로 화면에 박힌다. 코드 펜스가 닫히기 전까지 코드 블록은 일반 문단처럼 흘러나온다. 스트림이 끝나고 나면 정상인데, 흐르는 동안만 매번 깨져 보인다. 파서의 버그가 아니라, 토큰 스트림이 항상 불완전한 상태를 지나간다는 조건에서 비롯되는 현상이다. 읽는 입장에서는 깨진 화면이 먼저 기억에 남는다.

불완전한 문법을 화면에 드러내는 순간

가장 흔한 구현은 매 청크마다 누적 텍스트 전체를 다시 파싱하는 방식이다. 단순하고, 대부분의 마크다운 파서가 일괄 처리(batch) 설계라는 점에서 어쩔 수 없는 선택이기도 하다. 다만 비용이 두 갈래로 나온다. 축적된 길이에 비례하는 재파싱이 매 토큰마다 반복되니 전체적으로 O(n²)에 가깝고, 완성된 문법과 미완성 문법을 구분하지 못해 이미 제대로 그려진 부분까지 다시 렌더링된다. 텍스트가 길어질수록 프레임마다 점점 더 늦어지는 것은 이 이중 비용 때문이다. 토큰당 시간이 아니라, 그때까지 쌓인 누적 길이에 비례해 시간이 커지는 게 핵심이다. 매 토큰마다 DOM을 다시 만들고 레이아웃을 다시 계산하는 비용까지 얹히면, 데이터가 아무리 빨리 와도 화면은 뚝뚝 끊긴다.

갈림길은 두 가지다. 하나는 미완성 상태를 분리해서 다루는 것이다. 닫히지 않은 펜스나 리스트, 표의 머리 같은 구조가 완결되기 전까지 그 영역을 일반 텍스트로 보여주거나 아예 드러내지 않는다. 렌더러가 전체를 다시 그리는 부담은 그대로지만, 문법이 점프하며 겹쳐 보이는 현상은 줄어든다. 다른 하나는 서버가 보내는 조각의 경계를 맞추는 것이다. 마크다운 문법이 끊기지 않는 위치에서만 청크를 끊어 보내면 클라이언트 파싱은 거의 부담을 잃는다. 이 선택지는 서버를 손댈 여지가 있을 때만 열리고, 캐시된 과거 응답을 그대로 재생해야 하는 상황에서는 적용되지 않는다. 증분 파싱을 지원하는 렌더러로 바꾸는 방법도 있지만, 파서 대부분이 일괄 설계라 라이브러리 교체만으로는 비용이 줄어들지 않는다.

두 방법 모두 같은 검증 기준을 쓴다. 스트림이 끝났을 때 최종 상태가 완전한지, 그리고 코드 펜스를 연 채로 임의의 위치에서 끊어도 화면이 허용 가능한 수준인지다. 후자는 파서 테스트로 잡기 어려워서, 응답을 토큰 단위로 잘라 모든 접두사를 순회하는 속성 테스트가 실용적이다. "허용 가능"의 기준을 미리 정해두지 않으면 이 검증은 끝나지 않는다.

abort 신호가 서버까지 닿는 경로

파싱 붕괴보다 감지가 어려운 쪽은 중단 처리다. AbortController로 signal을 넘기고 스트림을 중단하는 코드는 몇 줄이면 되지만, 실제로 중단이 전달되는 경로는 생각보다 길다.

read 루프 안에서 abort를 확인하지 않으면 fetch가 중단된 뒤에도 루프가 계속 버퍼에 남은 데이터를 소비한다. catch에서 e.name === 'AbortError'를 걸러내지 않으면 중단을 오류로 오인해서 재시도 로직이 발동한다. 재시도가 돌면 의도한 중단이 중복 응답으로 이어지는 흐름이 시작된다.

const controller = new AbortController(); try { const res = await fetch(url, { signal: controller.signal }); const reader = res.body.getReader(); while (true) { const { value, done } = await reader.read(); if (done || controller.signal.aborted) break; push(value); } } catch (e) { if (e.name === 'AbortError') return; throw e; }

루프가 끝난 뒤에도 잊기 쉬운 처리가 남는다. 서버 쪽이 연결이 끊겼는지 확인하지 않으면, 클라이언트가 포기한 뒤에도 응답을 만드는 비용을 계속 지불한다. Node 서버라면 res.writableEndedreq.closed를 중단 신호로 삼아 응답 루프를 끊어주는 처리를 해야 한다. SSE에서는 서버가 주기적으로 comment 한 줄을 보내는 keepalive가 흔한데, comment는 화면에 그릴 대상이 아니라서 응답에 섞이지 않고, 연결이 절반쯤 열린 상태를 감지하는 용도로는 이보다 간단한 수단이 없다.

리스너 관리도 자주 어긋난다. signal.addEventListener를 read 반복문마다 등록하고 해제하지 않으면 리스너가 누적된다. 연결당 한 번 등록해두는 편이 안전하다. AbortController는 재사용이 안 되므로 재시도할 때마다 새로 만들어야 한다. fetch의 abort와 스트림 읽기의 abort가 항상 같은 타이밍에 일어나지 않는 점도 고려 대상이다. 둘을 같은 signal로 묶고, 그래도 느슨하게 남는 연결은 클라이언트 쪽 타임아웃으로 닫는 게 현실적이다. 검증은 abort 후 서버 로그에 연결 종료가 남는지, 재시도가 발동하지 않는지로 한다.

반쪽짜리 응답, 살릴지 버릴지

중간에 끊긴 응답을 이어받아 복구할지 폐기할지는 두 조건으로 나뉜다. 끊긴 지점 이후부터 이어서 받아낼 여지가 있는지, 그리고 끊기기 전까지 받은 내용이 부분으로서 가치가 있는지다. 이어받는 방식은 서버가 "중단된 지점 이후부터"라는 재개(resume)를 지원해야 하고, 이어받을 위치가 어긋나면 오히려 어색한 문장이 완성된다. 판단이 흐려지면 폐기하고 처음부터 다시 받는 쪽이 상태 관리가 단순하다. 대신 시도 횟수, 백오프 간격, jitter를 한 곳에서 관리하지 않으면 같은 지점에서 같은 비용이 반복된다. 여기서는 재시도 예산이 유일하게 지켜야 할 규칙이 된다. 어느 쪽을 고르든 만드는 중 표시를 내리고 상태를 완료로 바꾸는 시점은 스트림이 실제로 닫힌 뒤여야 한다. 그 전에 바꾸면 반쪽짜리 응답이 최종본으로 남는다.

연결 끊김과 중단을 같은 경로로 처리하면 또 다른 문제가 붙는다. 중단은 의도된 종료라 재시도를 하면 안 되지만, 연결 끊김은 재시도를 해야 한다. 두 경우를 구분하는 플래그가 없으면 "멈춤" 버튼을 누른 뒤에도 자동 재시도가 같은 응답을 다시 받아온다. 응답 id를 기준으로 스트림을 버리면 이 중복을 걸러낼 수 있다. 이미 처리한 id면 새 스트림이 와도 무시하고, 재시도에도 같은 id를 달아 멱등하게 만드는 패턴이 이 함정을 가장 깔끔하게 막는다.

EventSource는 연결이 끊기면 기본적으로 자동 재접속을 시도하고, 오류 시 error 이벤트를 던진 뒤 재연결 대기로 들어간다. 재접속이 일어나면 스트림 전체가 처음부터 다시 도착해서, 이미 그린 내용 위에 다시 그려지는 겹침이 생긴다. 끊김이 잦으면 화면은 끊긴 사실을 모르는 채 반쪽 응답 위에 같은 내용을 덧그리는 동작을 반복한다. fetch와 ReadableStream 조합으로 직접 루프를 짜면 자동 재접속은 사라지지만, 끊김 감지까지 전부 자기 책임이 된다. Last-Event-ID로 이어받는 패턴은 EventSource 환경에서만 기본 지원되므로, 직접 루프를 쓸 때는 재개 기능을 직접 만들어야 한다.

서버 속도와 화면 속도를 분리하는 역압

정합성 문제 뒤에는 성능 축이 기다린다. 토큰마다 상태를 갱신하고 전체 컴포넌트를 다시 그리는 구조는 첫 토큰 도달까지의 시간(TTFT)이 짧은 대신, 이후 프레임당 부담이 축적된 길이에 비례해 커진다. 한 프레임에 여러 갱신을 묶어주는 배칭이 어느 정도 가려주지만, 청크가 수십 밀리초 간격으로 들어오는 스트림에서는 한계가 있다. 렌더링만의 문제가 아니라, 응답이 긴 채팅일수록 누적 DOM이 커지면서 diff 비용이 함께 늘어난다. 보통 LLM 스트림은 초당 수십에서 수백 토큰 수준으로 도착한다. 이 속도를 파싱과 렌더링이 따라잡지 못하는 순간부터 버퍼가 차오르고, 몇 천 자를 넘어서면 축적된 텍스트 자체가 프레임을 지연시킨다.

실효성이 높은 해법은 버퍼를 두고 화면이 소비하는 속도로만 꺼내 쓰는 역압(backpressure)이다. 서버가 보내는 속도와 렌더러가 그릴 수 있는 속도를 분리하고, requestAnimationFrame(RAF) 기준으로 버퍼를 비운다. 파싱과 렌더링이 한 프레임에 최대 한 번만 일어나므로 매 토큰 재렌더링이 사라진다. 여기서 조절 강도를 처음부터 꽉 조이면 TTFT가 늘어나 체감 반응이 나빠진다. 버퍼가 일정량 쌓인 뒤부터만 꺼내는 속도를 제한하고, 버퍼가 비어 있으면 즉시 그리는 적응형 방식이 안정적이다. 조절 강도는 픽스당 프레임이 60을 넘는지, 렌더링 시간이 16.6ms 예산 안에 들어오는지를 기준으로 정한다. 역압의 실패 모드는 과잉 버퍼링이다. 서버가 빠르게 쏟아내는데 소비만 조이면 버퍼가 커지고, 중단 시점에 버려지는 양도 함께 커진다. 버퍼 상한을 정하고 그 이상 쌓이면 스트림 읽기를 잠시 멈추는 상호 조절을 걸어야 한다.

DOM을 안정적으로 유지하는 것도 같은 맥락이다. 전체 텍스트를 매번 다시 파싱해 통째로 교체하면 커서와 스크롤 위치가 함께 흔들린다. 스트리밍 영역을 컴포넌트 경계로 격리하고 바깥 트리를 다시 그리지 않는 범위를 만들어두는 편이 미세 최적화보다 효과가 크다. React 기준이라면 props가 변하지 않은 하위 트리가 렌더링을 건너뛰도록 경계를 memo로 감싸는 정도가 실질적인 변화를 만든다. 자동 스크롤도 바닥 근처에서만 따라가고, 위로 올라간 상태면 갱신을 멈추는 게 일반적이다. 문턱값 하나면 충분하고, 대개 40~80픽셀 안팎이 무난하다.

검증은 세 지점에서 각각 한다. 파싱은 코드 펜스를 연 채 중간에서 끊어 토큰 단위로 잘라가며 어느 지점에서 화면이 부서지는지 캡처로 남긴다. 중단은 abort 후 서버에 연결 종료가 찍히는지, 재시도가 중복을 만들지 않았는지를 본다. 성능은 CDP 네트워크 제한으로 대역폭과 지연을 조작하면서 TTFT와 프레임 타임을 측정한다. 세 지점 모두 정상 네트워크에서는 드러나지 않으므로, 지연을 인위적으로 키운 환경에서 돌려야 의미가 있다. 가장 빠른 재현은 로컬에서 토큰을 몇 밀리초 간격으로 내뱉는 작은 스트림 서버를 띄우는 것이다. 대역폭 제한 없이도 끊기고, 재시도되고, 다시 끊기는 시나리오를 순서대로 재생하면 된다.

이 함정들을 한 번에 다 해결하려 들지 말고, 중단 경로부터 손대라. 파싱과 성능은 눈에 보여서 고치기 쉽다. 중단과 재시도는 동작하는 것처럼 보이다가 스트림이 실제로 끊긴 순간에만 결함이 드러난다. abort 후 서버 로그에 연결이 닫혔는지부터 확인하면, 나머지 대부분은 그 경로 안에 들어와 있다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

AI 전체 보기
AI

잘게 썰수록 놓치는 문맥, 크게 썰수록 잡히는 노이즈

RAG 품질은 모델이나 임베딩보다 청크 분할에서 갈리는 경우가 많다. 문장 하나를 통째로 잘라내도 답이 될 단락까지 쪼개는 실수, 고정 토큰 수 분할이 의미 단위를 무너뜨리는 문제, 임베딩 모델의 문맥 창을 무시한 크기 설정까지. 이 글은 흔한 분할 실수들을 문맥 유실과 노이즈 유입이라는 두 축으로 비교하고, 평가 시 실제 쿼리 적중 여부와 판단 척도를 따져 청크 설계를 바로잡는 방법을 정리한다.

#RAG#청크 분할#Chunking#임베딩

이전 글

EXPLAIN 앞에서 무너지는 인덱스, 네 가지 몸통 찾기

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독