Taeseong Blog

AI 답변이 뭉텅이로 튀어나오던 이유와 RAF 페이서

2026-05-30

PerformanceReactStreamingrequestAnimationFrameSSE

이번 작업 전후의 차이는 아래 화면에서 가장 잘 보인다. 왼쪽이 개선 전, 오른쪽이 개선 후다.

AI 답변 스트리밍 출력 안정화 전후 비교
개선 전 — 답변 영역이 급격하게 점멸하고, 글자가 불규칙한 속도로 뭉텅이씩 튀어나와 시각적 피로감이 크다.
개선 후 — 출력 속도를 일정하게 유지하고 스크롤 위치를 안정적으로 고정했다.

1편에서 긴 대화방의 렌더링 범위를 줄이고 나니, 스트리밍 자체가 화면에서 어떻게 보이는지가 더 눈에 들어왔다. 서버는 답변을 빠르게 만들고 있었지만 글자는 잠깐 멈췄다가 여러 글자가 한꺼번에 나타나곤 했다. 그렇다고 청크가 도착할 때마다 React 상태를 바로 갱신하는 구조를 유지하면 브라우저 쪽 비용이 다시 커졌다.

AI 답변 하나는 Markdown 렌더링을 거치면서 수십 개, 길면 백 개가 넘는 HTML 태그를 만든다. 한 번의 답변 동안 들어오는 스트리밍 청크도 수백 개였다. 당시 구현은 청크가 들어오는 시점과 화면을 갱신하는 시점이 사실상 같았다.

토큰 도착
→ completion 갱신
→ React 상태 변경
→ 렌더
→ 다음 토큰 도착
→ 다시 렌더

짧은 답변에서는 크게 거슬리지 않았다. 대화와 답변이 길어지면 후반부에서 입력창 반응이 밀리고, 글자가 일정하게 흐르지 않은 채 뭉텅이로 나타났다. 일부 구간에서는 깜빡임도 보였고, 스트리밍 중 스크롤과 입력이 함께 버벅였다.

처음에는 렌더 횟수를 줄이는 쪽부터 봤다. 그런데 이 문제는 횟수만 줄여서는 화면까지 자연스러워지지 않았다. 서버에서 데이터가 도착하는 속도와 화면에 글자를 내보내는 속도를 따로 다뤄야 했다.

네트워크와 화면 렌더링 사이의 간격

기존 구조에서는 서버가 초당 10번 데이터를 보내면 10번 렌더했고, 50번 보내면 50번 렌더했다. 네트워크 이벤트가 그대로 React의 렌더링 스케줄이 된 셈이다.

SSE 데이터가 실제로 도착하는 간격은 일정하지 않다.

10ms  : "안"
13ms  : "녕하"
14ms  : "세요"
80ms  : ...
83ms  : "오늘은 "
85ms  : "무엇을"

평균 속도가 충분히 빠르더라도 몇 개의 청크가 몰려오다가 잠깐 비는 식의 편차가 생긴다. 이 간격을 그대로 화면에 반영하면 글자가 흐르는 속도에도 같은 흔들림이 보인다.

처음 적용한 건 debounce였다. 하지만 스트리밍에서는 이벤트가 계속 들어오기 때문에 실행 시점도 계속 뒤로 밀렸다. 원했던 건

안
안녕
안녕하
안녕하세
안녕하세요

같은 흐름이었는데, 실제 화면은 한동안 멈춘 뒤 문장이 한 번에 나타나는 쪽에 가까웠다.

그래서 throttle로 바꿨다. 업데이트 횟수가 줄면서 입력창이 밀리는 현상은 많이 좋아졌다. 다만 글자는 여전히 잠깐 멈췄다가 몇 글자씩 붙어서 나왔다. throttle은 렌더 횟수를 제한할 뿐, 서버에서 들어오는 데이터의 불규칙한 간격까지 고르게 만들지는 않는다.

이 시점부터 수신과 노출을 같은 상태 변화로 처리하지 않았다.

수신 속도와 노출 속도의 분리

서버에서 받은 전체 문자열과 사용자에게 실제로 노출한 길이를 따로 관리했다.

서버에서 받은 전체 문자열
████████████████████████

사용자에게 보여준 문자열
██████████

아직 화면에 안 보여준 부분
          ██████████████

데이터는 도착하는 즉시 버퍼에 쌓고, 화면에서는 requestAnimationFrame을 돌면서 그 버퍼를 일정한 속도로 소비한다.

const TARGET_CPS = 80;
const FAST_CPS = 240;
const QUEUE_FAST_THRESHOLD = 30;

const tick = (now: number) => {
  const dt = now - lastTime;
  lastTime = now;

  const total =
    completionRef.current.length;

  const exposed =
    chatStreamRefs.exposedLength;

  const queue = total - exposed;

  if (queue > 0 && tempId) {
    const cps =
      queue > QUEUE_FAST_THRESHOLD
        ? FAST_CPS
        : TARGET_CPS;

    const release = Math.max(
      1,
      Math.floor((dt / 1000) * cps)
    );

    const next = Math.min(
      exposed + release,
      total
    );

    chatStreamRefs.exposedLength = next;

    updateStreamContent(
      tempId,
      completionRef.current.slice(
        0,
        next
      )
    );
  }

  rafId =
    requestAnimationFrame(tick);
};

평소에는 초당 약 80자를 보여주고, 서버에서 데이터가 몰려 대기열이 30자보다 많아지면 240cps로 올려 따라잡는다. 서버가 보내는 속도를 화면이 그대로 따라가는 대신, 둘 사이의 큐가 속도 차이를 받아주는 구조다.

여기서 프레임마다 고정된 글자 수를 꺼내지는 않았다. 예를 들어 아래처럼 구현하면 브라우저의 프레임 수가 곧 답변 출력 속도가 된다.

requestAnimationFrame(() => {
  exposeNext(2);
});

브라우저가 항상 60fps로 동작하는 것도 아니고, 렌더링이 무거우면 프레임이 떨어질 수 있다. 120Hz 이상의 환경도 있고 백그라운드 탭에서는 RAF 호출 빈도 자체가 크게 줄어든다.

그래서 프레임 개수 대신 실제 경과 시간 dt를 사용했다.

const release =
  Math.floor(
    (dt / 1000) * cps
  );

같은 CPS를 기준으로 두면 60fps와 120fps에서 목표 출력 속도가 달라지지 않는다. 프레임이 잠깐 끊긴 경우에도 다음 프레임에서 실제로 지난 시간을 반영할 수 있다.

페이서가 서버보다 계속 뒤처지는 것도 피해야 했다. 답변이 이미 충분히 도착했는데 화면만 몇 초씩 늦게 따라가면 그것 역시 좋지 않았다. 그래서 큐가 많이 쌓였을 때만 노출 속도를 높였다.

const cps =
  queue > 30
    ? 240
    : 80;

이 구조에서 RAF 페이서는 답변을 일부러 느리게 보여주는 애니메이션이라기보다, 네트워크 jitter가 그대로 화면의 리듬이 되지 않도록 중간에서 흡수하는 역할에 가까웠다.

스트림이 바뀌는 순간

페이서는 현재 버퍼에 쌓이는 텍스트가 같은 답변의 연속이라는 전제에서 동작한다. 실제 스트림에서는 completion.length가 계속 증가하기만 하는 게 아니었다.

새로운 답변 턴이 시작될 수 있고, 스트림이 종료되거나 SDK 내부의 completion 값이 초기화될 수도 있다. 재생성하면서 새로운 임시 메시지가 만들어지는 경우도 있다. 이때 문자열 길이가 갑자기 줄거나 기준이 되는 메시지가 달라진다.

이 변화를 이전 답변의 연속으로 처리하면, 남아 있던 큐가 새 답변에 섞일 수 있다. 그래서 스트림의 기준이 바뀌는 시점에는 exposedLength를 초기화하거나 해당 틱을 건너뛰도록 했다.

CPS를 어떻게 계산할지보다 이 경계를 제대로 잡는 쪽이 더 까다로웠다. 페이서 입장에서는 얼마나 꺼낼지만큼이나 지금 보고 있는 버퍼가 어느 답변의 것인지가 중요했다.

스트림 전환 시점의 상태 초기화

텍스트 스트리밍이 안정되고 나서는 나레이션이나 행동 묘사처럼 일반 대사와 다른 영역에 줄 단위 연출을 넣었다. 글자를 한 자씩 보여주는 대신 한 줄이 완성되면 그 줄 전체가 왼쪽에서 오른쪽으로 드러나는 방식이다.

여기서는 "한 줄"을 어디서 나눌지가 문제였다. 자동 줄바꿈 위치는 화면 너비와 폰트에 따라 달라진다. 같은 문장도 모바일과 데스크톱에서 줄 수가 다르고, 말풍선 너비나 폰트 크기가 달라지면 줄 경계도 바뀐다.

실제 DOM에 텍스트를 그린 다음 높이와 위치를 읽으면 가장 직접적으로 알 수 있다. 다만 스트리밍 중에는 텍스트가 거의 매 프레임 바뀐다. 그때마다

DOM 쓰기
→ layout 계산
→ DOM 읽기
→ 다시 DOM 쓰기

가 반복되면 강제 reflow가 계속 발생할 수 있다. 페이서 쪽 렌더를 줄여놓고 줄바꿈 계산에서 다시 프레임 비용을 만드는 구조는 피하고 싶었다.

줄바꿈 계산은 @chenglou/pretext로 DOM 밖에서 처리했다. 폰트, 최대 너비, 줄 높이를 넘겨 실제 DOM을 그리지 않고 visual line을 계산했다.

const visualLines = useMemo(() => {
  if (!config || !decodedText) {
    return [];
  }

  try {
    const prepared =
      prepareWithSegments(
        decodedText,
        config.font,
        {
          whiteSpace: 'pre-wrap',
        }
      );

    const result =
      layoutWithLines(
        prepared,
        config.maxWidth,
        config.lineHeight
      );

    return result.lines.map(
      (line) => line.text
    );
  } catch {
    return decodedText.split('\n');
  }
}, [decodedText, config]);

DOM에서는 font, clientWidth, line-height만 측정했다. 측정용 엘리먼트에서 이 값을 읽고, 컨테이너 크기가 바뀔 때는 ResizeObserver로 다시 갱신했다. 텍스트가 바뀔 때마다 DOM layout을 읽는 대신, 레이아웃 조건이 바뀌었을 때만 필요한 측정값을 새로 얻는 식이다.

줄 단위 렌더링과 줄바꿈 계산 비용

줄 단위 애니메이션에서는 현재 마지막 visual line을 바로 보여주지 않았다. 스트리밍 중인 마지막 줄은 다음 토큰에 따라 계속 길어질 수 있기 때문이다.

지금까지 들어온 텍스트가

오늘은 정말 좋은

이어도 다음 토큰이 붙으면

오늘은 정말 좋은 날씨네요.

가 같은 줄에 이어질 수 있다. 폭이 좁은 경우에는 뒤에 텍스트가 붙으면서 일부가 다음 줄로 밀리기도 한다.

이 상태에서 마지막 줄에 애니메이션을 바로 적용하면 줄 경계가 바뀔 때 같은 줄의 연출이 다시 시작될 수 있다. 스트리밍 중에는 마지막 visual line을 숨기고, 다음 줄이 생겨 이전 줄이 더 이상 바뀌지 않는 시점에만 새 key로 mount했다. clip-path 기반 wipe 애니메이션도 그때 한 번 실행했다.

실제 서비스 텍스트에서는 몇 가지 예외 처리가 더 필요했다. HTML entity를 디코딩하기 전에 길이를 재면 &#x...; 같은 문자열이 여러 글자로 계산된다. 브라우저에서는 한 글자인 문자가 계측 쪽에서는 여러 글자로 보이기 때문에 엔티티 중간에서 줄이 잘릴 수 있었다.

순서는 다음처럼 고정했다.

HTML entity decode
→ line measurement

pre-wrap에서는 앞뒤 개행이 빈 visual line으로 잡혀 일반 Markdown 렌더와 여백이 달라지는 경우가 있어 앞쪽의 불필요한 빈 줄을 정리했다.

이미지가 들어간 줄도 그대로 애니메이션하지 않았다. 텍스트가 왼쪽부터 드러나는 건 자연스러웠지만 이미지가 clip-path로 반쯤 잘린 채 펼쳐지는 모습은 어색했다. 줄을 텍스트와 이미지 조각으로 나누고 텍스트에만 애니메이션을 적용했다.

코드 블록과 표는 줄 단위 연출 대상에서 제외했다. 이런 블록을 임의로 나누면 Markdown 파싱이나 문법 강조, 테이블 구조가 깨질 수 있었다.

스크롤 타이밍을 맞추는 RAF

requestAnimationFrame은 새 메시지가 추가된 뒤 맨 아래로 스크롤할 때도 사용했다.

React 상태를 바꾼 직후 스크롤을 호출하면 새 메시지가 아직 DOM에 반영되기 전일 수 있다. 그러면 이전 높이를 기준으로 먼저 스크롤하고, 다음 렌더에서 메시지 높이가 늘어나면서 화면이 한 번 더 움직인다. 사용자 눈에는 아래로 내려갔다가 다시 튀는 것처럼 보였다.

스크롤 호출을 RAF로 한 프레임 미루고 나서는 새 DOM이 반영된 뒤 위치를 잡을 수 있었다.

상태 변경
→ React 렌더
→ 브라우저가 새 DOM 반영
→ RAF
→ 스크롤

같은 API를 쓰고 있지만 이쪽은 페이싱과 목적이 다르다. 애니메이션을 만들기 위해서가 아니라 브라우저 렌더링 사이클 안에서 스크롤을 실행할 시점을 맞추기 위해 사용했다.

빠른 수신과 부드러운 노출의 분리

throttle까지 적용했을 때 React가 처리하는 업데이트 횟수가 줄었고, CPU 사용량과 입력 지연도 많이 좋아졌다. 그런데 그 상태만으로는 글자가 일정하게 흐르지 않았다. 서버에서 데이터가 도착하는 간격 자체가 화면에 그대로 드러나고 있었기 때문이다.

그래서 스트림을 받는 쪽에서는 도착한 텍스트를 바로 저장하고, 화면에서는 RAF 페이서가 실제 경과 시간을 기준으로 그 버퍼를 소비하게 했다.

[서버]
불규칙한 속도로 토큰 생성
        ↓
[수신 버퍼]
도착한 텍스트를 즉시 저장
        ↓
[RAF 페이서]
실제 경과 시간을 기준으로 일정량 방출
        ↓
[화면]
사용자에게 비교적 균일하게 노출

줄 단위 연출도 같은 화면에 들어가지만 계산 경로는 따로 뒀다. DOM은 실제 렌더링을 담당하고, pretext는 visual line 계산을 맡는다. 둘을 섞어서 스트리밍 중 계속 layout을 읽는 구조는 만들지 않았다.

1편에서 긴 채팅방의 렌더링 비용을 줄인 뒤에도 스트리밍 쪽에는 별도의 문제가 남아 있었다. 답변이 빨리 도착하는 것과 그 답변이 화면에서 부드럽게 보이는 건 같은 일이 아니었다.

그다음에는 브라우저가 새로고침되는 경우를 처리해야 했다. 답변 생성 중 기존 HTTP 연결이 끊겨도 생성은 계속되고, 다시 들어왔을 때 이어서 보여주려면 스트리밍 자체를 연결의 수명과 분리해야 했다. 3편은 그 구조를 바꾼 이야기다.