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
11 changes: 6 additions & 5 deletions frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { StatusBadge } from '@/shared/ui/StatusBadge'
import { ScoreBar } from '@/shared/ui/ScoreBar'
import { Button } from '@/shared/ui/Button'
Expand Down Expand Up @@ -54,10 +54,11 @@ export function FeedbackReport({

const overall = feedback.overallScore
// 강조 대상: AI 가 고른 핵심 구절 ∪ 다음에 채울 키워드. 본문 문단에서 <mark> 처리.
const highlightTerms = [
...(feedback.highlights ?? []),
...(feedback.improvementKeywords ?? []),
]
// 참조를 고정하지 않으면 HighlightedText 8곳의 useMemo([terms])가 매 렌더 미스한다.
const highlightTerms = useMemo(
() => [...(feedback.highlights ?? []), ...(feedback.improvementKeywords ?? [])],
[feedback.highlights, feedback.improvementKeywords],
)
// '첫인상'·'직무 적합도'는 종합 점수에 포함되지 않는 별도 정성 평가 → 패널과 분리해 전용 섹션으로.
const panel = feedback.panelBreakdown ?? []
const selfIntro = panel.find((b) => b.evaluator === SELF_INTRO_LABEL)
Expand Down
166 changes: 166 additions & 0 deletions frontend/src/features/interview/model/useLiveInterview.render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ReactNode } from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { Message } from '@/domain/session'
import { useLiveInterview } from './useLiveInterview'
import { FOLLOWUP_GENERATING_TEXT } from './streamingBuffer'
import { sessionKeys } from './useSession'
import { messageKeys } from './useSessionMessages'

vi.mock('@/features/interview/api/streamToken', () => ({
fetchSessionStreamToken: vi.fn().mockResolvedValue('tok'),
}))

// jsdom 에 없는 WebSocket — 테스트가 프레임을 직접 밀어 넣는다.
class FakeWS {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static last: FakeWS | null = null
readyState = FakeWS.CONNECTING
onopen: (() => void) | null = null
onmessage: ((e: { data: string }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
url: string
constructor(url: string) {
this.url = url
FakeWS.last = this
}
send() {}
close() {
this.readyState = FakeWS.CLOSED
}
}

class FakeAudio {
src = ''
addEventListener() {}
removeEventListener() {}
play() {
return Promise.resolve()
}
pause() {}
}

vi.stubGlobal('WebSocket', FakeWS)
vi.stubGlobal('Audio', FakeAudio)

const SESSION_ID = 7
const PLACEHOLDER_ID = 503

function buildMessages(): Message[] {
const out: Message[] = []
for (let i = 0; i < 10; i++) {
out.push({
id: 100 + i * 2,
sessionId: SESSION_ID,
role: 'INTERVIEWER',
content: `질문 ${i}`,
sequenceNumber: i * 2 + 1,
} as Message)
out.push({
id: 101 + i * 2,
sessionId: SESSION_ID,
role: 'INTERVIEWEE',
content: `답변 ${i}`,
sequenceNumber: i * 2 + 2,
parentMessageId: 100 + i * 2,
} as Message)
}
out.push({
id: PLACEHOLDER_ID,
sessionId: SESSION_ID,
role: 'INTERVIEWER',
content: FOLLOWUP_GENERATING_TEXT,
sequenceNumber: 21,
} as Message)
return out
}

function setup() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnWindowFocus: false } },
})
qc.setQueryData(sessionKeys.detail(SESSION_ID), {
id: SESSION_ID,
status: 'IN_PROGRESS',
maxQuestions: 15,
generalQuestionCount: 10,
totalQuestionCount: 11,
})
qc.setQueryData(messageKeys.list(SESSION_ID), buildMessages())
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
)
return renderHook(() => useLiveInterview(SESSION_ID), { wrapper })
}

function pushDelta(seq: number, text: string) {
act(() => {
FakeWS.last?.onmessage?.({
data: JSON.stringify({
id: String(seq),
event: 'SESSION_MESSAGE_DELTA',
data: { data: { messageId: PLACEHOLDER_ID, seq, text } },
}),
})
})
}

beforeEach(() => {
FakeWS.last = null
})

// 델타 폭풍 시나리오의 재계산·참조 안정성 계측 (A3 회귀 가드).
// baseline(개선 전) 수치는 evidence 2026-08-22-A3-렌더성능-baseline/ 에 기록되어 있다.
describe('useLiveInterview 렌더 안정성 (A3 계측)', () => {
it('델타 1건당 참조가 유지되는 item 수', async () => {
const { result } = setup()
await waitFor(() => expect(FakeWS.last).not.toBeNull())
expect(result.current.items).toHaveLength(21)

const before = result.current.items
pushDelta(0, '동시성 ')
const after = result.current.items

const kept = after.filter((it, i) => it === before[i]).length
// 스트리밍 중인 placeholder 1개만 새 객체 — 나머지 20개는 참조 유지 (baseline: 0개).
expect(kept).toBe(20)
expect(after[20].content).toBe('동시성 ')
expect(after[20].streaming).toBe(true)
})

it('델타와 무관한 리렌더에서 items 배열 참조', async () => {
const { result, rerender } = setup()
await waitFor(() => expect(FakeWS.last).not.toBeNull())

const before = result.current.items
rerender()
// 입력이 안 바뀐 리렌더에서는 배열 자체가 그대로다 (baseline: 전부 새 참조).
expect(result.current.items).toBe(before)
})

it('콜백 참조 안정성 (memo 자식의 전제)', async () => {
const { result } = setup()
await waitFor(() => expect(FakeWS.last).not.toBeNull())

const first = {
submitVoice: result.current.submitVoice,
endSession: result.current.endSession,
interruptSession: result.current.interruptSession,
refetchSession: result.current.refetchSession,
}
pushDelta(0, '안녕')
// memo 자식(AnswerComposer 등)이 bail out 하려면 콜백 참조가 유지되어야 한다 (baseline: 전부 새 함수).
expect(result.current.submitVoice).toBe(first.submitVoice)
expect(result.current.endSession).toBe(first.endSession)
expect(result.current.interruptSession).toBe(first.interruptSession)
expect(result.current.refetchSession).toBe(first.refetchSession)
})
})
101 changes: 67 additions & 34 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { currentTurn } from '@/domain/session'
Expand Down Expand Up @@ -76,24 +76,43 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
}

// 서버가 sequenceNumber asc 로 주지만, 순서를 코드에서 명시적으로 보장한다.
const serverMessages = [...(messagesQuery.data ?? [])].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
const serverMessages = useMemo(
() =>
[...(messagesQuery.data ?? [])].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
),
[messagesQuery.data],
)
const pending = useMemo(
() => pendingAnswers(optimistic, serverMessages),
[optimistic, serverMessages],
)
const pending = pendingAnswers(optimistic, serverMessages)

// 스트리밍 중인 메시지는 deltaBuffer의 seq 재조립 텍스트로 content를 오버라이드한다.
const mergedMessages = serverMessages.map((m) => {
const buffered = bufferedText(deltaBuffer, m.id)
if (buffered !== undefined && isStreamingMessage(m, buffered)) {
return { ...m, content: buffered, streaming: true as const }
}
return m
})
// 델타(deltaBuffer)와 무관한 기반 목록을 분리해, 델타 1토큰마다 스트리밍 중인
// 메시지 하나만 새 객체가 되게 한다 — 나머지 item 은 참조가 유지되어
// memo 된 버블·형제 컴포넌트가 리렌더를 건너뛸 수 있다.
const baseItems: ThreadItem[] = useMemo(
() => [
...serverMessages.map((m) => ({ ...m, key: `m-${m.id}` })),
...pending.map((o) => ({ ...toOptimisticMessage(o), key: `opt-${o.tempId}` })),
],
[serverMessages, pending],
)

const items: ThreadItem[] = [
...mergedMessages.map((m) => ({ ...m, key: `m-${m.id}` })),
...pending.map((o) => ({ ...toOptimisticMessage(o), key: `opt-${o.tempId}` })),
]
// 스트리밍 중인 메시지는 deltaBuffer의 seq 재조립 텍스트로 content를 오버라이드한다.
// 낙관적(optimistic) 항목은 `id` 가 없어 bufferedText 가 항상 undefined — 델타가
// 붙지 않는다. toOptimisticMessage 가 id 를 채우게 되면 이 전제가 깨진다.
const items: ThreadItem[] = useMemo(
() =>
baseItems.map((m) => {
const buffered = bufferedText(deltaBuffer, m.id)
if (buffered !== undefined && isStreamingMessage(m, buffered)) {
return { ...m, content: buffered, streaming: true as const }
}
return m
}),
[baseItems, deltaBuffer],
)

// 세그먼트 오디오 엘리먼트 재생 리스너 — 마운트 1회.
// play/pause/ended 로 '말하는 중' 상태를 갱신해 아바타·질문 카드가 동기화되게 한다.
Expand Down Expand Up @@ -266,14 +285,15 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
toast.error('음성 답변 업로드에 실패했어요. 다시 시도해 주세요.'),
})

const submitVoice = useCallback(
(audio: Blob) => voiceMutation.mutate(audio),
[voiceMutation],
)
// mutate 는 참조가 안정적이다 — mutation 객체 전체를 deps 로 두면 매 렌더 새 함수가 된다.
const { mutate: voiceMutate } = voiceMutation
const submitVoice = useCallback((audio: Blob) => voiceMutate(audio), [voiceMutate])

// 서버 메시지 기준으로 가장 최근 면접관 메시지가 여전히 sentinel이면 스트리밍 진행 중.
const latestServerQuestion = [...serverMessages].reverse().find((m) => m.role === 'INTERVIEWER')
const questionStreaming = latestServerQuestion?.content === FOLLOWUP_GENERATING_TEXT
const questionStreaming = useMemo(() => {
const latest = [...serverMessages].reverse().find((m) => m.role === 'INTERVIEWER')
return latest?.content === FOLLOWUP_GENERATING_TEXT
}, [serverMessages])

const wasSegmented = useCallback((id: number) => segmentedIds.current.has(id), [])

Expand All @@ -285,33 +305,46 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =

// 첫 질문이 실제 content 를 갖고 도착했는지. 면접 스테이지 진입 전에 이걸 기다려
// 사용자가 스테이지에 들어서면 바로 질문을 볼 수 있게 한다(빈 대기 화면 회피).
const firstQuestionReady = items.some((m) => {
if (m.role !== 'INTERVIEWER') return false
const c = (m.content ?? '').trim()
return c.length > 0 && c !== FOLLOWUP_GENERATING_TEXT
})
const firstQuestionReady = useMemo(
() =>
items.some((m) => {
if (m.role !== 'INTERVIEWER') return false
const c = (m.content ?? '').trim()
return c.length > 0 && c !== FOLLOWUP_GENERATING_TEXT
}),
[items],
)

const turn = useMemo(() => currentTurn(items), [items])

const { mutate: endMutate } = end
const { mutate: interruptMutate } = interrupt
const { refetch: refetchSessionQuery } = sessionQuery
const endSession = useCallback(() => endMutate(), [endMutate])
// 잠시 중단 — 대화를 남긴 채 INTERRUPTED 로. 나중에 '이어서 진행하기' 로 돌아온다.
const interruptSession = useCallback(() => interruptMutate(), [interruptMutate])
const refetchSession = useCallback(() => {
void refetchSessionQuery()
}, [refetchSessionQuery])

return {
session: sessionQuery.data,
status,
items,
turn: currentTurn(items),
turn,
connection,
submitAnswer,
restoreDraft,
submitVoice,
voiceUploading: voiceMutation.isPending,
voiceError: voiceMutation.isError,
endSession: () => end.mutate(),
// 잠시 중단 — 대화를 남긴 채 INTERRUPTED 로. 나중에 '이어서 진행하기' 로 돌아온다.
interruptSession: () => interrupt.mutate(),
endSession,
interruptSession,
isLoading: sessionQuery.isLoading,
// 세션 조회 실패를 화면에 알리기 위한 것 — 없으면 LiveInterview 의
// `isLoading || !session` 분기가 에러 시에도 스피너를 영원히 돌린다.
isError: sessionQuery.isError,
refetchSession: () => {
void sessionQuery.refetch()
},
refetchSession,
questionStreaming,
wasSegmented,
isSpeaking,
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/features/interview/ui/InterviewTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { isQuestion } from '@/domain/session'
import { QueryError } from '@/shared/ui'
import { Spinner } from '@/shared/ui/Spinner'
Expand All @@ -11,6 +12,13 @@ import { AnswerCoachingAccordion } from './live/AnswerCoachingAccordion'
export function InterviewTranscript({ sessionId }: { sessionId: number }) {
const { data, isLoading, isError, refetch } = useSessionMessages(sessionId)

// memo 된 버블이 리렌더를 건너뛰려면 item 참조가 유지되어야 한다 (early return 위라 훅 순서 안전).
const items = useMemo(
() =>
[...(data ?? [])].sort((a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0)),
[data],
)

if (isLoading) {
return (
<div className="flex justify-center py-8">
Expand All @@ -29,9 +37,6 @@ export function InterviewTranscript({ sessionId }: { sessionId: number }) {
)
}

const items = [...(data ?? [])].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
)
if (items.length === 0) return null

return (
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/features/interview/ui/live/AnswerBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { memo } from 'react'
import { isTranscribing } from '@/domain/session'
import type { Message } from '@/domain/session'
import { useMessageAudio } from '../../lib/media/useMessageAudio'

export function AnswerBubble({ message }: { message: Message }) {
export const AnswerBubble = memo(function AnswerBubble({ message }: { message: Message }) {
const transcribing = isTranscribing(message)
const failed = transcribing && message.status === 'FAILED'
const hasVoice = Boolean(message.audioFilePath)
Expand Down Expand Up @@ -41,4 +42,4 @@ export function AnswerBubble({ message }: { message: Message }) {
))}
</div>
)
}
})
Loading
Loading