UI에서 모델 출력 스트리밍하고 렌더링하기
스트리밍이 AI 기능을 빠르게 느끼게 만드는 이유, 그리고 깜빡임·깨진 마크업·레이아웃 혼란 없이 토큰 단위 출력을 UI에 렌더링하는 법.
빠르게 느껴지는 AI 기능과 고장 난 것처럼 느껴지는 AI 기능의 차이는 종종 모델이 아닙니다. 출력을 스트리밍하느냐의 문제죠. 전체 답을 내놓는 데 몇 초가 걸리는 모델은, 사용자가 그 내내 스피너만 바라본다면 고통스러울 만큼 느리게 느껴집니다. 같은 모델이라도 첫 단어가 거의 즉시 나타나고 나머지가 흘러 들어오면 반응이 빠르게 느껴집니다. 스트리밍은 긴 기다림을 살아 있는 응답으로 바꾸는 기법이며, 이를 잘 렌더링하는 일은 익혀 둘 가치가 있는 작은 기술입니다.
스트리밍이 경험을 바꾸는 이유
보통의 요청을 보내면, 무언가를 보기 전에 응답 전체를 기다려야 합니다. 긴 답이라면 한참 동안 아무것도 없는 화면을 응시하는 셈이죠. 스트리밍은 기다림의 형태를 바꿉니다. 끝에 한 번의 큰 지연 대신, 모델은 생성하는 대로 출력을 점진적으로 보내고, 여러분은 도착하는 조각을 그때그때 표시합니다.
완료까지의 총 시간은 대체로 같습니다. 바뀌는 것은 체감 속도입니다. 무언가가 나타나기까지의 시간, 즉 첫 토큰까지의 시간이 1초도 안 되는 수준으로 떨어지고, 사람은 전체 생성이 느리더라도 스트리밍 응답을 빠르다고 읽습니다. 이는 멈춘 화면 대 진행 막대의 심리와 같습니다. 작업은 동일하지만 경험은 다르죠. 사람이 기다리는 무언가라면, 스트리밍은 사실상 필수입니다.
스트리밍이 작동하는 큰 그림
내부적으로 스트리밍 응답은 하나의 최종 페이로드가 아니라 작은 이벤트의 연속을 전달하는, 오래 유지되는 연결입니다. 각 이벤트는 출력의 한 청크를 담는데, 보통 몇 글자나 토큰 하나죠. 여러분의 코드는 이 이벤트들을 도착하는 대로 읽어, 각 청크를 지금까지 누적한 것에 덧붙이고, 화면을 갱신합니다. 스트림이 완료를 알리면, 조각조각 조립된 전체 응답을 손에 쥐게 됩니다.
의사 코드로 보면 루프는 단순합니다:
accumulated = ""
for chunk in stream(request):
accumulated += chunk.text
render(accumulated)
on_complete():
finalize(accumulated)
제공사의 SDK가 전송 세부 사항을 처리해 줍니다. 여러분의 일은 그 루프입니다. 청크를 읽고, 누적하고, 렌더링하고, 끝을 처리하는 것이죠. 흥미로운 문제는 거의 전부 렌더링 쪽에 있습니다.
델타가 아니라 누적된 텍스트를 렌더링하세요
스트림 렌더링의 첫 번째 규칙은 가장 최근 청크가 아니라 누적된 문자열을 표시하는 것입니다. 각 델타를 원시 텍스트로 DOM에 바로 덧붙이고 싶은 유혹이 들지만, 형식이 조금이라도 필요해지는 순간 그 방식은 깨집니다. 마크다운, 코드 블록, 구조화된 출력은 전체로서만 의미가 통합니다. 청크 하나가 단어, 마크다운 토큰, 또는 태그를 반으로 쪼갤 수도 있습니다. 델타를 독립적으로 렌더링하면 깨진 부분 마크업이 나옵니다.
대신, 전체 누적 텍스트를 상태에 보관하고 갱신할 때마다 다시 렌더링하세요. 현대 UI 프레임워크는 이를 저렴하게 만들어 줍니다. 상태의 문자열 하나만 갱신하면, 프레임워크가 화면을 효율적으로 조정합니다. 매번 전체 누적 답을 렌더링하면 마크다운이나 구문 강조가 항상 "지금까지 완성된" 텍스트를 보게 되어, 고립된 조각보다 훨씬 우아하게 파싱할 수 있습니다.
부분적이고 깨진 중간 상태를 처리하세요
응답이 스트리밍되는 동안, 모든 중간 상태는 정의상 불완전합니다. 코드 블록이 여는 펜스만 있고 아직 닫는 펜스가 없을 수도 있습니다. 마크다운 링크가 절반만 입력됐을 수도 있죠. 목록이 항목 중간에 멈출 수도 있습니다. 렌더러가 엄격하다면 이런 부분 상태는 깜빡이거나 오류를 던집니다.
해법은 관대하게 렌더링하는 것입니다. 종료되지 않은 구조를 오류 없이 우아하게 처리하는 마크다운 파서를 쓰고, 텍스트가 더 도착하면서 해소될 진행 중 형식을 화면이 잠깐 보여 주는 것을 받아들이세요. 특히 코드 블록의 경우, 열린 펜스를 감지해 닫힐 때까지 나머지를 코드로 취급하는 것이 도움이 됩니다. 원칙은 스트리밍 중에는 "이 텍스트는 아직 끝나지 않았다"를 정상 상태로 보고 설계하는 것입니다. 실제로 그러하니까요.
레이아웃 이동과 스크롤을 길들이세요
스트리밍 응답은 자라나고, 자라남은 페이지를 움직입니다. 주의하지 않으면 새 줄이 나타날 때마다 응답 아래 콘텐츠가 들썩이고, 위쪽을 읽으려던 사용자가 아래로 끌려갑니다. 두 가지 습관이 이를 차분하게 유지해 줍니다.
첫째, 공간을 미리 확보하고 무관한 콘텐츠가 리플로우되지 않게 하세요. 스트리밍 텍스트를, 나머지 레이아웃을 예측 불가능하게 밀어내지 않으면서 아래로 자라는 컨테이너에 렌더링하세요. 둘째, 스크롤을 의도적으로 다루세요. 흔하고 기분 좋은 동작은, 사용자가 이미 바닥에 있을 때는 뷰를 바닥에 고정해 라이브 출력을 따라가게 하되, 사용자가 무언가를 읽으려고 위로 스크롤하는 순간 자동 스크롤을 멈춰 사용자와 다투지 않는 것입니다. 사용자가 바닥 근처에 있는지 감지해, 그럴 때만 자동 스크롤하세요.
갱신도 조절하세요. 토큰 하나하나마다 다시 렌더링하면 빠른 스트림에서 브라우저가 버거워할 수 있습니다. 갱신을 적당한 간격으로, 가령 프레임당 몇 번 정도로 묶으면 체감 반응성을 눈에 띄게 해치지 않으면서 UI를 매끄럽게 유지합니다.
상태를 보여 주고, 중단을 처리하세요
스트리밍은 상태를 전달할 자연스러운 기회를 줍니다. 첫 토큰 전에 생성이 시작됐음을 보여 주고, 진행 중임을 분명히 표시하며, 완료되면 그 사실을 표시하세요. 스트리밍 중의 은은한 커서나 "정지" 버튼은 시스템이 살아서 일하고 있음을 사용자에게 알려 줍니다.
그 정지 버튼이 중요합니다. 스트림은 살아 있는 연결이므로, 도중에 취소할 수 있습니다. 길거나 틀린 응답을 끝까지 기다리게 강요하는 대신, 사용자에게 중단할 방법을 주세요. 요청을 취소하고, 지금까지 도착한 텍스트는 유지하며, 제어권을 돌려주는 것이죠. 오류 측면에서도, 스트림은 도중에 실패할 수 있습니다. 끊긴 연결을 복구 가능한 상태로 취급해, 부분 출력을 보존하고, 전부 버리는 대신 재시도를 제공하세요. 취소와 부분 실패를 처음부터 염두에 두고 설계하는 것이, 나중에 끼워 넣는 것보다 훨씬 쉽습니다.
정리
스트리밍은 AI 기능의 체감을 끌어올리는 가장 저렴한 큰 업그레이드입니다. 작업은 같지만, 즉시 나타나는 것은 빠르게 읽힙니다. 청크를 루프로 읽고 항상 누적된 텍스트를 렌더링하세요. 원시 델타는 절대 안 됩니다. 그래야 형식이 온전하게 유지됩니다. 모든 중간 상태는 미완성이니 관대하게 파싱하고, 읽기가 편하도록 레이아웃 이동과 자동 스크롤을 길들이며, 매끄러움을 위해 갱신을 조절하세요. 마지막으로, 취소와 부분 실패를 일급 상태로 다루세요. 이것들을 제대로 해내면 느린 모델도 반응 빠르게 느껴집니다. 그리고 그것이 사용자가 실제로 판단하는 것의 대부분입니다.
