Case Study · 15

RAG 챗봇 SSE 스트리밍 파이프라인 + Pending 지연 구조 5일 만에 재설계

RAG 응답의 긴 첫 토큰 지연과 라우트 이탈 시 응답 손실 문제를, 6단계 SSE 이벤트 + 라우트 밖에서 살아 있는 글로벌 StreamManager로 풀고, 1차 출시 5일 뒤 Context+Reducer+localStorage 3축에 흩어진 상태를 React Query 캐시 단일 소스로 collapse해 체감 pending 지연을 다시 줄였습니다. (팀 안에서 SSE 스트리밍 + 상태 수명 영역 담당, 백엔드는 다른 분 코드 위에 SSE 엔드포인트 등을 AI agent와 확장)

Headline Outcome
235LOC235LOC

1차 출시 5일 뒤 재설계로 줄인 상태 레이어 라인 수

Product
CBR
Role
Frontend Engineer (팀 · BE 일부 확장)
Period
2025.07 — 2025.08
Stack
Next.js 15 React 19 TanStack Query 5 FastAPI
Implementation AI-paired (Claude agent)
  • SSE 이벤트 타입 6종(`start` / `status` / `delta` / `progress` / `complete` / `error`) + `event: close` 종료 시그널. 매 10 청크마다 progress 발행.
  • 라우트 이탈에도 응답 손실 0건. `chatId → { AbortController, Promise }` 글로벌 StreamManager가 in-flight Promise를 `ensureStreamCompletion`으로 await해 캐시에 정답 도달.
  • 1차 출시 5일 뒤 Context+Reducer+localStorage 3축(−235 LOC)을 React Query 캐시로 단일화 — "UI 상태 = 서버 캐시 상태" 원칙 사후 적용.
  • 재시도 최대 3회 / 지수 백오프 1·2·4s / 5분 타임아웃 / 15초 안전 타임아웃으로 좀비 pending 상태 방어.
  • 서버 측 message_id를 `start` 이벤트로 선전송해 클라이언트 임시 ID ↔ 실제 ID 매핑을 단방향으로 해결.

배경

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 상태 = 서버 캐시 상태” 원칙을 처음에 못 보고 출시 후에 깨달은 게 사실이지만, 그걸 빨리 인정하고 자르는 게 결과적으로 더 빠른 길이었습니다.