Taeseong Blog

[채팅방 개선기 2] AI 답변이 뭉텅이로 튀어나오던 이유와 RAF 페이서

2026-05-22

PerformanceReactStreamingrequestAnimationFrameSSE

먼저 이번 글에서 다룰 변화부터 보자. 이미지의 왼쪽이 개선 전, 오른쪽이 개선 후다.

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

AI 답변은 빨리 오는 것만으로는 충분하지 않았다. 서버가 토큰을 빠르게 만들어도 화면에서는 글자가 잠깐 멈췄다가 한꺼번에 튀어나올 수 있다. 반대로 부드럽게 보여주겠다고 토큰이 올 때마다 React 상태를 갱신하면 브라우저가 먼저 지친다.

베이비챗 채팅방에서도 딱 이 두 문제가 같이 나타나고 있었다. AI 답변 하나에는 Markdown 렌더링을 거치며 수십 개, 길면 백 개가 넘는 HTML 태그가 생긴다. 한 번의 답변 동안 들어오는 스트리밍 청크도 수백 개에 달한다.

초기 구현은 청크가 올 때마다 상태를 갱신하는 구조였다.

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

짧은 답변에서는 별 문제가 없어 보였다. 하지만 대화가 길어지고 답변도 길어지자 증상이 눈에 띄기 시작했다.

  • 답변 후반부로 갈수록 입력창 반응이 밀린다.
  • 글자가 일정하게 흐르지 않고 뭉텅이로 나타난다.
  • 일부 구간에서는 깜빡임이 보인다.
  • 스트리밍 중 스크롤과 입력이 같이 버벅인다.

처음에는 "렌더 횟수만 줄이면 되겠지"라고 생각했다. 실제로는 그보다 한 단계 더 분리해야 했다.

서버에서 도착하는 속도와 화면에 그리는 속도는 서로 다른 문제였다.

네트워크 이벤트를 그대로 렌더링 스케줄로 쓰고 있었다

서버가 초당 10번 데이터를 보내면 10번 렌더하고, 50번 보내면 50번 렌더하는 구조였다. 즉 네트워크 이벤트가 그대로 React의 렌더링 스케줄이 되고 있었다. 문제는 네트워크가 UI를 그리기 좋은 간격으로 데이터를 보내주지 않는다는 점이다.

실제 SSE 데이터는 보통 이런 식으로 들어온다.

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

평균적으로는 충분히 빠르더라도 실제 도착 간격에는 편차가 있다. 처음에는 익숙한 방법부터 시도했다. debounce였다. 검색창이나 자동 저장에서는 debounce가 잘 맞는다. 입력이 계속되는 동안 기다렸다가, 잠잠해지는 순간 한 번 실행하면 되기 때문이다.

그런데 스트리밍에서는 이 특성이 오히려 문제가 됐다. 모델이 답변을 만드는 동안 이벤트는 계속 들어온다. 그러면 debounce의 실행 시점도 계속 뒤로 밀린다.

원했던 건 이런 흐름이었다.

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

그런데 실제로는 이렇게 보였다.

(잠깐 멈춤)
안녕하세요. 반갑습니다...

필요했던 동작은 "마지막 이벤트 이후 조용해지면 실행"이 아니었다.

이벤트가 계속 들어와도 일정한 간격으로 화면을 갱신해야 했다.

그래서 debounce를 throttle로 바꿨다. throttle을 적용하니 React가 처리해야 하는 업데이트 횟수는 확실히 줄었다. 입력창이 밀리는 현상도 많이 좋아졌다. 하지만 화면을 보고 있으면 여전히 뭔가 어색했다.

글자가 매끄럽게 흐르는 게 아니라, 잠깐 멈췄다가 몇 글자가 붙어서 나오기를 반복했다.

이유는 간단했다.

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까지 올려 빠르게 따라잡는다.

중요한 건 숫자 자체보다 구조였다.

서버는 서버 속도로 데이터를 보내고, 화면은 화면에 맞는 속도로 그린다. 중간의 큐가 두 속도 차이를 흡수한다. 그래서 서버에서 데이터가 조금 몰려오거나 잠깐 뜸해져도 사용자 입장에서는 훨씬 일정하게 흐르는 것처럼 보인다.

왜 프레임당 N글자가 아니라 CPS였나

처음 떠올리기 쉬운 방법은 프레임마다 두 글자씩 보여주는 식이다.

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

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

프레임 수에 글자 수를 묶으면 환경에 따라 답변 속도까지 달라진다. 그래서 프레임 개수가 아니라 실제로 지난 시간 dt를 사용했다.

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

기준은 하나다.

초당 몇 글자를 보여줄 것인가.

60fps든 120fps든 같은 속도를 목표로 할 수 있고, 중간에 프레임이 잠깐 끊겨도 다음 프레임에서 실제 경과 시간을 반영해 따라간다. 또 하나 중요했던 건 페이서를 "느리게 보여주는 애니메이션"으로 만들지 않는 것이었다.

서버가 이미 답변을 다 만들어놨는데 화면만 몇 초씩 뒤처져 있다면 그것도 좋은 경험은 아니다. 그래서 큐가 많이 쌓이면 속도를 높였다.

const cps =
  queue > 30
    ? 240
    : 80;

평소에는 부드럽게 보여주고, 데이터가 밀리면 빨리 따라잡는다. 결국 페이서의 역할은 답변을 일부러 늦추는 것이 아니라 네트워크의 jitter를 화면에서 감추는 것에 가까웠다.

페이서보다 더 까다로웠던 건 스트림의 경계였다

RAF 페이서가 잘 돌아가려면 한 가지 전제가 있다. 지금 쌓이고 있는 텍스트가 같은 답변의 연속이어야 한다. 그런데 실제 스트리밍에서는 항상 문자열 길이가 증가하기만 하지 않는다.

예를 들어 이런 일이 생긴다.

  • 새로운 답변 턴이 시작된다.
  • 스트림이 종료된다.
  • SDK 내부 completion 값이 초기화된다.
  • 재생성하면서 새로운 임시 메시지가 만들어진다.

이때 completion.length가 갑자기 줄어들거나 기준이 되는 메시지 자체가 바뀔 수 있다. 페이서가 이걸 이전 스트림의 연속이라고 생각하면 이전 답변의 남은 큐를 새 답변에 섞어서 보여줄 수도 있다.

그래서 스트림의 기준이 바뀌는 순간에는 exposedLength를 초기화하거나 해당 틱을 건너뛰도록 했다. 막상 구현하고 보니 CPS 계산보다 이쪽이 더 중요했다.

얼마나 빠르게 보여줄지보다, 지금 무엇을 보여주고 있는지를 정확히 아는 게 먼저였다.

글자는 부드러워졌는데, 이번에는 '줄'이 문제였다

텍스트 스트리밍 자체는 꽤 자연스러워졌다. 그런데 나레이션이나 행동 묘사처럼 일반 대사와 다른 느낌을 주고 싶은 영역에서는 한 단계 더 욕심이 생겼다.

글자를 한 자씩 보여주는 대신,

한 줄이 완성되면 그 줄 전체가 왼쪽에서 오른쪽으로 드러나는 연출

을 넣고 싶었다.

문제는 "한 줄"이 생각보다 단순한 개념이 아니라는 것이다. 브라우저가 어디에서 자동 줄바꿈을 할지는 화면 너비와 폰트에 따라 달라진다. 같은 문장도 모바일과 데스크톱에서 줄 수가 다르고, 말풍선 너비나 폰트 크기가 달라져도 줄바꿈 위치가 바뀐다.

가장 쉬운 방법은 실제 DOM에 텍스트를 그린 뒤 높이와 위치를 읽는 것이다. 하지만 스트리밍 중에는 텍스트가 거의 매 프레임 바뀐다. 그때마다 아래 과정이 반복되면 문제가 된다.

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

강제 reflow가 계속 발생할 수 있다. RAF 페이서로 겨우 렌더링을 부드럽게 만들어놓고, 줄바꿈 계산 때문에 프레임을 다시 망칠 수 있는 구조였다. 그래서 줄바꿈 계산 자체를 DOM 밖으로 옮겼다.

@chenglou/pretext에 폰트, 최대 너비, 줄 높이 같은 값을 넘기고 실제 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 읽지 않음

컨테이너 크기가 바뀔 때
→ 필요한 측정값만 갱신

스트리밍 도중 계속 layout을 읽는 일을 피할 수 있었다.

마지막 줄은 일부러 숨겼다

줄 단위 애니메이션을 만들고 나니 또 하나 재미있는 문제가 생겼다. 현재 화면의 마지막 줄은 사실 아직 "완성된 줄"이 아닐 수 있다.

예를 들어 지금까지 들어온 텍스트가

오늘은 정말 좋은

이라고 해도, 다음 토큰이 오면

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

가 같은 줄에 이어질 수 있다.

폭이 좁다면 중간에서 새로운 줄로 밀려날 수도 있다. 그 상태에서 마지막 줄을 바로 애니메이션하면 텍스트가 들어올 때마다 줄 경계가 바뀌고, 같은 줄의 애니메이션이 반복될 수 있다. 그래서 스트리밍 중에는 마지막 visual line을 보여주지 않았다.

다음 줄이 생기는 순간 이전 줄은 더 이상 바뀌지 않는 확정된 줄이 된다. 그때 새로운 key로 mount하고 clip-path 기반 wipe 애니메이션을 딱 한 번 실행했다. 이렇게 하니 줄이 계속 재계산되더라도 이미 보여준 줄의 연출이 다시 시작되지 않았다.

실제 서비스 텍스트를 넣어보니 예외도 몇 가지 있었다. HTML entity는 디코딩 전에 길이를 재면 &#x...; 같은 문자열이 여러 글자로 계산된다. 브라우저에서는 한 글자인 문자가 계측기에서는 여러 글자로 보이니, 심하면 엔티티 중간에서 줄이 잘렸다.

그래서 순서를 반드시 이렇게 유지했다.

HTML entity decode
→ line measurement

pre-wrap에서는 앞뒤 개행이 빈 visual line이 되면서 일반 Markdown 렌더와 여백이 달라지는 경우가 있었고, 앞쪽의 불필요한 빈 줄을 정리해야 했다.

이미지가 포함된 줄도 따로 처리했다. 텍스트가 왼쪽부터 드러나는 것은 자연스럽지만 이미지가 clip-path로 반쯤 잘린 상태에서 펼쳐지는 건 꽤 어색했다. 그래서 줄을 텍스트와 이미지 조각으로 나누고 텍스트만 애니메이션했다. 코드블록과 표는 아예 줄 단위 연출에서 제외했다.

이런 블록을 임의로 줄 단위 분리하면 Markdown 파싱이나 문법 강조, 테이블 구조가 깨질 수 있기 때문이다.

RAF는 애니메이션보다 '언제 실행할지'를 정하는 도구였다

이번 작업에서 RAF를 한 군데 더 사용했다. 새 메시지가 추가된 뒤 맨 아래로 스크롤하는 코드였다. React 상태를 바꾼 직후 바로 스크롤하면 아직 새 메시지가 DOM에 반영되기 전일 수 있다.

그러면 브라우저는 이전 높이를 기준으로 스크롤한다. 바로 다음 렌더에서 메시지 높이가 늘어나면서 화면이 다시 움직이고, 사용자 눈에는 한 번 내려갔다가 다시 튀는 것처럼 보인다. 스크롤 호출을 requestAnimationFrame으로 한 프레임 미뤄보니 문제가 사라졌다.

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

여기서 RAF는 애니메이션을 만드는 용도가 아니었다. 브라우저의 렌더링 사이클 안에서, 작업을 실행하기 좋은 시점을 잡는 도구였다.

빠른 답변과 부드러운 답변은 결국 다른 문제였다

처음 이 문제를 잡았을 때는 렌더 횟수만 줄이면 끝날 줄 알았다. 실제로 throttle만 적용해도 CPU 사용량과 입력 지연은 많이 줄었다. 하지만 화면의 움직임까지 자연스러워지지는 않았다.

서버가 데이터를 보내는 속도 자체가 불규칙했기 때문이다. 결국 스트리밍 구조는 이렇게 바뀌었다.

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

줄 단위 연출에서는 한 단계 더 분리했다.

DOM
→ 실제 렌더링

pretext
→ 줄바꿈 계산

돌이켜보면 이번 작업에서 가장 효과가 컸던 건 복잡한 최적화 기법이 아니었다. 서로 다른 역할을 같은 곳에서 처리하지 않도록 경계를 나눈 것이었다.

도착하는 속도와 그리는 속도를 분리하고,

텍스트를 보여주는 일과 줄바꿈을 계산하는 일을 분리했다.

빠르게 만드는 것과 부드럽게 보이게 만드는 것은 꽤 가까워 보이지만 같은 문제는 아니었다. 그리고 부드러운 연출을 넣겠다고 렌더링이나 레이아웃 비용을 다시 늘리면, 결국 빠르지도 부드럽지도 않은 화면이 된다.

1편에서 긴 채팅방의 렌더링 비용을 줄였다면, 이번에는 그 위에서 스트리밍이 어떻게 보여야 하는지를 다뤘다. 그다음 문제는 브라우저 바깥에 있었다. 사용자가 답변 생성 중에 새로고침하면 브라우저와 기존 HTTP 연결은 사라진다.

그래도 생성은 계속되고, 다시 들어왔을 때 이어서 보여주려면 스트리밍 자체를 HTTP 연결에서 분리해야 한다.

다음 개선은 그 지점에서 시작됐다.