From 4d1443a12b521c5015a40f62d4b48e6b00ccc84e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 20:55:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?perf(frontend):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EB=A9=B4=EC=A0=91=20=EB=8D=B8=ED=83=80=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EA=B2=BD=EB=A1=9C=20=EB=A9=94=EB=AA=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLiveInterview 가 매 렌더 정렬·병합·items 를 재계산하고 반환 콜백도 렌더마다 새로 만들어, 델타 1토큰마다 스테이지 전체가 재계산·리렌더됐다 - 정렬(serverMessages)·pending·items 를 useMemo 로, submitVoice·endSession· interruptSession·refetchSession 을 useCallback 으로 안정화 - baseItems(델타 무관)/items(델타 오버레이) 2단 분리 — 델타 1건당 스트리밍 중인 메시지 1개만 새 객체 (참조 유지 0/21 → 20/21) - FakeWS 델타 주입 계측 테스트 3케이스 (참조 안정성 회귀 가드) --- .../model/useLiveInterview.render.test.tsx | 166 ++++++++++++++++++ .../interview/model/useLiveInterview.ts | 101 +++++++---- 2 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 frontend/src/features/interview/model/useLiveInterview.render.test.tsx diff --git a/frontend/src/features/interview/model/useLiveInterview.render.test.tsx b/frontend/src/features/interview/model/useLiveInterview.render.test.tsx new file mode 100644 index 0000000..1643641 --- /dev/null +++ b/frontend/src/features/interview/model/useLiveInterview.render.test.tsx @@ -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 }) => ( + + {children} + + ) + 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) + }) +}) diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts index 031ea53..b088dad 100644 --- a/frontend/src/features/interview/model/useLiveInterview.ts +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -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' @@ -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 로 '말하는 중' 상태를 갱신해 아바타·질문 카드가 동기화되게 한다. @@ -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), []) @@ -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, From 3bbf2933bca0c9d36589cb32ae4a8180477b24ee Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 20:55:46 +0900 Subject: [PATCH 2/3] =?UTF-8?q?perf(frontend):=20AI=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20React.memo?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 델타 1토큰마다 InterviewStage 서브트리 전체(웹캠·컴포저·아바타)와 드로어의 버블 전원이 리렌더되던 것을 변경분만 렌더하도록 축소 - QuestionBubble·AnswerBubble·ConversationThread·StageQuestion· WebcamSelfView·AnswerComposer·InterviewerAvatar memo 래핑 (props 참조 안정화는 선행 커밋의 useLiveInterview 메모화가 전제) - 훅 카운터 계측 테스트 2케이스 — 동일 참조 리렌더 시 버블 재실행 0/20, 델타 1건 시 1/20 만 재실행 --- .../interview/ui/live/AnswerBubble.tsx | 5 +- .../interview/ui/live/AnswerComposer.tsx | 6 +- .../live/ConversationThread.render.test.tsx | 77 +++++++++++++++++++ .../interview/ui/live/ConversationThread.tsx | 6 +- .../interview/ui/live/InterviewerAvatar.tsx | 6 +- .../interview/ui/live/QuestionBubble.tsx | 5 +- .../interview/ui/live/StageQuestion.tsx | 6 +- .../interview/ui/live/WebcamSelfView.tsx | 5 +- 8 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/interview/ui/live/ConversationThread.render.test.tsx diff --git a/frontend/src/features/interview/ui/live/AnswerBubble.tsx b/frontend/src/features/interview/ui/live/AnswerBubble.tsx index 22fde8f..f443108 100644 --- a/frontend/src/features/interview/ui/live/AnswerBubble.tsx +++ b/frontend/src/features/interview/ui/live/AnswerBubble.tsx @@ -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) @@ -41,4 +42,4 @@ export function AnswerBubble({ message }: { message: Message }) { ))} ) -} +}) diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.tsx index ffa71eb..f83a3c7 100644 --- a/frontend/src/features/interview/ui/live/AnswerComposer.tsx +++ b/frontend/src/features/interview/ui/live/AnswerComposer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import type { KeyboardEvent } from 'react' import { TextArea } from '@/shared/ui/TextArea' import { Button } from '@/shared/ui/Button' @@ -30,7 +30,7 @@ function MicIcon() { ) } -export function AnswerComposer({ +export const AnswerComposer = memo(function AnswerComposer({ disabled = false, disabledReason = 'awaiting-question', submitLocked = false, @@ -187,4 +187,4 @@ export function AnswerComposer({ )} ) -} +}) diff --git a/frontend/src/features/interview/ui/live/ConversationThread.render.test.tsx b/frontend/src/features/interview/ui/live/ConversationThread.render.test.tsx new file mode 100644 index 0000000..a9e9a9e --- /dev/null +++ b/frontend/src/features/interview/ui/live/ConversationThread.render.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import type { ThreadItem } from '../../model/useLiveInterview' +import { ConversationThread } from './ConversationThread' + +// 버블 본문이 실행될 때만 호출되는 내부 훅을 카운터로 바꿔 렌더 수를 센다 +// (React.memo 가 bail out 하면 본문이 실행되지 않아 카운트되지 않는다). +const counts = { question: 0, answer: 0 } + +vi.mock('../../lib/media/useTtsPlayback', () => ({ + useTtsPlayback: () => { + counts.question += 1 + return { playing: false, toggle: () => {}, audioNode: null } + }, +})) +vi.mock('../../lib/media/useMessageAudio', () => ({ + useMessageAudio: () => { + counts.answer += 1 + return { url: undefined, load: async () => undefined } + }, +})) +vi.mock('../../model/useBookmarks', () => ({ + useSetQuestionBookmark: () => ({ mutate: () => {}, isPending: false }), +})) + +const items: ThreadItem[] = [] +for (let i = 0; i < 10; i++) { + items.push({ + id: 100 + i * 2, + key: `m-${100 + i * 2}`, + role: 'INTERVIEWER', + content: `질문 ${i}`, + sequenceNumber: i * 2 + 1, + } as ThreadItem) + items.push({ + id: 101 + i * 2, + key: `m-${101 + i * 2}`, + role: 'INTERVIEWEE', + content: `답변 ${i}`, + sequenceNumber: i * 2 + 2, + } as ThreadItem) +} + +function Harness({ list }: { list: ThreadItem[] }) { + return +} + +beforeEach(() => { + counts.question = 0 + counts.answer = 0 +}) + +// baseline(개선 전) 수치는 evidence 2026-08-22-A3-렌더성능-baseline/ 에 기록되어 있다. +describe('ConversationThread 버블 렌더 수 (A3 계측)', () => { + it('items 참조가 그대로인 부모 리렌더에서 버블 본문은 재실행되지 않는다', () => { + const { rerender } = render() + expect(counts.question).toBe(10) + expect(counts.answer).toBe(10) + + rerender() + // React.memo bail out (baseline: 20/20 전부 재실행). + expect(counts.question).toBe(10) + expect(counts.answer).toBe(10) + }) + + it('델타로 item 1개만 바뀌면 해당 버블만 재실행된다 (드로어 열림 + 스트리밍 시나리오)', () => { + const { rerender } = render() + expect(counts.question).toBe(10) + expect(counts.answer).toBe(10) + + // useLiveInterview 가 델타 1건에 하는 일과 동일: 배열은 새로, 스트리밍 중인 1개만 새 객체. + const next = items.map((it, i) => (i === 18 ? { ...it, content: '질문 9 + 델타' } : it)) + rerender() + expect(counts.question).toBe(11) + expect(counts.answer).toBe(10) + }) +}) diff --git a/frontend/src/features/interview/ui/live/ConversationThread.tsx b/frontend/src/features/interview/ui/live/ConversationThread.tsx index e6655bd..2ae3bf1 100644 --- a/frontend/src/features/interview/ui/live/ConversationThread.tsx +++ b/frontend/src/features/interview/ui/live/ConversationThread.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { memo, useEffect, useRef } from 'react' import { isQuestion } from '@/domain/session' import type { ThreadItem } from '../../model/useLiveInterview' import type { DeliveryMode } from '../../model/useDeliveryMode' @@ -6,7 +6,7 @@ import { QuestionBubble } from './QuestionBubble' import { AnswerBubble } from './AnswerBubble' import { TypingIndicator } from './TypingIndicator' -export function ConversationThread({ +export const ConversationThread = memo(function ConversationThread({ items, awaitingQuestion, mode = 'text', @@ -38,4 +38,4 @@ export function ConversationThread({ {awaitingQuestion ? : null} ) -} +}) diff --git a/frontend/src/features/interview/ui/live/InterviewerAvatar.tsx b/frontend/src/features/interview/ui/live/InterviewerAvatar.tsx index b1ef7d8..1763145 100644 --- a/frontend/src/features/interview/ui/live/InterviewerAvatar.tsx +++ b/frontend/src/features/interview/ui/live/InterviewerAvatar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useState } from 'react' export type InterviewerState = 'idle' | 'thinking' | 'asking' | 'speaking' @@ -17,7 +17,7 @@ function FallbackFace() { // 라이브 면접 스테이지 상단에서 "사람이 묻는" 존재감을 주는 면접관 아바타. // 캡션(면접관/카테고리)은 아래 질문 카드가 이미 보여주므로 여기서는 시각 요소만 담당한다. -export function InterviewerAvatar({ state }: { state: InterviewerState }) { +export const InterviewerAvatar = memo(function InterviewerAvatar({ state }: { state: InterviewerState }) { const [imgFailed, setImgFailed] = useState(false) const ringColor = @@ -61,4 +61,4 @@ export function InterviewerAvatar({ state }: { state: InterviewerState }) { ) -} +}) diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.tsx index 9d373d8..7ea8996 100644 --- a/frontend/src/features/interview/ui/live/QuestionBubble.tsx +++ b/frontend/src/features/interview/ui/live/QuestionBubble.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import type { Message } from '@/domain/session' import { StatusBadge } from '@/shared/ui/StatusBadge' import { categoryLabel } from '../../lib/categoryLabel' @@ -30,7 +31,7 @@ function PlayIcon({ playing }: { playing: boolean }) { ) } -export function QuestionBubble({ +export const QuestionBubble = memo(function QuestionBubble({ message, autoPlay = false, bookmarkable = false, @@ -126,4 +127,4 @@ export function QuestionBubble({ ) -} +}) diff --git a/frontend/src/features/interview/ui/live/StageQuestion.tsx b/frontend/src/features/interview/ui/live/StageQuestion.tsx index 0c22f28..20facf0 100644 --- a/frontend/src/features/interview/ui/live/StageQuestion.tsx +++ b/frontend/src/features/interview/ui/live/StageQuestion.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { memo, useState } from 'react' import type { Message } from '@/domain/session' import { categoryLabel } from '../../lib/categoryLabel' import { useTtsPlayback } from '../../lib/media/useTtsPlayback' @@ -29,7 +29,7 @@ function VoiceWave({ playing }: { playing: boolean }) { } // 면접관이 지금 막 던진 한 질문에만 집중시키는 카드. -export function StageQuestion({ +export const StageQuestion = memo(function StageQuestion({ question, segmented = false, speaking = false, @@ -181,4 +181,4 @@ export function StageQuestion({ )} ) -} +}) diff --git a/frontend/src/features/interview/ui/live/WebcamSelfView.tsx b/frontend/src/features/interview/ui/live/WebcamSelfView.tsx index 9b4426b..2b63119 100644 --- a/frontend/src/features/interview/ui/live/WebcamSelfView.tsx +++ b/frontend/src/features/interview/ui/live/WebcamSelfView.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { useWebcamPreview } from '../../lib/media/useWebcamPreview' function CameraOffIcon() { @@ -16,7 +17,7 @@ const PLACEHOLDER: Record = { } // 면접 스테이지에 떠 있는 본인 카메라 미리보기 카드. 위치는 호출부(InterviewStage)가 결정. -export function WebcamSelfView() { +export const WebcamSelfView = memo(function WebcamSelfView() { const { videoRef, state, start, stop } = useWebcamPreview() const live = state === 'live' @@ -48,4 +49,4 @@ export function WebcamSelfView() { ) -} +}) From cd4785be0775ac0ac9e977fbf0118e892309e29e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 22 Aug 2026 20:55:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?perf(frontend):=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=C2=B7?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=A9=94=EB=AA=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedbackReport 가 매 렌더 새 highlightTerms 배열을 만들어 HighlightedText 8곳의 useMemo([terms])가 항상 미스 — Set 생성·sort·RegExp 재컴파일 반복 - InterviewTranscript 의 메시지 정렬을 useMemo 로 호이스트해 memo 버블이 참조 유지로 리렌더를 건너뛸 수 있게 함 --- frontend/src/features/feedback/ui/FeedbackReport.tsx | 11 ++++++----- .../src/features/interview/ui/InterviewTranscript.tsx | 11 ++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index 19d452b..1e2d03b 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -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' @@ -54,10 +54,11 @@ export function FeedbackReport({ const overall = feedback.overallScore // 강조 대상: AI 가 고른 핵심 구절 ∪ 다음에 채울 키워드. 본문 문단에서 처리. - 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) diff --git a/frontend/src/features/interview/ui/InterviewTranscript.tsx b/frontend/src/features/interview/ui/InterviewTranscript.tsx index 17df00e..9f061cd 100644 --- a/frontend/src/features/interview/ui/InterviewTranscript.tsx +++ b/frontend/src/features/interview/ui/InterviewTranscript.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { isQuestion } from '@/domain/session' import { QueryError } from '@/shared/ui' import { Spinner } from '@/shared/ui/Spinner' @@ -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 (
@@ -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 (