diff --git a/frontend/src/features/feedback/index.ts b/frontend/src/features/feedback/index.ts index 8f54c3c..8763a7e 100644 --- a/frontend/src/features/feedback/index.ts +++ b/frontend/src/features/feedback/index.ts @@ -1,4 +1,6 @@ export { FeedbackReport } from './ui/FeedbackReport' +export { FeedbackReportSkeleton } from './ui/FeedbackReportSkeleton' +export { useFeedbackLive } from './model/useFeedbackLive' export { useFeedback, isFeedbackPending, diff --git a/frontend/src/features/feedback/model/useFeedbackLive.test.tsx b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx new file mode 100644 index 0000000..5e92d32 --- /dev/null +++ b/frontend/src/features/feedback/model/useFeedbackLive.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ReactNode } from 'react' +import { renderHook, act, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ApiError } from '@/shared/api' +import { useFeedbackLive } from './useFeedbackLive' +import { getFeedback } from '../api/feedbackApi' + +vi.mock('../api/feedbackApi', () => ({ + getFeedback: vi.fn(), +})) + +// SSE 를 흉내 내는 EventSource — 테스트가 FEEDBACK_READY 를 직접 발화한다. +class FakeES { + static last: FakeES | null = null + handlers: Record void> = {} + onopen: (() => void) | null = null + onerror: (() => void) | null = null + closed = false + url: string + constructor(url: string) { + this.url = url + FakeES.last = this + } + addEventListener(name: string, fn: (e: { data: string }) => void) { + this.handlers[name] = fn + } + close() { + this.closed = true + } + emit(name: string, payload: unknown) { + this.handlers[name]?.({ data: JSON.stringify(payload) }) + } +} + +vi.stubGlobal('EventSource', FakeES) + +const notReady = () => new ApiError(404, { code: 'FEEDBACK_NOT_READY', message: '생성 중' }) + +function setup() { + const qc = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return renderHook(() => useFeedbackLive(99, async () => 'tok'), { wrapper }) +} + +beforeEach(() => { + FakeES.last = null + vi.mocked(getFeedback).mockReset() +}) + +describe('useFeedbackLive', () => { + it('FEEDBACK_READY 수신 즉시 재조회한다 — 3s 폴링 간격을 기다리지 않는다', async () => { + // 첫 조회는 생성 중(404) → 재시도 대기(3s) 상태로 들어간다. + vi.mocked(getFeedback) + .mockRejectedValueOnce(notReady()) + .mockResolvedValue({ sessionId: 99, overallScore: 80 } as never) + + const { result } = setup() + await waitFor(() => expect(FakeES.last).not.toBeNull()) + await waitFor(() => expect(getFeedback).toHaveBeenCalledTimes(1)) + expect(result.current.data).toBeUndefined() + + act(() => FakeES.last?.emit('FEEDBACK_READY', { data: { sessionId: 99 } })) + + // 3s 재시도 타이머보다 훨씬 이른 시점에 데이터가 도착해야 한다. + await waitFor(() => expect(result.current.data).toBeDefined(), { timeout: 1_500 }) + expect(result.current.data?.overallScore).toBe(80) + }) + + it('피드백이 도착하면 스트림을 닫는다', async () => { + vi.mocked(getFeedback).mockResolvedValue({ sessionId: 99, overallScore: 80 } as never) + + const { result } = setup() + await waitFor(() => expect(result.current.data).toBeDefined()) + // path 가 null 이 되어 연결 effect 가 정리된다. + await waitFor(() => expect(FakeES.last?.closed ?? true).toBe(true)) + }) + + it('데이터가 이미 표시 중일 때 늦게 온 이벤트는 무시한다 — 스켈레톤으로 되돌아가지 않는다', async () => { + vi.mocked(getFeedback).mockResolvedValue({ sessionId: 99, overallScore: 80 } as never) + + const { result } = setup() + await waitFor(() => expect(result.current.data).toBeDefined()) + const callsAfterLoad = vi.mocked(getFeedback).mock.calls.length + + // 스트림 close 전 좁은 레이스 창에서 지연 도착한 이벤트를 재현. + act(() => FakeES.last?.emit('FEEDBACK_READY', { data: { sessionId: 99 } })) + + expect(result.current.data?.overallScore).toBe(80) + expect(vi.mocked(getFeedback).mock.calls.length).toBe(callsAfterLoad) + }) +}) diff --git a/frontend/src/features/feedback/model/useFeedbackLive.ts b/frontend/src/features/feedback/model/useFeedbackLive.ts new file mode 100644 index 0000000..8ee03ec --- /dev/null +++ b/frontend/src/features/feedback/model/useFeedbackLive.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEventStream } from '@/shared/hooks' +import type { StreamConnectionStatus } from '@/shared/hooks' +import { getFeedback } from '../api/feedbackApi' +import { feedbackKeys, isFeedbackPending } from './useFeedback' + +// 피드백 조회 + 세션 채널 SSE(FEEDBACK_READY) 구독. +// - 준비 완료 이벤트 수신 즉시 재조회 — 3s 폴링 간격을 기다리지 않는다. +// - 기존 3s×40 재시도는 백스톱으로 축소: 스트림이 살아 있으면 15s 간격(이벤트가 주 신호), +// 죽어 있으면 기존대로 3s (api-conventions §9 — SSE 우선, 끊기면 폴링). +// 어느 모드든 총 대기 예산은 ≈2분 — 소진 시 페이지가 재생성 복구 UI 를 연다. +// - getToken 은 호출부(페이지)가 주입한다 — 세션 stream token API 는 interview 슬라이스 +// 소유라 feedback 이 직접 import 할 수 없다 (FSD 동일 레이어 금지). +export function useFeedbackLive(sessionId: number, getToken: () => Promise) { + const queryClient = useQueryClient() + + // retry 콜백은 비동기 시점에 실행되므로 ref 로 최신 스트림 상태를 읽는다. + const statusRef = useRef('connecting') + + const query = useQuery({ + queryKey: feedbackKeys.detail(sessionId), + queryFn: () => getFeedback(sessionId), + retry: (count, err) => + isFeedbackPending(err) && count < (statusRef.current === 'open' ? 8 : 40), + retryDelay: () => (statusRef.current === 'open' ? 15_000 : 3_000), + }) + + const onReady = useCallback(() => { + // 데이터가 이미 도착한 뒤 늦게 온 이벤트는 무시 — reset 하면 표시 중인 리포트가 + // 스켈레톤으로 되돌아간다 (스트림 close 는 다음 커밋에서야 일어나는 좁은 레이스 창). + if (queryClient.getQueryData(feedbackKeys.detail(sessionId)) !== undefined) return + // 재시도 대기 중인 쿼리를 리셋해 즉시 다시 조회한다 (useRegenerateFeedback 과 동일 패턴). + void queryClient.resetQueries({ queryKey: feedbackKeys.detail(sessionId) }) + }, [queryClient, sessionId]) + + // 피드백이 도착하면 스트림을 닫는다 (path: null). + const streamStatus = useEventStream({ + path: query.data ? null : `/realtime/stream/sessions/${sessionId}`, + getToken, + handlers: { FEEDBACK_READY: onReady }, + }) + + useEffect(() => { + statusRef.current = streamStatus + }, [streamStatus]) + + return { ...query, streamStatus } +} diff --git a/frontend/src/features/feedback/ui/FeedbackReportSkeleton.test.tsx b/frontend/src/features/feedback/ui/FeedbackReportSkeleton.test.tsx new file mode 100644 index 0000000..3f7f42e --- /dev/null +++ b/frontend/src/features/feedback/ui/FeedbackReportSkeleton.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import { FeedbackReportSkeleton } from './FeedbackReportSkeleton' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('FeedbackReportSkeleton', () => { + it('생성 중 안내와 자동 표시 예고를 보여준다', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('피드백을 생성하는 중입니다') + expect(screen.getByRole('status')).toHaveTextContent('완성되면 자동으로 표시됩니다') + }) + + it('경과 시간이 흐른다 — 멈춘 화면으로 보이지 않게', () => { + vi.useFakeTimers() + render() + expect(screen.getByRole('status')).toHaveTextContent('(0s 경과)') + act(() => { + vi.advanceTimersByTime(12_000) + }) + expect(screen.getByRole('status')).toHaveTextContent('(12s 경과)') + }) +}) diff --git a/frontend/src/features/feedback/ui/FeedbackReportSkeleton.tsx b/frontend/src/features/feedback/ui/FeedbackReportSkeleton.tsx new file mode 100644 index 0000000..a6b007d --- /dev/null +++ b/frontend/src/features/feedback/ui/FeedbackReportSkeleton.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' + +function Pulse({ className }: { className: string }) { + return
+} + +// 피드백 생성 대기 화면 — 리포트가 올 자리의 형태를 미리 보여주고 경과 시간을 알린다. +// 정적 텍스트만 있으면 "멈췄나?" 로 읽힌다 (A4). 완료는 FEEDBACK_READY SSE 로 즉시 반영된다. +export function FeedbackReportSkeleton() { + const [elapsedSec, setElapsedSec] = useState(0) + useEffect(() => { + const id = setInterval(() => setElapsedSec((s) => s + 1), 1_000) + return () => clearInterval(id) + }, []) + + return ( +
+
+

피드백을 생성하는 중입니다…

+

+ 답변을 종합 분석하고 있어요. 보통 1분 내외, 길면 2분 이상 걸릴 수 있습니다. + 완성되면 자동으로 표시됩니다. ({elapsedSec}s 경과) +

+
+ + {/* 종합 점수 + 축별 점수 바 자리 */} +
+ +
+ + + +
+
+ + {/* 강점/개선 본문 자리 */} +
+ +
+ + + +
+
+
+ ) +} diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts index 86ab609..3863724 100644 --- a/frontend/src/features/interview/index.ts +++ b/frontend/src/features/interview/index.ts @@ -1,4 +1,5 @@ export { LiveInterview } from './ui/live/LiveInterview' +export { fetchSessionStreamToken } from './api/streamToken' export { InterviewTranscript } from './ui/InterviewTranscript' export { BookmarkList } from './ui/BookmarkList' export { InterviewSetupForm } from './ui/setup/InterviewSetupForm' diff --git a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx index 9dc7738..5eb1a0f 100644 --- a/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx +++ b/frontend/src/pages/SessionFeedback/ui/SessionFeedbackPage.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { Link, useParams } from 'react-router-dom' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' @@ -5,16 +6,25 @@ import { Button } from '@/shared/ui/Button' import { PageHeader } from '@/shared/ui' import { FeedbackReport, + FeedbackReportSkeleton, isFeedbackPending, - useFeedback, + useFeedbackLive, useRegenerateFeedback, } from '@/features/feedback' -import { InterviewTranscript, useRetrySession, useSession } from '@/features/interview' +import { + InterviewTranscript, + fetchSessionStreamToken, + useRetrySession, + useSession, +} from '@/features/interview' export default function SessionFeedbackPage() { const { id } = useParams<{ id: string }>() const sessionId = Number(id) - const { data, isLoading, isError, error, refetch } = useFeedback(sessionId) + // 피드백 조회 + FEEDBACK_READY SSE — 준비 완료 즉시 표시(폴링은 백스톱). + // 세션 stream token 은 interview 슬라이스 소유라 페이지가 주입한다. + const getToken = useCallback(() => fetchSessionStreamToken(sessionId), [sessionId]) + const { data, isLoading, isError, error, refetch } = useFeedbackLive(sessionId, getToken) const regenerate = useRegenerateFeedback(sessionId) // 재도전은 원본 세션의 자료 수를 알아야 "몇 개가 빠졌는지" 안내할 수 있다. const { data: session } = useSession(sessionId) @@ -51,19 +61,12 @@ export default function SessionFeedbackPage() { } /> - {isLoading && ( -
-

피드백을 생성하는 중입니다…

-

- 답변을 종합 분석하고 있어요. 최대 1분가량 걸릴 수 있습니다. -

-
- )} + {isLoading && } {isError && (isFeedbackPending(error) ? ( - // polling 40회(약 2분)를 다 써도 피드백이 없으면 생성 요청이 유실됐을 가능성이 - // 높다(브로커 다운·AI 실패). 무한 대기 대신 재생성 복구 경로를 연다. + // 백스톱 재시도 예산(모드 무관 ≈2분, useFeedbackLive)을 다 써도 피드백이 없으면 + // 생성 요청이 유실됐을 가능성이 높다(브로커 다운·AI 실패). 무한 대기 대신 복구 경로를 연다.

피드백 생성이 예상보다 오래 걸리고 있어요.