
깜빡임 없이 실시간 리포트를 스트리밍하는 방법
이제 회의가 끝나면 GeekBye의 요약은 스피너를 멍하니 바라보게 하는 대신 실시간으로 채워집니다. 스트리밍 UI가 불안하게 떨리는 대신 차분하게 느껴지도록 만들려면 깜빡임을 두 번 해결해야 했습니다 — 한 번은 구조화된 필드를 위해, 또 한 번은 markdown을 위해. 그리고 두 번째 해결책은 우리가 이미 가지고 있던 렌더러였습니다.
이번 릴리스 이전에는 GeekBye 회의를 끝낸다는 것이 스피너를 바라보는 일을 뜻했습니다. 앱은 전체 대화 기록을 분석하러 보내고, 요약 전체 — 점수, 핵심 포인트, 실행 항목, 그 모든 것 — 를 기다린 뒤 한꺼번에 화면에 떨궜습니다. 작동은 했지만 느리게 느껴졌습니다. 작업이 진행되는 동안 당신은 아무것도 없는 화면을 바라보고 있었으니까요.
GeekBye v1.6.13~v1.6.15는 그것을, 생성되는 대로 필드 단위로 실시간으로 흘러 들어오는 리포트로 바꿨습니다. 흥미로운 부분은 스트리밍 자체가 아닙니다 — 스트리밍이 불안하게 보이지 않도록 우리가 해야 했던 모든 것입니다. 차분한 스트리밍과 덜컹거리는 스트리밍은 실행 방식만 아주 다를 뿐 같은 기능입니다.
깜빡임, 그 하나: 모든 청크가 React를 뒤흔들게 두지 마라
요약은 하나의 덩어리가 아닙니다 — 백엔드가 server-sent events로 하나씩 내보내는 구조화된 필드(전체 점수, 핵심 포인트, 실행 항목 등)입니다. 그것을 그리는 순진한 방법은 이벤트가 도착할 때마다 React state를 갱신하는 것입니다.
그렇게 하면 깜빡임이 생깁니다. 각 필드의 도착은 저마다의 리렌더링을 촉발하고, "비어 있음"을 보여주던 컴포넌트가 마운트되었다가 언마운트되고 다시 "로드됨"으로 리마운트됩니다. 레이아웃은 패킷마다 움찔거립니다. 리포트는 가능한 한 최악의 방식으로 당신 눈앞에서 스스로 조립됩니다 — 눈에 띄게, 신경질적으로.
해결책은 두 부분으로 된 규율입니다. 첫째, 들어오는 필드를 ref — 렌더링을 촉발하지 않는 가변 홀더 — 에 누적하고, 청크당 한 번씩 새 복사본으로 React state에 게시합니다. 그러면 트리는 모든 미세 이벤트마다가 아니라 의도적으로 갱신됩니다. 둘째, 스트리밍 중이든 완료됐든, 아직 도착하지 않은 필드에는 인라인 플레이스홀더를 두고 처음부터 끝까지 하나의 안정된 컴포넌트 트리를 그립니다:
// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })
// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>
점수를 보여주는 컴포넌트는 절대 언마운트되지 않습니다 — 숫자가 도착할 때까지 그저 … 를 보여주다가 숫자를 보여줍니다. 아무것도 움찔거리지 않습니다. 그리고 완전히 조립된 객체는 마지막에 로컬 데이터베이스로 캐시되므로, 스트리밍 UI와 영속 저장소는 서로 다투지 않고 공존합니다.
깜빡임, 그 둘: 우리가 이미 가지고 있던 렌더러
두 번째 깜빡임은 더 미묘하며 markdown 자체 안에 삽니다. 리포트는 풍부한 markdown — 제목, 굵은 글씨, 리스트, 코드 블록 — 을 그립니다. 하지만 스트림 도중에 도착하는 markdown은, 어느 한순간을 보면 절반만 완성된 상태입니다. 지금까지 불릿이 하나뿐인 리스트. 열렸지만 닫히지 않은 코드 펜스. 아직 짝이 없는 굵은 글씨 표시.
전통적인 markdown 렌더러는 토큰마다 전체 문자열을 재파싱하며, 그 절반만 완성된 상태는 매번 다른 것으로 파싱됩니다 — 그래서 리스트가 깜빡이고, 코드 블록이 열렸다 닫혔다 번쩍이며, 토큰이 완성될 때마다 레이아웃이 튑니다. 한 겹 아래에서 벌어지는, 앞서와 똑같은 신경질적인 조립입니다.
해결책은 거의 민망할 만큼 가까이 있었습니다: 우리는 이미 라이브 AI 채팅에 스트리밍 인식 markdown 렌더러를 쓰고 있었습니다. 불완전한 토큰을 견디도록 만들어진 렌더러 — 매번 처음부터 재파싱하는 대신, 절반만 완성된 markdown을 안정적으로 그리고 토큰이 완성될 때에만 자리를 잡게 하는 렌더러였습니다. 리포트는 그저 같은 것을 쓰기만 하면 됐습니다. 우리는 바로 이 문제를 몇 달 전 채팅에서 해결해 두었던 것입니다. 리포트의 "해결책"이란 이미 그 도구를 가지고 있음을 알아차리고 그것을 두 번째 표면으로 향하게 하는 것이었습니다. 재구축보다 재사용이 이겼습니다.
덤: 서식 있는 복사는 클립보드 형식의 문제다
리포트가 보기 좋아지자 사람들은 그것을 붙여넣고 싶어 했습니다 — 문서에, 이메일에 — 그러면서 서식과 스크린샷을 그대로 유지한 채로요. 본능적인 발상은 리포트를 markdown 문자열로 직렬화하고 대상이 그것을 그려주기를 바라는 것입니다. 대개는 그러지 않습니다.
진짜 답은, 클립보드가 여러 표현을 동시에 담을 수 있다는 것입니다. 그래서 우리는 두 가지를 써 넣습니다: 서식이 그대로 살아 있고 스크린샷이 인라인 이미지로 삽입된 HTML 버전과, HTML을 받지 않는 대상을 위한 플레인 텍스트 폴백입니다. 서식 있는 에디터에 붙여넣으면 그림이 포함된 서식 있는 리포트가 나오고, 플레인 텍스트 상자에 붙여넣으면 깔끔한 텍스트가 나옵니다. "서식 있는 복사"는 결코 직렬화 문제가 아니었습니다 — 그것은 올바른 클립보드 형식을 고르는 문제였습니다.
같은 릴리스는 또한 리포트에 채팅 스타일의 Me / Them 라벨을 주어 두 화자를 서로 반대편으로 정렬했고, 덕분에 대화 기록은 본래 그것이 그러했던 대화처럼 읽힙니다. 그리고 당신 자신의 스크린샷도 거기에 맞춰 자기 편으로 옮겼습니다. (이 기간의 한 릴리스, v1.6.14는 순수한 리빌드였습니다 — 들려줄 이야기가 없고, 그렇게 솔직히 말해 두는 게 옳습니다.)
스트리밍 UI가 가르쳐 준 세 가지
- 스트리밍되는 필드는 버퍼링하고, 청크당 한 번씩만 게시하라. 모든 server-sent event가 저마다의 state 갱신을 몰아가게 두는 것이 바로 깜빡임이다. ref 누적기에 청크당 단일 게시를 더해, 절대 언마운트되지 않는 트리로 흘려보내면 신경질적인 조립이 차분한 채워짐으로 바뀐다.
- 스트리밍되는 것은 무엇이든 스트리밍 인식 렌더러가 필요하다. 토큰마다 전체 문자열을 재파싱하는 렌더러는 절반만 완성된 markdown에서 깜빡인다. 불완전한 입력을 위해 만들어진 것을 써라 — 그리고 다른 표면을 위해 이미 하나 가지고 있다면, 두 번째를 만들기 전에 재사용하라.
- 서식 있는 복사는 직렬화가 아니라 클립보드 형식에 관한 것이다. 이미지를 삽입한
text/html그리고text/plain폴백을 써 넣어라. 클립보드는 둘 다 실어 나르도록 설계되었다. 그것을 써라.
이것은 GeekBye v2가 되는 신뢰성과 다듬기 이야기의 다섯 번째 장입니다. 앞 장은 이어폰 버그(v1.6.12)를 보십시오. 같은 server-sent-events 아이디어가 이후 하나의 온전한 폴백 전송로가 된 이야기는 방화벽이 WebSocket을 막을 때의 실시간 문자 변환(v2.0.8)을 보십시오. 그리고 전체 흐름에 대해서는 소프트웨어를 완벽하게 출시하는 일의 해부학을 보십시오.