배경
RAG 챗봇의 응답은 LLM 호출 · 벡터 검색 · 리랭크 · 이력 조회 · 키워드/타이틀 생성을 모두 거치기에 첫 토큰까지의 공백이 길었습니다. 단발 POST 응답으로는 사용자가 빈 화면을 7~10초 응시해야 했고, 사용자가 채팅 상세에서 다른 탭으로 이동하면 응답이 끊겨 캐시에 도달하지 못했습니다.
접근
서버에서 start → llm_ready → context_ready(문서 N건) → streaming → delta×N → complete/close 6단계 SSE 이벤트를 정의했습니다. 청크 10개마다 progress 이벤트를 같이 흘려보내, 프런트가 “검색 완료 → 답변 생성 시작” 같은 단계별 UX를 그릴 수 있게 했습니다.
클라이언트는 ReadableStream 리더 + 라인 버퍼링 파서, 5분 타임아웃, 1·2·4초 지수 백오프 재시도를 묶었습니다. 핵심은 chatId → { AbortController, Promise } 맵을 가진 글로벌 StreamManager — 컴포넌트가 unmount되어도 in-flight fetch는 살아 있고, 사용자가 다른 채팅으로 돌아오면 ensureStreamCompletion으로 진행 중 Promise를 await해 캐시에 정답 도달.
AbortController 수명을 컴포넌트가 아닌 전역 매니저에 둔 게 핵심 결정이었습니다. 라우트 변경 ≠ 요청 취소, 사용자 의도 변경 = 요청 취소.
1차 출시 후 5일째, 자기 코드를 다시 잘랐습니다. StreamingContext(52 LOC) + streamingReducer(109 LOC) + streamingStorage(74 LOC) — 총 −235 LOC를 삭제하고, 훅을 +278 / −291로 재작성. isPending · isStreaming · messageId 같은 상태가 React Query 캐시와 이중 관리되며 첫 토큰 표시가 늦어지는 게 보였고, 캐시 mutation을 단일 진실로 삼고 보조 레이어를 제거하니 체감 pending이 줄었습니다.
결과
- SSE 이벤트 타입 6종 +
event: close종료 시그널, 10 청크마다 progress 발행. - 라우트 이탈 시 응답 손실 0건 — 글로벌 StreamManager +
ensureStreamCompletion. - 1차 출시 5일 뒤 재설계로 보조 상태 레이어 −235 LOC 삭제, 훅 +278 / −291 재작성.
- 좀비 pending 가드 — 재시도 3회 / 지수 백오프 1·2·4s / 5분 타임아웃 / 15초 안전 타임아웃.
- 백엔드 측은 다른 분의 FastAPI 코드 위에
async for chunk in llm.astream(...)기반StreamingResponse를 AI agent와 함께 추가,X-Accel-Buffering: no·Cache-Control: no-transform등 프록시 버퍼링 회피 헤더 세팅 — 클라이언트와 같은 결로 일관성 유지.
배운 점
상태 수명은 컴포넌트가 아니라 도메인이 결정한다는 게 가장 큰 교훈이었습니다. SSE 같은 장시간 요청은 컴포넌트 unmount보다 “사용자가 의도를 바꿨는가”가 진짜 종료 신호입니다. AbortController를 컴포넌트 안에 두면 라우트 이동만으로도 응답이 죽고, 전역에 두면 사용자가 진짜 취소할 때만 죽습니다.
또 한 가지: 1차 설계가 완벽하지 않다는 걸 인정하고 5일 만에 다시 자르는 것. Context+Reducer+localStorage는 React 생태계에서 흔한 패턴이지만, 서버 캐시가 이미 존재하는 환경에서는 이중 관리가 됩니다. “UI 상태 = 서버 캐시 상태” 원칙을 처음에 못 보고 출시 후에 깨달은 게 사실이지만, 그걸 빨리 인정하고 자르는 게 결과적으로 더 빠른 길이었습니다.