From 510fc6a19dab7d6bf57f2b9d076f613464398ff0 Mon Sep 17 00:00:00 2001 From: jmj Date: Tue, 8 Sep 2026 02:52:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(web):=20=EA=B6=8C=ED=95=9C=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=A4=91=20=EC=96=B8=EB=A7=88=EC=9A=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B9=B4=EB=A9=94=EB=9D=BC=C2=B7=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=ED=8A=B8=EB=9E=99=20=EB=88=84=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useWebcamPreview·useVoiceRecorder 가 getUserMedia 를 await 하는 동안 언마운트되면, 언마운트 cleanup 은 아직 null 인 streamRef 만 읽어 아무것도 정지하지 못한다. 그 뒤 권한이 뒤늦게 허용되면 스트림이 소유자 없는 훅에 할당되어 카메라 LED·마이크 표시등이 페이지 새로고침 전까지 켜진 채 남는다. 프롬프트가 열려 있는 창(수 초)이라 흔히 재현된다. - 두 훅에 requestId 세대값 도입. stop()/언마운트가 이를 올려 pending 을 무효화. - getUserMedia resolve 후 requestId 가 바뀌었으면 방금 열린 트랙을 즉시 정지. - 늦게 도착한 거부(catch)는 requestId 일치할 때만 denied 로 반영. - pending 중 언마운트 회귀 테스트 2건 추가 (수정 없으면 실패, 있으면 통과 확인). useVoiceRecorder 는 기존 cancel() 만 requestId 를 올려 언마운트 경로가 빠져 있었고, useWebcamPreview 는 세대값 자체가 없었다. --- .../lib/media/useVoiceRecorder.test.ts | 67 +++++++++++++++++++ .../interview/lib/media/useVoiceRecorder.ts | 11 ++- .../interview/lib/media/useWebcamPreview.ts | 18 ++++- .../interview/ui/live/WebcamSelfView.test.tsx | 19 ++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts new file mode 100644 index 00000000..57bf3576 --- /dev/null +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useVoiceRecorder } from './useVoiceRecorder' + +// 최소 MediaRecorder 스텁 — 가드가 깨져 늦은 스트림으로 녹음이 시작되면 +// 여기서 start() 가 불려 테스트가 그 사실을 잡을 수 있다. +class FakeMediaRecorder { + static isTypeSupported() { + return true + } + state = 'inactive' + ondataavailable: unknown = null + onstop: unknown = null + start() { + this.state = 'recording' + } + stop() { + this.state = 'inactive' + } +} + +function installMediaRecorder() { + vi.stubGlobal('MediaRecorder', FakeMediaRecorder) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useVoiceRecorder', () => { + it('권한 대기 중 언마운트되면 뒤늦게 도착한 트랙을 정지하고 녹음을 시작하지 않는다', async () => { + installMediaRecorder() + const stop = vi.fn() + const track = { stop } + let resolveStream!: (s: unknown) => void + const pending = new Promise((res) => { + resolveStream = res + }) + const getUserMedia = vi.fn().mockReturnValue(pending) + Object.defineProperty(navigator, 'mediaDevices', { + value: { getUserMedia }, + configurable: true, + }) + const startSpy = vi.spyOn(FakeMediaRecorder.prototype, 'start') + + const { result, unmount } = renderHook(() => useVoiceRecorder()) + + // 권한 프롬프트 대기(getUserMedia 미해결) 중 start 호출. + let startPromise!: Promise + act(() => { + startPromise = result.current.start() + }) + expect(result.current.status).toBe('requesting') + + // 대기 중 언마운트 → 뒤늦게 권한 허용. + unmount() + await act(async () => { + resolveStream({ getTracks: () => [track] }) + await startPromise + }) + + // 소유자 없는 트랙은 즉시 정지되고, 녹음(MediaRecorder.start)은 시작되지 않아야 한다. + expect(stop).toHaveBeenCalled() + expect(startSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts index 0f281813..903d7e35 100644 --- a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts @@ -36,7 +36,16 @@ export function useVoiceRecorder() { setStream(null) }, []) - useEffect(() => cleanup, [cleanup]) + // 언마운트 정리. requestId 를 올려 아직 pending 인 getUserMedia 를 무효화한다 — + // 안 그러면 resolve 가 언마운트 뒤에 도착해 start() 가드(아래 requestId 비교)를 통과, + // 소유자 없는 녹음이 시작되며 마이크 표시등이 켜진 채 남는다. + useEffect( + () => () => { + requestIdRef.current += 1 + cleanup() + }, + [cleanup], + ) const start = useCallback(async (): Promise => { if (status === 'unsupported' || status === 'recording') return false diff --git a/frontend/src/features/interview/lib/media/useWebcamPreview.ts b/frontend/src/features/interview/lib/media/useWebcamPreview.ts index b4e9397e..8218962d 100644 --- a/frontend/src/features/interview/lib/media/useWebcamPreview.ts +++ b/frontend/src/features/interview/lib/media/useWebcamPreview.ts @@ -9,7 +9,13 @@ export function useWebcamPreview() { const streamRef = useRef(null) const [state, setState] = useState('idle') + // 권한 프롬프트는 사용자가 방치하면 오래 pending 이다. 그 사이 stop() 이나 언마운트가 + // 일어나면 이 값을 올려 뒤늦게 도착한 스트림을 무효화한다 — 안 그러면 소유자 없는 + // 트랙이 살아남아 카메라 LED 가 계속 켜진 채로 남는다. + const requestIdRef = useRef(0) + const stop = useCallback(() => { + requestIdRef.current += 1 streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current = null if (videoRef.current) { @@ -23,13 +29,20 @@ export function useWebcamPreview() { setState('unsupported') return } + const requestId = ++requestIdRef.current setState('requesting') try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + // 대기 중에 stop()/언마운트로 무효화됐으면 방금 열린 트랙을 바로 닫는다. + if (requestIdRef.current !== requestId) { + stream.getTracks().forEach((t) => t.stop()) + return + } streamRef.current = stream setState('live') } catch { - setState('denied') + // 무효화 뒤 늦게 거부가 오면 상태를 되돌리지 않는다. + if (requestIdRef.current === requestId) setState('denied') } }, []) @@ -41,8 +54,11 @@ export function useWebcamPreview() { }, [state]) // 언마운트 시 트랙 정리 (카메라 LED 가 계속 켜지는 것 방지 — 필수). + // requestId 를 올려 아직 pending 인 getUserMedia 도 무효화한다 (resolve 가 언마운트 + // 뒤에 와도 start() 가 그 트랙을 바로 닫는다). useEffect( () => () => { + requestIdRef.current += 1 streamRef.current?.getTracks().forEach((t) => t.stop()) }, [], diff --git a/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx b/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx index a91fd15c..b7f659e2 100644 --- a/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx +++ b/frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx @@ -49,6 +49,25 @@ describe('WebcamSelfView', () => { unmount() expect(stop).toHaveBeenCalled() }) + + it('권한 대기 중 언마운트되면 뒤늦게 도착한 스트림도 정지한다 (LED 잔존 방지)', async () => { + const stop = vi.fn() + const stream = { getTracks: () => [{ stop }] } + let resolveStream!: (s: typeof stream) => void + const pending = new Promise((res) => { + resolveStream = res + }) + mockMediaDevices(vi.fn().mockReturnValue(pending)) + + const { unmount } = render() + await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' })) + // 프롬프트 대기 중(getUserMedia 미해결) 상태에서 언마운트. + unmount() + // 권한이 뒤늦게 허용됨 → 소유자 없는 트랙은 즉시 정지되어야 한다. + resolveStream(stream) + await pending + expect(stop).toHaveBeenCalled() + }) }) beforeEach(() => {