Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/cover-letter/model/useCoverLetters.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,9 +17,12 @@ export const coverLetterKeys = {
}

export function useCoverLetters() {
// 분석 SSE 가 죽은 동안만 5s 폴링 (useResumes 와 동일한 이유).
const fallbackPolling = useAnalysisFallbackPolling()
return useQuery<CoverLetter[]>({
queryKey: coverLetterKeys.all,
queryFn: fetchCoverLetters,
refetchInterval: fallbackPolling,
})
}

Expand Down
10 changes: 0 additions & 10 deletions frontend/src/features/interview/ui/live/ConnectionBanner.tsx

This file was deleted.

8 changes: 6 additions & 2 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -150,7 +150,11 @@ export function InterviewStage({
</header>

<SmallScreenNotice />
<ConnectionBanner connection={connection} />
<ConnectionBanner
connection={connection}
connectingText="면접 서버에 연결 중입니다…"
reconnectingText="연결이 끊겨 재연결 중입니다…"
/>

<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-6 overflow-y-auto px-5 py-8">
<InterviewerAvatar
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/repo/model/useRepositories.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,9 +21,12 @@ export const repoKeys = {
}

export function useRegisteredRepositories() {
// 분석 SSE 가 죽은 동안만 5s 폴링 (useResumes 와 동일한 이유).
const fallbackPolling = useAnalysisFallbackPolling()
return useQuery<RegisteredRepository[]>({
queryKey: repoKeys.registered,
queryFn: fetchRegisteredRepositories,
refetchInterval: fallbackPolling,
})
}

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/features/resume/model/useResumes.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,9 +18,13 @@ export const resumeKeys = {
}

export function useResumes() {
// 분석 SSE 가 죽은 동안만 5s 폴링 — 상태 변화(ANALYZING→ANALYZED)가 SSE 로만 오기 때문에
// 폴백이 없으면 카드가 "분석 중"으로 영구 고착된다.
const fallbackPolling = useAnalysisFallbackPolling()
return useQuery<Resume[]>({
queryKey: resumeKeys.all,
queryFn: fetchResumes,
refetchInterval: fallbackPolling,
})
}

Expand Down
20 changes: 16 additions & 4 deletions frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts
Original file line number Diff line number Diff line change
@@ -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: <payload>, traceId }.
type StreamData<T> = { data?: T; traceId?: string | null }
Expand All @@ -23,7 +24,8 @@ function unwrap<T>(raw: unknown): T | undefined {

// 이력서·레포·문서 쿼리를 무효화 → 화면이 자동으로 최신 상태로 갱신된다.
// 추가로 ANALYSIS_PROGRESS(단계별 진행)는 쿼리 무효화 없이 진행 store 만 갱신한다.
export function useWorkspaceAnalysisStream() {
// 반환값: 연결 상태 — WorkspacePage 가 단절 배너를 그리는 데 사용.
export function useWorkspaceAnalysisStream(): StreamConnectionStatus {
const queryClient = useQueryClient()

const getToken = useCallback(() => createStreamToken(), [])
Expand Down Expand Up @@ -52,7 +54,7 @@ export function useWorkspaceAnalysisStream() {
})
}, [])

useEventStream({
const status = useEventStream({
path: '/realtime/stream/me',
getToken,
handlers: {
Expand All @@ -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
}
12 changes: 10 additions & 2 deletions frontend/src/pages/Workspace/ui/WorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,7 +25,7 @@ function resolveView(pathname: string): View {

export default function WorkspacePage() {
// 분석 상태 실시간 구독 (SSE) — 어떤 뷰에 있든 완료 시 목록이 자동 갱신된다.
useWorkspaceAnalysisStream()
const streamStatus = useWorkspaceAnalysisStream()

const { user } = useAuth()
const { pathname } = useLocation()
Expand Down Expand Up @@ -73,6 +73,14 @@ export default function WorkspacePage() {
<div className="flex min-h-svh flex-col bg-surface text-fg lg:flex-row">
<WorkspaceSidebar />
<main className="min-w-0 flex-1">
{/* 최초 연결(connecting)은 정상 부팅 구간이라 조용히 두고, 끊김(closed)만 알린다.
이 동안 목록은 5s 폴백 폴링으로 갱신된다 (useAnalysisFallbackPolling). */}
{streamStatus === 'closed' && (
<ConnectionBanner
connection={streamStatus}
reconnectingText="실시간 연결이 끊겨 재연결 중입니다. 분석 상태 표시가 몇 초 지연될 수 있어요."
/>
)}
<div className="mx-auto w-full max-w-content px-6 py-10 lg:px-12 lg:py-14">
<PageHeader
eyebrow={meta.eyebrow}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 끼리는 서로
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export { useEventStream } from './useEventStream'
export type { StreamConnectionStatus } from './useEventStream'
export { useAnalysisProgress, analysisProgress } from './useAnalysisProgress'
export type { AnalysisProgress } from './useAnalysisProgress'
export {
workspaceStreamHealth,
useWorkspaceStreamHealth,
useAnalysisFallbackPolling,
} from './workspaceStreamHealth'
export type { WorkspaceStreamHealth } from './workspaceStreamHealth'
export { useCopyToClipboard } from './useCopyToClipboard'
export { useQuestionRunner } from './useQuestionRunner'
export { useNoIndex } from './useNoIndex'
39 changes: 36 additions & 3 deletions frontend/src/shared/hooks/useAnalysisProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,53 @@ export type AnalysisProgress = {

const keyOf = (targetType: string, targetId: number) => `${targetType}:${targetId}`

const store = new Map<string, AnalysisProgress>()
// 진행 이벤트는 분석 중 수 초 간격으로 온다. 이 시간 동안 갱신이 없으면 종료 이벤트
// (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<string, Entry>()
const listeners = new Set<() => void>()
let sweepTimer: ReturnType<typeof setInterval> | 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))
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/shared/hooks/useAnalysisProgress.ttl.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
90 changes: 90 additions & 0 deletions frontend/src/shared/hooks/useEventStream.status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useEventStream } from './useEventStream'

// jsdom 에 없는 EventSource — 테스트가 open/error 를 직접 발화한다.
class FakeES {
static last: FakeES | null = null
onopen: (() => 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<string | null> = 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<string | null>((_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()
}
})
})
Loading
Loading