공고 추천 — 로딩 개선(캐시·스트리밍·후보수)
2026-07-29 · 웹으로 실제 써보면서 나온 피드백 3건을 순서대로 고침 + 실전에서 터진
4번째 문제(연결이 조용히 끊기는 것)까지 추가로 해결.
브랜치: 백엔드 fix/recommendations-streaming(push 완료) · 프론트 feat/sidebar-chat-ui(push 완료).
문제 ① 사이드바 탭 옮길 때마다 재요청됨
증상: 공고 추천을 한 번 받고 대화 탭 갔다가 돌아오면 또 처음부터 로딩.
원인: 추천 상태가 RecommendationsPage 컴포넌트 안에만 있었음. 사이드바로 다른
화면 가면 라우트가 바뀌어 이 컴포넌트가 언마운트→리마운트되고, useEffect가 다시
돌면서 프로필이 그대로여도 API를 새로 부름.
수정: lib/recommendations-context.tsx 신설 — 상태를 앱 셸 레벨로 끌어올림
(profile-context·active-docs-context와 같은 패턴). 캐시 키는 profileText 자체 —
같으면 재요청 안 하고, 바뀌면 자동으로 새로 부름. sessionStorage에도 저장해서
새로고침(F5)해도 유지(탭 닫으면 사라짐 — 영구 저장 아님).
문제 ② 로딩 중 프로필을 다시 만지면 꼬임
원인: “이미 요청 중이면 또 안 보낸다”는 가드를 state(loading)로 체크했는데,
React가 개발 모드에서 컴포넌트를 일부러 두 번 실행하는 StrictMode 때문에, state
반영이 늦어지는 찰나에 두 번째 실행이 “아직 요청 안 나갔네?”로 착각 → 요청이
두 번 나가고 첫 번째가 취소되면서 화면이 영원히 로딩 상태로 남는 레이스가 생김.
수정: state 대신 ref(inFlightForRef, 즉시 반영됨)로 가드 교체. 이 타이밍
문제 자체가 원천적으로 안 생기게 함.
교훈: “지금 요청 중인가?”처럼 동기적으로 정확해야 하는 판단은 state가 아니라 ref로 해야 한다. state는 배치·비동기라 “지금 이 순간의 진실”을 못 보장한다.
문제 ③ 로딩 중 진행상황이 전혀 안 보임
회원님 피드백: “클로드나 제미나이처럼 ~하는 중으로 계속 진행상황을 보고해야 할 것 같다” — 지어낸 퍼센트가 아니라 진짜 지금 뭘 하고 있는지를 보여달라는 요청.
백엔드: recommend()가 최종 결과만 반환하던 걸, recommend_progress() 제너레이터로
바꿔서 단계마다 이벤트를 낸다.
{"type": "candidates", "count": N} # 적합도 후보 추림 완료
{"type": "matching", "done": i, "total": G} # 배치 그룹 i/G 완료 (실시간, 그룹 끝날 때마다)
{"type": "result", "items": [...]} # 최종 결과pool.map() 대신 as_completed()를 쓴 게 핵심 — map은 다 끝나야 결과를 주지만,
as_completed는 그룹이 끝나는 순서대로 즉시 이벤트를 낼 수 있다.
API 계층(src/api/eligibility.py)에 get_recommendations_progress() 추가 —
“result” 이벤트를 받으면 카드·키워드칩 보강까지 마치고 “done”으로 바꿔 냄
(enriching 이벤트로 그 사이도 신호를 줌). 도중 예외는 {"type": "error", ...}로
흘림 — 스트리밍 응답은 이미 200 헤더가 나간 뒤라 HTTP 상태코드로 못 바꾼다.
새 엔드포인트: POST /recommendations/stream — NDJSON(줄바꿈 구분 JSON)으로 응답.
기존 POST /recommendations(한 번에 반환)는 그대로 유지 — 진행상황 필요없는
호출부(스크립트 등)를 위해 남겨둠, 내부적으로 recommend()가
recommend_progress()를 소진해서 마지막 값만 리턴하게 재구성.
프론트: fetch + ReadableStream으로 직접 읽어서 줄 단위 파싱
(streamRecommendations(), src/lib/api.ts). SSE(EventSource) 대신 이 방식을 쓴
이유 — EventSource는 GET만 지원해서 프로필 텍스트(POST 바디)를 못 보낸다.
화면엔 3단계 체크리스트로 표시:
✓ 적합도 높은 공고 8건을 후보로 선별했어요
⟳ 참가자격을 하나씩 대조하는 중 (1/2)
· 결과 정리 중
완료=체크, 진행중=스피너, 대기=점.
검증: curl로 실시간 도착 확인 — candidates(1초) → matching 1/2(79초 뒤) →
matching 2/2(12초 뒤) → done(4초 뒤). cloudflared 터널을 거쳐도 버퍼링 없이
그대로 전달되는 것까지 확인함(프록시가 스트림을 통째로 모았다 보내면 스트리밍
의미가 없어지는데, 그런 문제 없음).
겸사겸사 — 후보 개수 15 → 8
의도: 시간 단축. RECOMMEND_CANDIDATE_K 8로 변경.
실측 결과가 예상과 달랐음: 배치 그룹들이 병렬로 도는 구조라, 전체 소요시간은 “그룹 개수의 합”이 아니라 **“제일 느린 그룹 하나”**로 결정됨.
15건 → 그룹 4개 (병렬) 전체시간 = 4개 중 가장 느린 것
8건 → 그룹 2개 (병렬) 전체시간 = 2개 중 가장 느린 것
문서마다 참가자격 요건 개수 편차가 커서(5개~40개), 요건 많은 문서 하나가 그룹에 끼면 그 그룹이 오래 걸림. 그룹이 4개일 땐 “느린 놈 하나 있어도 나머지 3개가 빠르게 끝나줄” 여유가 있었는데, 그룹이 2개뿐이면 그 여유가 없어짐 — 이론상으로는 표본(그룹)이 적을수록 최댓값의 기댓값도 낮아져야 정상이지만, 실측 시 매번 다른 프로필 텍스트로 테스트해서 뽑히는 문서 자체가 매번 달랐던 게 커서, “8건이 15건 보다 느렸다”는 건 구조적 문제라기보다 어쩌다 무거운 문서가 걸렸는지의 운 쪽에 더 가까움. 공정 비교(같은 프로필로 8 vs 15)는 아직 안 해봄.
확실히 좋아진 건 비용 — 처리하는 문서 자체가 줄어서 총 토큰 사용량(=비용)은 확실히 감소. 체감 속도는 케이스마다 다를 수 있지만, 이제 진행상황이 보이니 “멈춘 건지 도는 건지 모르는” 답답함 자체는 해결됨.
문제 ④ (실전 발생) — 연결이 조용히 끊겨서 20분 넘게 멈춤
스트리밍까지 다 붙이고 실제로 써보니, 화면이 “적합도 높은 공고 8건을 후보로 선별했어요”에서 그대로 20분 넘게 멈춘 사례 발생. 회원님이 “존나 불안정하다”고 직접 겪음.
진단: 그 순간 서버가 살아있는지부터 확인 — 완전히 새 요청을 즉시 날려보니 1초 만에 정상 응답. 서버는 멀쩡했다. 즉 문제는 그 특정 요청의 연결 자체.
원인 추정: 배치 그룹 하나가 reasoning 예산 재시도까지 겹치면 몇 분씩 걸릴 수
있는데, 그 사이 아무 이벤트도 안 나가면 cloudflared 같은 터널이 “죽은 연결”로
보고 조용히 끊어버림. fetch의 ReadableStream은 이런 상황을 에러로 알려주지
않아서, 화면은 마지막으로 받은 이벤트 상태 그대로 영원히 멈춰있게 됨.
곁들여 발견한 별개 문제: 이 진단 도중 백엔드를 재시작하려다 GPU 메모리 부족 (CUDA OOM)으로 못 뜨는 것도 발견 — 공유 VM이라 팀원이 GPU 19GB를 쓰고 있었고, 제 예전 좀비 프로세스도 안 죽고 GPU를 물고 있었음. 이건 코드 문제가 아니라 VM 자원 경합 문제라 별개로 처리(좀비 프로세스 정리로 임시 해결, 근본 해결은 팀 차원의 GPU 사용 조율 필요).
수정:
- 백엔드: 8초마다
{"type": "heartbeat"}이벤트를 내보냄.as_completed()대신wait(timeout=8, return_when=FIRST_COMPLETED)로 폴링하며, 아무것도 안 끝났으면 heartbeat를 끼워 넣고 계속 기다림 — 연결이 오래 조용해지는 걸 원천 차단. - 프론트: 이벤트(heartbeat 포함)가 30초 넘게 하나도 안 오면 스스로 포기하고 “서버 응답이 끊긴 것 같아요. 다시 시도해주세요” 에러로 전환. 최악의 경우에도 30초 안에 재시도 버튼이 뜨게 됨 — 예전처럼 무한정 멈춰있는 일은 없어짐.
교훈: 오래 걸리는 스트리밍 응답은 “진행 이벤트가 온다”만으로는 부족하다. 주기적으로 뭔가 나가야 한다(내용이 없어도) — 그래야 중간의 프록시·터널이 죽은 연결로 오판하지 않는다. 그리고 클라이언트도 “마지막으로 뭔가 받은 지 얼마나 됐는지”를 스스로 감시해야, 서버 없이도 무한 대기를 끊어낼 수 있다.
관련
- 참가자격 매칭 자체의 설계는 참가자격 매칭 — 기능 설계
- 배치 매칭(4건씩 묶기)의 등장 배경은 그 노트의 “비용·속도 설계” 절 참고
- 대화 자체의 히스토리 기능은 대화 히스토리 — 세션 목록·삭제