From 63036a434aa7b77d20590467feb31f986eb412ed Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 21:20:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B6=84=EC=84=9D=20SSE?= =?UTF-8?q?=20=EB=8B=A8=EC=A0=88=20=EB=B0=B0=EB=84=88=20+=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20=ED=8F=B4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 상태 SSE 가 끊겨도 무통보였고 목록 갱신 경로가 0개라 카드가 '분석 중'으로 영구 고착됐다 (focus refetch 전역 비활성 + 폴링 없음) - useEventStream 이 연결 상태(connecting|open|closed)를 반환 — 끊긴 뒤 재시도 중에도 closed 를 유지해 배너 깜빡임을 방지, 언마운트 후 늦은 토큰 실패가 새 effect 를 오염시키지 않게 cancelled 가드 통일 - ConnectionBanner 를 shared/ui 로 승격(문구 prop 화)하고 워크스페이스는 closed 일 때만 표시, 면접 화면은 기존 문구로 재사용 - workspaceStreamHealth store 신설 — 스트림이 죽은 동안만 이력서·레포·자소서 목록 쿼리가 5s 폴백 폴링 (api-conventions §9), 워크스페이스 밖에선 idle - 상태 전이·언마운트 레이스·폴링 스위치·배너 테스트 8케이스 --- .../cover-letter/model/useCoverLetters.ts | 4 + .../interview/ui/live/ConnectionBanner.tsx | 10 --- .../interview/ui/live/InterviewStage.tsx | 8 +- .../features/repo/model/useRepositories.ts | 4 + .../src/features/resume/model/useResumes.ts | 5 ++ .../model/useWorkspaceAnalysisStream.ts | 20 ++++- .../src/pages/Workspace/ui/WorkspacePage.tsx | 12 ++- frontend/src/shared/hooks/index.ts | 7 ++ .../hooks/useEventStream.status.test.tsx | 90 +++++++++++++++++++ frontend/src/shared/hooks/useEventStream.ts | 26 +++++- .../hooks/workspaceStreamHealth.test.tsx | 31 +++++++ .../src/shared/hooks/workspaceStreamHealth.ts | 38 ++++++++ .../ConnectionBanner.test.tsx | 22 +++++ .../ui/ConnectionBanner/ConnectionBanner.tsx | 19 ++++ .../src/shared/ui/ConnectionBanner/index.ts | 1 + frontend/src/shared/ui/index.ts | 1 + 16 files changed, 277 insertions(+), 21 deletions(-) delete mode 100644 frontend/src/features/interview/ui/live/ConnectionBanner.tsx create mode 100644 frontend/src/shared/hooks/useEventStream.status.test.tsx create mode 100644 frontend/src/shared/hooks/workspaceStreamHealth.test.tsx create mode 100644 frontend/src/shared/hooks/workspaceStreamHealth.ts create mode 100644 frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx create mode 100644 frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx create mode 100644 frontend/src/shared/ui/ConnectionBanner/index.ts diff --git a/frontend/src/features/cover-letter/model/useCoverLetters.ts b/frontend/src/features/cover-letter/model/useCoverLetters.ts index cc99c56f..c4202e8b 100644 --- a/frontend/src/features/cover-letter/model/useCoverLetters.ts +++ b/frontend/src/features/cover-letter/model/useCoverLetters.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAnalysisFallbackPolling } from '@/shared/hooks' import { isApiError } from '@/shared/api' import { toast } from '@/shared/ui' import { @@ -16,9 +17,12 @@ export const coverLetterKeys = { } export function useCoverLetters() { + // 분석 SSE 가 죽은 동안만 5s 폴링 (useResumes 와 동일한 이유). + const fallbackPolling = useAnalysisFallbackPolling() return useQuery({ queryKey: coverLetterKeys.all, queryFn: fetchCoverLetters, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/features/interview/ui/live/ConnectionBanner.tsx b/frontend/src/features/interview/ui/live/ConnectionBanner.tsx deleted file mode 100644 index 3116730b..00000000 --- a/frontend/src/features/interview/ui/live/ConnectionBanner.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ConnectionStatus } from '../../model/useLiveInterview' - -export function ConnectionBanner({ connection }: { connection: ConnectionStatus }) { - if (connection === 'open') return null - return ( -
- {connection === 'connecting' ? '면접 서버에 연결 중입니다…' : '연결이 끊겨 재연결 중입니다…'} -
- ) -} diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index 37799488..ad932927 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -7,7 +7,7 @@ import { isQuestion, isTranscribing, sessionProgress } from '@/domain/session' import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' import type { DeliveryMode } from '../../model/useDeliveryMode' -import { ConnectionBanner } from './ConnectionBanner' +import { ConnectionBanner } from '@/shared/ui' import { SmallScreenNotice } from './SmallScreenNotice' import { AnswerComposer } from './AnswerComposer' import { StageQuestion } from './StageQuestion' @@ -150,7 +150,11 @@ export function InterviewStage({ - +
({ queryKey: repoKeys.registered, queryFn: fetchRegisteredRepositories, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts index 98627c09..e04e7126 100644 --- a/frontend/src/features/resume/model/useResumes.ts +++ b/frontend/src/features/resume/model/useResumes.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAnalysisFallbackPolling } from '@/shared/hooks' import { isApiError } from '@/shared/api' import { toast } from '@/shared/ui' import { @@ -17,9 +18,13 @@ export const resumeKeys = { } export function useResumes() { + // 분석 SSE 가 죽은 동안만 5s 폴링 — 상태 변화(ANALYZING→ANALYZED)가 SSE 로만 오기 때문에 + // 폴백이 없으면 카드가 "분석 중"으로 영구 고착된다. + const fallbackPolling = useAnalysisFallbackPolling() return useQuery({ queryKey: resumeKeys.all, queryFn: fetchResumes, + refetchInterval: fallbackPolling, }) } diff --git a/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts index 1d328754..d6e076a8 100644 --- a/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts +++ b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts @@ -1,10 +1,11 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' import { createStreamToken } from '@/features/auth' import { resumeKeys } from '@/features/resume' import { repoKeys } from '@/features/repo' import { documentKeys } from '@/features/analysis' -import { analysisProgress, useEventStream } from '@/shared/hooks' +import { analysisProgress, useEventStream, workspaceStreamHealth } from '@/shared/hooks' +import type { StreamConnectionStatus } from '@/shared/hooks' // RealTime SSE data 봉투: { data: , traceId }. type StreamData = { data?: T; traceId?: string | null } @@ -23,7 +24,8 @@ function unwrap(raw: unknown): T | undefined { // 이력서·레포·문서 쿼리를 무효화 → 화면이 자동으로 최신 상태로 갱신된다. // 추가로 ANALYSIS_PROGRESS(단계별 진행)는 쿼리 무효화 없이 진행 store 만 갱신한다. -export function useWorkspaceAnalysisStream() { +// 반환값: 연결 상태 — WorkspacePage 가 단절 배너를 그리는 데 사용. +export function useWorkspaceAnalysisStream(): StreamConnectionStatus { const queryClient = useQueryClient() const getToken = useCallback(() => createStreamToken(), []) @@ -52,7 +54,7 @@ export function useWorkspaceAnalysisStream() { }) }, []) - useEventStream({ + const status = useEventStream({ path: '/realtime/stream/me', getToken, handlers: { @@ -61,4 +63,14 @@ export function useWorkspaceAnalysisStream() { ANALYSIS_PROGRESS: onProgress, }, }) + + // 목록 쿼리의 폴백 폴링 스위치. 'closed' 에서만 down — 최초 'connecting' 구간에 + // 폴링을 켜면 정상 부팅마다 불필요한 요청이 나간다. 언마운트 시 idle 로 되돌려 + // 워크스페이스 밖에서 폴링이 돌지 않게 한다. + useEffect(() => { + workspaceStreamHealth.set(status === 'closed' ? 'down' : 'up') + }, [status]) + useEffect(() => () => workspaceStreamHealth.set('idle'), []) + + return status } diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 227ffde2..1560ecbc 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,6 +1,6 @@ import { useLocation } from 'react-router-dom' import { useAuth } from '@/features/auth' -import { PageHeader } from '@/shared/ui' +import { ConnectionBanner, PageHeader } from '@/shared/ui' import { WorkspaceSidebar } from '@/widgets/workspace-sidebar' import { useWorkspaceAnalysisStream } from '../model/useWorkspaceAnalysisStream' import { HomeView } from './HomeView' @@ -25,7 +25,7 @@ function resolveView(pathname: string): View { export default function WorkspacePage() { // 분석 상태 실시간 구독 (SSE) — 어떤 뷰에 있든 완료 시 목록이 자동 갱신된다. - useWorkspaceAnalysisStream() + const streamStatus = useWorkspaceAnalysisStream() const { user } = useAuth() const { pathname } = useLocation() @@ -73,6 +73,14 @@ export default function WorkspacePage() {
+ {/* 최초 연결(connecting)은 정상 부팅 구간이라 조용히 두고, 끊김(closed)만 알린다. + 이 동안 목록은 5s 폴백 폴링으로 갱신된다 (useAnalysisFallbackPolling). */} + {streamStatus === 'closed' && ( + + )}
void) | null = null + onerror: (() => void) | null = null + url: string + constructor(url: string) { + this.url = url + FakeES.last = this + } + addEventListener() {} + close() {} +} + +vi.stubGlobal('EventSource', FakeES) + +function setup(getToken: () => Promise = async () => 'tok') { + return renderHook(() => + useEventStream({ + path: '/realtime/stream/me', + getToken, + handlers: { DOC_STATE: () => {} }, + }), + ) +} + +beforeEach(() => { + FakeES.last = null +}) + +describe('useEventStream 연결 상태', () => { + it('최초엔 connecting, 열리면 open', async () => { + const { result } = setup() + expect(result.current).toBe('connecting') + await waitFor(() => expect(FakeES.last).not.toBeNull()) + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + }) + + it('끊기면 closed 로 승격되고, 재연결이 성사되기 전까지 유지된다', async () => { + vi.useFakeTimers() + try { + const { result } = setup() + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + + act(() => FakeES.last?.onerror?.()) + expect(result.current).toBe('closed') + + // 백오프 재시도 대기 중에도 connecting 으로 되돌아가지 않는다 (배너 깜빡임 방지). + await act(async () => { + await vi.advanceTimersByTimeAsync(1_100) + }) + expect(result.current).toBe('closed') + + // 재연결 성사 시에만 open 복귀. + act(() => FakeES.last?.onopen?.()) + expect(result.current).toBe('open') + } finally { + vi.useRealTimers() + } + }) + + it('언마운트 후 늦게 실패한 토큰 발급은 재연결을 스케줄하지 않는다', async () => { + vi.useFakeTimers() + try { + let reject: ((e: Error) => void) | undefined + const { unmount } = setup( + () => new Promise((_res, rej) => (reject = rej)), + ) + unmount() + + await act(async () => { + reject?.(new Error('late failure')) + await vi.advanceTimersByTimeAsync(60_000) + }) + // 버려진 effect 가 재연결 타이머를 만들었다면 새 EventSource 가 생겼을 것. + expect(FakeES.last).toBeNull() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/frontend/src/shared/hooks/useEventStream.ts b/frontend/src/shared/hooks/useEventStream.ts index db2217e0..257c8b50 100644 --- a/frontend/src/shared/hooks/useEventStream.ts +++ b/frontend/src/shared/hooks/useEventStream.ts @@ -1,9 +1,15 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { env } from '@/shared/config/env' type EventHandler = (data: unknown) => void type EventHandlers = Record +// 'connecting' = 최초 연결 시도 중(정상 부팅 구간 — 배너 등 경고 UI 로 다루지 말 것), +// 'open' = 수신 중, 'closed' = 끊겨서 백오프 재연결 중(이벤트 유실 가능 구간). +// 한 번 끊긴 뒤의 재시도는 'connecting' 으로 되돌리지 않는다 — 재연결이 성사될 때까지 +// 'closed' 를 유지해 소비자가 "지연될 수 있음" 상태를 깜빡임 없이 표시할 수 있게 한다. +export type StreamConnectionStatus = 'connecting' | 'open' | 'closed' + type UseEventStreamOptions = { /** SSE_BASE_URL 에 붙일 경로. null 이면 연결하지 않음. */ path: string | null @@ -23,9 +29,10 @@ export function useEventStream({ getToken, handlers, enabled = true, -}: UseEventStreamOptions): void { +}: UseEventStreamOptions): StreamConnectionStatus { const handlersRef = useRef(handlers) const getTokenRef = useRef(getToken) + const [status, setStatus] = useState('connecting') // 렌더 중 ref 쓰기는 금지 → effect 에서 최신 값으로 동기화. useLayoutEffect(() => { @@ -36,6 +43,8 @@ export function useEventStream({ useEffect(() => { if (!enabled || !path) return + // 초기 state 가 'connecting' 이라 마운트 시 리셋이 필요 없다. path/enabled 는 현재 + // 사용처에서 상수라 재연결로 인한 상태 리셋 경로는 두지 않는다(effect 내 동기 setState 금지). let source: EventSource | null = null let reconnectTimer: ReturnType | null = null let attempt = 0 @@ -55,6 +64,11 @@ export function useEventStream({ try { token = await getTokenRef.current() } catch { + // StrictMode 재마운트에서 버려진 effect 의 늦은 reject 가 새 effect 의 상태를 + // 덮어쓰지 않도록, 다른 분기와 동일하게 cancelled 를 먼저 확인한다. + if (cancelled) return + // 첫 시도 실패도 '유실 가능 구간'이므로 closed 로 승격한다. + setStatus('closed') scheduleReconnect() return } @@ -68,11 +82,15 @@ export function useEventStream({ es.onopen = () => { attempt = 0 + setStatus('open') } es.onerror = () => { es.close() if (source === es) source = null - if (!cancelled) scheduleReconnect() + if (!cancelled) { + setStatus('closed') + scheduleReconnect() + } } for (const name of Object.keys(handlersRef.current)) { @@ -98,4 +116,6 @@ export function useEventStream({ source = null } }, [path, enabled]) + + return status } diff --git a/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx b/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx new file mode 100644 index 00000000..bb536c02 --- /dev/null +++ b/frontend/src/shared/hooks/workspaceStreamHealth.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { + workspaceStreamHealth, + useAnalysisFallbackPolling, + useWorkspaceStreamHealth, +} from './workspaceStreamHealth' + +afterEach(() => { + workspaceStreamHealth.set('idle') +}) + +describe('workspaceStreamHealth', () => { + it('스트림이 죽었을 때만 폴백 폴링(5s)을 켠다', () => { + const { result } = renderHook(() => useAnalysisFallbackPolling()) + expect(result.current).toBe(false) + + act(() => workspaceStreamHealth.set('down')) + expect(result.current).toBe(5_000) + + act(() => workspaceStreamHealth.set('up')) + expect(result.current).toBe(false) + }) + + it('idle(워크스페이스 밖)에서는 폴링하지 않는다', () => { + const { result } = renderHook(() => useWorkspaceStreamHealth()) + expect(result.current).toBe('idle') + const polling = renderHook(() => useAnalysisFallbackPolling()) + expect(polling.result.current).toBe(false) + }) +}) diff --git a/frontend/src/shared/hooks/workspaceStreamHealth.ts b/frontend/src/shared/hooks/workspaceStreamHealth.ts new file mode 100644 index 00000000..c0e382d2 --- /dev/null +++ b/frontend/src/shared/hooks/workspaceStreamHealth.ts @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react' + +// 워크스페이스 분석 SSE(/realtime/stream/me)의 건강 상태. +// 'down' 이면 SSE 푸시가 유실될 수 있으므로 목록 쿼리들이 폴백 폴링을 켠다 +// (api-conventions §9: SSE 우선, 끊기면 폴링 fallback). +// 스트림을 소유한 훅(useWorkspaceAnalysisStream)만 set 하고, 언마운트 시 'idle' 로 +// 되돌린다 — 워크스페이스 밖에서는 폴링이 돌지 않아야 한다. +export type WorkspaceStreamHealth = 'idle' | 'up' | 'down' + +let health: WorkspaceStreamHealth = 'idle' +const listeners = new Set<() => void>() + +export const workspaceStreamHealth = { + set(next: WorkspaceStreamHealth): void { + if (health === next) return + health = next + for (const listener of listeners) listener() + }, + get(): WorkspaceStreamHealth { + return health + }, + subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, +} + +export function useWorkspaceStreamHealth(): WorkspaceStreamHealth { + return useSyncExternalStore(workspaceStreamHealth.subscribe, workspaceStreamHealth.get) +} + +// 목록 쿼리용 폴백 폴링 간격 — 스트림이 죽었을 때만 5s (api-conventions §9). +export function useAnalysisFallbackPolling(): number | false { + const health = useWorkspaceStreamHealth() + return health === 'down' ? 5_000 : false +} diff --git a/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx new file mode 100644 index 00000000..5c82790e --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ConnectionBanner } from './ConnectionBanner' + +describe('ConnectionBanner', () => { + it('open 이면 그리지 않는다', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('closed 면 재연결 문구를 alert 로 알린다', () => { + render() + expect(screen.getByRole('alert')).toHaveTextContent('연결이 끊겨 재연결 중입니다…') + }) + + it('문구를 맥락에 맞게 바꿀 수 있다', () => { + render( + , + ) + expect(screen.getByRole('alert')).toHaveTextContent('실시간 연결이 끊겼습니다.') + }) +}) diff --git a/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx new file mode 100644 index 00000000..6b3deca1 --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/ConnectionBanner.tsx @@ -0,0 +1,19 @@ +import type { StreamConnectionStatus } from '@/shared/hooks' + +// 실시간 채널(WS/SSE)의 단절을 알리는 경고 배너. 'open' 이면 그리지 않는다. +export function ConnectionBanner({ + connection, + connectingText = '서버에 연결 중입니다…', + reconnectingText = '연결이 끊겨 재연결 중입니다…', +}: { + connection: StreamConnectionStatus + connectingText?: string + reconnectingText?: string +}) { + if (connection === 'open') return null + return ( +
+ {connection === 'connecting' ? connectingText : reconnectingText} +
+ ) +} diff --git a/frontend/src/shared/ui/ConnectionBanner/index.ts b/frontend/src/shared/ui/ConnectionBanner/index.ts new file mode 100644 index 00000000..90e5983c --- /dev/null +++ b/frontend/src/shared/ui/ConnectionBanner/index.ts @@ -0,0 +1 @@ +export { ConnectionBanner } from './ConnectionBanner' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 33b106d5..abf602a3 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -22,3 +22,4 @@ export { Reveal } from './Reveal' export type { RevealProps } from './Reveal' export { QueryError } from './QueryError' export type { QueryErrorProps } from './QueryError' +export { ConnectionBanner } from './ConnectionBanner' From 6ae2c0764884e00afacc85d3572503ee31d5f874 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 21:20:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(frontend):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=AC=B8=EA=B5=AC=EC=97=90=20TTL=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 progress Map 에 만료가 없어 종료 이벤트(DOC_STATE) 유실 시 '임베딩하는 중…' 문구가 영구 고착되고, 같은 문서를 재분석하면 이전 세션 문구가 되살아났다 - 항목에 updatedAt 을 기록하고 90s 무갱신 시 15s 스위퍼가 걷어낸다 (진행 이벤트는 분석 중 수 초 간격 — 90s 침묵은 유실로 판단) - 스토어가 비면 스위퍼 정지 (set/clear/sweep 모두), 테스트 3케이스 --- .../src/shared/hooks/useAnalysisProgress.ts | 39 ++++++++++++++-- .../hooks/useAnalysisProgress.ttl.test.ts | 45 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts diff --git a/frontend/src/shared/hooks/useAnalysisProgress.ts b/frontend/src/shared/hooks/useAnalysisProgress.ts index 7296cd0b..56c4a11f 100644 --- a/frontend/src/shared/hooks/useAnalysisProgress.ts +++ b/frontend/src/shared/hooks/useAnalysisProgress.ts @@ -10,20 +10,53 @@ export type AnalysisProgress = { const keyOf = (targetType: string, targetId: number) => `${targetType}:${targetId}` -const store = new Map() +// 진행 이벤트는 분석 중 수 초 간격으로 온다. 이 시간 동안 갱신이 없으면 종료 이벤트 +// (DOC_STATE/REPO_STATE)가 유실된 것으로 보고 문구를 걷어낸다 — TTL 이 없으면 +// "임베딩하는 중…" 이 영구 고착되고, 재분석 시 이전 세션 문구가 되살아난다. +const PROGRESS_TTL_MS = 90_000 +const SWEEP_INTERVAL_MS = 15_000 + +type Entry = AnalysisProgress & { updatedAt: number } + +const store = new Map() const listeners = new Set<() => void>() +let sweepTimer: ReturnType | null = null function emitChange(): void { for (const listener of listeners) listener() } +// 스토어가 비어 있지 않은 동안에만 만료 스위퍼를 돌린다. +function sweep(): void { + const cutoff = Date.now() - PROGRESS_TTL_MS + let changed = false + for (const [key, entry] of store) { + if (entry.updatedAt < cutoff) { + store.delete(key) + changed = true + } + } + if (store.size === 0 && sweepTimer !== null) { + clearInterval(sweepTimer) + sweepTimer = null + } + if (changed) emitChange() +} + export const analysisProgress = { set(targetType: string, targetId: number, value: AnalysisProgress): void { - store.set(keyOf(targetType, targetId), value) + store.set(keyOf(targetType, targetId), { ...value, updatedAt: Date.now() }) + if (sweepTimer === null) sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS) emitChange() }, clear(targetType: string, targetId: number): void { - if (store.delete(keyOf(targetType, targetId))) emitChange() + if (store.delete(keyOf(targetType, targetId))) { + if (store.size === 0 && sweepTimer !== null) { + clearInterval(sweepTimer) + sweepTimer = null + } + emitChange() + } }, get(targetType: string, targetId: number): AnalysisProgress | undefined { return store.get(keyOf(targetType, targetId)) diff --git a/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts b/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts new file mode 100644 index 00000000..dd7964c3 --- /dev/null +++ b/frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { analysisProgress } from './useAnalysisProgress' + +afterEach(() => { + analysisProgress.clear('RESUME', 1) + analysisProgress.clear('RESUME', 2) + vi.useRealTimers() +}) + +describe('analysisProgress TTL', () => { + it('갱신이 끊긴 항목은 TTL(90s) 후 걷어낸다 — 종료 이벤트 유실 시 영구 고착 방지', () => { + vi.useFakeTimers() + analysisProgress.set('RESUME', 1, { phase: 'EMBEDDING', message: '임베딩 중…' }) + expect(analysisProgress.get('RESUME', 1)?.message).toBe('임베딩 중…') + + vi.advanceTimersByTime(91_000 + 15_000) + expect(analysisProgress.get('RESUME', 1)).toBeUndefined() + }) + + it('계속 갱신되는 항목은 살아있고, 끊긴 항목만 만료된다', () => { + vi.useFakeTimers() + analysisProgress.set('RESUME', 1, { phase: 'EXTRACTING', message: 'A' }) + analysisProgress.set('RESUME', 2, { phase: 'EXTRACTING', message: 'B' }) + + // 1번만 60초마다 갱신 — 2번은 방치. + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(60_000) + analysisProgress.set('RESUME', 1, { phase: 'SUMMARIZING', message: `A${i}` }) + } + expect(analysisProgress.get('RESUME', 1)).toBeDefined() + expect(analysisProgress.get('RESUME', 2)).toBeUndefined() + }) + + it('구독자는 만료 시점에 알림을 받는다', () => { + vi.useFakeTimers() + const listener = vi.fn() + const unsubscribe = analysisProgress.subscribe(listener) + analysisProgress.set('RESUME', 1, { phase: 'EMBEDDING', message: '임베딩 중…' }) + listener.mockClear() + + vi.advanceTimersByTime(91_000 + 15_000) + expect(listener).toHaveBeenCalled() + unsubscribe() + }) +}) From 5056d865d4ef4a07f929b89f7292e554eae4d8c6 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 21:20:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(frontend):=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B5=AC=ED=98=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - '폴링 fallback' 이 구현된 것처럼 기술돼 있었으나 실제로는 없었다 — 이번에 도입된 단절 시 5s 폴백 폴링·연결 상태 반환으로 갱신 - §10 'WebSocket 미사용' 도 stale — SSE+WS 병행(라이브 면접 WS) 사실로 정정 --- frontend/CLAUDE.md | 4 ++-- frontend/src/shared/CLAUDE.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index f717ebdd..6853042e 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -235,8 +235,8 @@ SEED 팔레트 블록에는 `prefers-color-scheme` 미디어쿼리가 없어서, ## 10. 실시간 이벤트 -- **SSE 단일화** — 양방향 WebSocket 미사용. 모든 서버 → 클라이언트 푸시는 SSE로 처리. -- 구현: `shared/hooks/useEventStream.ts` (자동 재연결 + 폴링 fallback) +- **SSE + WebSocket 병행** — 작업 상태 푸시(분석·피드백)는 SSE, 라이브 면접 메시지는 WS(`features/interview/model/useInterviewSocket.ts`). (루트 CLAUDE.md §8 과 동일) +- 구현: `shared/hooks/useEventStream.ts` — 자동 재연결(지수 백오프) + 연결 상태 반환. 워크스페이스는 단절(closed) 시 배너 표시 + 목록 쿼리 5s 폴백 폴링(`useAnalysisFallbackPolling`) - 미디어 스트림(음성/영상)만 WebRTC: `features/interview/lib/media/` - 이벤트 스펙: [`/docs/event-stream.md`](../docs/event-stream.md) diff --git a/frontend/src/shared/CLAUDE.md b/frontend/src/shared/CLAUDE.md index 594bd28c..08fc350d 100644 --- a/frontend/src/shared/CLAUDE.md +++ b/frontend/src/shared/CLAUDE.md @@ -79,7 +79,8 @@ shared/api/ ## 6. shared/hooks (도메인 비종속만) 현재 구현: -- `useEventStream(url, options)` — SSE 추상화 (재연결, 폴링 fallback) +- `useEventStream({ path, getToken, handlers })` — SSE 추상화 (지수 백오프 재연결, `StreamConnectionStatus` 반환) +- `workspaceStreamHealth` / `useAnalysisFallbackPolling` — 워크스페이스 분석 SSE 건강 상태 store + 단절 시 5s 폴백 폴링 스위치 - `useAnalysisProgress`, `useCopyToClipboard` - `useQuestionRunner(questionIds, storageKey?)` — 질문을 한 개씩 넘기며 답을 적고 정답을 확인하는 드릴 상태 기계. **연습 면접과 오답노트가 함께 쓰므로 여기 있다** — features 끼리는 서로