From d0a1ce5ebf71c3bfa20588305bb38d33837fd1fc Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:41:11 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[FEAT]=20=ED=83=AD=20=EB=B3=B5=EA=B7=80?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=90=EC=A7=80=ED=95=98=EB=8A=94=20useDocumentV?= =?UTF-8?q?isibility=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저는 백그라운드 탭의 타이머와 네트워크를 억제한다. 라이브 공유는 탭이 돌아온 시점에 소켓 연결과 화면 상태를 다시 맞춰야 하므로, hidden에서 visible로 바뀔 때 콜백을 실행하는 공용 훅을 둔다. 탭을 빠르게 오가도 콜백이 반복 실행되지 않도록 최소 실행 간격을 둔다. Co-Authored-By: Claude Opus 5 --- src/hooks/useDocumentVisibility.test.ts | 86 +++++++++++++++++++++++++ src/hooks/useDocumentVisibility.ts | 58 +++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/hooks/useDocumentVisibility.test.ts create mode 100644 src/hooks/useDocumentVisibility.ts diff --git a/src/hooks/useDocumentVisibility.test.ts b/src/hooks/useDocumentVisibility.test.ts new file mode 100644 index 00000000..567d8b55 --- /dev/null +++ b/src/hooks/useDocumentVisibility.test.ts @@ -0,0 +1,86 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useDocumentVisibility from './useDocumentVisibility'; + +function setVisibilityState(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); +} + +function fireVisibilityChange(state: DocumentVisibilityState) { + setVisibilityState(state); + document.dispatchEvent(new Event('visibilitychange')); +} + +describe('useDocumentVisibility', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-25T10:00:00Z')); + setVisibilityState('visible'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('탭이 다시 보이게 되면 콜백을 실행한다', () => { + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible)); + + fireVisibilityChange('visible'); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('탭이 숨겨질 때는 콜백을 실행하지 않는다', () => { + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible)); + + fireVisibilityChange('hidden'); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('스로틀 시간 안의 재실행은 건너뛴다', () => { + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible, { throttleMs: 1000 })); + + fireVisibilityChange('visible'); + vi.advanceTimersByTime(999); + fireVisibilityChange('visible'); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('스로틀 시간이 지나면 다시 실행한다', () => { + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible, { throttleMs: 1000 })); + + fireVisibilityChange('visible'); + vi.advanceTimersByTime(1000); + fireVisibilityChange('visible'); + + expect(onVisible).toHaveBeenCalledTimes(2); + }); + + it('비활성화하면 콜백을 실행하지 않는다', () => { + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible, { enabled: false })); + + fireVisibilityChange('visible'); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('언마운트하면 리스너를 제거한다', () => { + const onVisible = vi.fn(); + const { unmount } = renderHook(() => useDocumentVisibility(onVisible)); + + unmount(); + fireVisibilityChange('visible'); + + expect(onVisible).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useDocumentVisibility.ts b/src/hooks/useDocumentVisibility.ts new file mode 100644 index 00000000..13610a1d --- /dev/null +++ b/src/hooks/useDocumentVisibility.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +/** + * 탭이 백그라운드에서 돌아올 때(`hidden` → `visible`) 콜백을 실행하는 훅입니다. + * + * 브라우저는 백그라운드 탭의 타이머와 네트워크를 억제하므로, 돌아온 시점에 + * 소켓 연결과 화면 상태를 다시 맞춰야 합니다. + * + * @param onVisible - 탭이 다시 보이게 되었을 때 실행할 콜백 + * @param options.throttleMs - 직전 실행 이후 이 시간 안에는 다시 실행하지 않습니다. (기본값 1000ms) + * @param options.enabled - false면 아무것도 구독하지 않습니다. (기본값 true) + */ +interface UseDocumentVisibilityOptions { + throttleMs?: number; + enabled?: boolean; +} + +export default function useDocumentVisibility( + onVisible: () => void, + options: UseDocumentVisibilityOptions = {}, +) { + const { throttleMs = 1000, enabled = true } = options; + + // 콜백이 매 렌더 새로 만들어져도 리스너를 다시 등록하지 않도록 ref로 읽는다 + const onVisibleRef = useRef(onVisible); + useEffect(() => { + onVisibleRef.current = onVisible; + }, [onVisible]); + + const lastRunAtRef = useRef(null); + + useEffect(() => { + if (!enabled) { + return; + } + + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') { + return; + } + + const now = Date.now(); + const lastRunAt = lastRunAtRef.current; + if (lastRunAt !== null && now - lastRunAt < throttleMs) { + return; + } + + lastRunAtRef.current = now; + onVisibleRef.current(); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, throttleMs]); +} From 40be421cd2701ef01b59dd9f16abcbfde29c0091 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:42:29 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[FIX]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=ED=83=AD=20=EB=B3=B5=EA=B7=80=20=EC=8B=9C=20=EC=A6=89=EC=8B=9C?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B3=B5=EC=9C=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저가 백그라운드 탭의 타이머를 억제하면 5초 heartbeat가 늦어지고, 청중은 15초 동안 사회자 메시지를 받지 못해 연결이 끊긴 것으로 오해한다. 탭이 돌아오면 heartbeat를 기다리지 않고 곧바로 현재 상태를 공유한다. 돌아온 시점에 연결이 이미 끊겨 있었다면 공유를 다시 시작한다. 공유를 시작한 적이 없거나 다른 곳에 밀려난 세션은 대상이 아니다. Co-Authored-By: Claude Opus 5 --- src/hooks/sockets/useChairmanSocket.test.ts | 109 ++++++++++++++++++++ src/hooks/sockets/useChairmanSocket.ts | 33 ++++++ 2 files changed, 142 insertions(+) diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts index 6a9ffbac..8df4fc00 100644 --- a/src/hooks/sockets/useChairmanSocket.test.ts +++ b/src/hooks/sockets/useChairmanSocket.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'; import type { IMessage } from '@stomp/stompjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { TimerDataPayload } from '../../apis/sockets/type'; +import { socketManager } from '../../apis/sockets/SocketManager'; import useChairmanSocket from './useChairmanSocket'; const useSocketMock = vi.hoisted(() => vi.fn()); @@ -606,4 +607,112 @@ describe('useChairmanSocket', () => { expect(onSyncRequest).not.toHaveBeenCalled(); }); }); + + describe('탭 복귀', () => { + const setConnected = (isConnected: boolean) => { + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + publish, + addConnectionListener, + isConnected, + error: null, + }); + }; + + const setVisibilityState = (state: DocumentVisibilityState) => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); + }; + + const returnToForeground = () => { + act(() => { + setVisibilityState('visible'); + document.dispatchEvent(new Event('visibilitychange')); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-25T10:00:00Z')); + setVisibilityState('visible'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('공유를 시작하지 않았으면 복귀해도 아무것도 하지 않아야 한다', () => { + setConnected(false); + vi.spyOn(socketManager, 'isConnected').mockReturnValue(false); + const onSyncRequest = vi.fn(); + + renderHook(() => useChairmanSocket(123, { onSyncRequest })); + returnToForeground(); + + expect(connect).not.toHaveBeenCalled(); + expect(onSyncRequest).not.toHaveBeenCalled(); + }); + + it('공유 중 연결이 살아 있으면 복귀 시 즉시 상태를 공유해야 한다', () => { + setConnected(true); + vi.spyOn(socketManager, 'isConnected').mockReturnValue(true); + const onSyncRequest = vi.fn(); + + const { result } = renderHook(() => + useChairmanSocket(123, { onSyncRequest }), + ); + act(() => { + result.current.connect(); + }); + onSyncRequest.mockClear(); + + returnToForeground(); + + expect(onSyncRequest).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); + + it('공유 중 연결이 끊겼으면 복귀 시 다시 연결해야 한다', () => { + setConnected(false); + vi.spyOn(socketManager, 'isConnected').mockReturnValue(false); + const onSyncRequest = vi.fn(); + + const { result } = renderHook(() => + useChairmanSocket(123, { onSyncRequest }), + ); + act(() => { + result.current.connect(); + }); + onSyncRequest.mockClear(); + + returnToForeground(); + + expect(connect).toHaveBeenCalledTimes(2); + expect(onSyncRequest).not.toHaveBeenCalled(); + }); + + it('짧은 간격으로 복귀를 반복해도 한 번만 상태를 공유해야 한다', () => { + setConnected(true); + vi.spyOn(socketManager, 'isConnected').mockReturnValue(true); + const onSyncRequest = vi.fn(); + + const { result } = renderHook(() => + useChairmanSocket(123, { onSyncRequest }), + ); + act(() => { + result.current.connect(); + }); + onSyncRequest.mockClear(); + + returnToForeground(); + returnToForeground(); + + expect(onSyncRequest).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts index 2ff13a49..f8694f23 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -7,13 +7,18 @@ import { SocketMessage, TimerDataPayload, } from '../../apis/sockets/type'; +import { socketManager } from '../../apis/sockets/SocketManager'; import { isChairmanNotice, isTimerEventType } from '../../apis/sockets/util'; +import useDocumentVisibility from '../useDocumentVisibility'; import { chairmanTokenQueryKey } from '../query/useGetChairmanToken'; import useSocket from './useSocket'; // 청중이 사회자 연결 여부를 판단할 수 있도록 현재 상태를 주기적으로 공유하는 간격 export const CHAIRMAN_HEARTBEAT_INTERVAL_MS = 5000; +// 탭이 돌아왔을 때 상태 공유를 다시 시도하는 최소 간격 +const VISIBILITY_SYNC_THROTTLE_MS = 1000; + /** * 공유를 시작할 때마다 새 사회자 세션 식별자를 만든다. * `crypto.randomUUID`는 보안 컨텍스트(HTTPS)에서만 제공되므로 없으면 시각과 난수로 대신한다. @@ -112,6 +117,9 @@ export default function useChairmanSocket( // 마지막으로 발행한 이벤트의 version const versionRef = useRef(0); + // 사용자가 공유를 시작했는지 여부 (탭 복귀 시 자동 재연결 여부를 가른다) + const hasStartedSharingRef = useRef(false); + /** * 현재 사회자 소켓 세션에서 누적된 신호 메타데이터를 초기화합니다. * 세션 간에 오래된 신호 수나 타임스탬프가 남지 않도록, 래핑된 connect 및 @@ -132,6 +140,7 @@ export default function useChairmanSocket( resetSignalState(); chairmanSessionIdRef.current = createChairmanSessionId(); setIsReplaced(false); + hasStartedSharingRef.current = true; connect(options); }, [connect, resetSignalState], @@ -147,6 +156,7 @@ export default function useChairmanSocket( */ const disconnectChairmanSocket = useCallback(() => { resetSignalState(); + hasStartedSharingRef.current = false; disconnect(); queryClient.removeQueries({ queryKey: chairmanTokenQueryKey(String(roomId)), @@ -175,9 +185,32 @@ export default function useChairmanSocket( const handleReplaced = useCallback(() => { setIsReplaced(true); resetSignalState(); + hasStartedSharingRef.current = false; disconnect(); }, [disconnect, resetSignalState]); + /** + * 백그라운드 탭에서 돌아오면 억제됐던 heartbeat를 기다리지 않고 곧바로 현재 상태를 공유한다. + * 청중은 사회자 메시지가 끊기면 연결이 끊긴 것으로 보기 때문에, 복귀 즉시 알려야 오해가 풀린다. + * 돌아왔을 때 연결이 이미 끊겨 있었다면 공유를 다시 시작한다. + */ + const handleVisible = useCallback(() => { + if (!hasStartedSharingRef.current) { + return; + } + + if (!socketManager.isConnected()) { + connectChairmanSocket(); + return; + } + + onSyncRequestRef.current?.(); + }, [connectChairmanSocket]); + + useDocumentVisibility(handleVisible, { + throttleMs: VISIBILITY_SYNC_THROTTLE_MS, + }); + // 서버로부터 토론 이벤트를 갱신해달라는 요청과 활성 사회자 교체 알림을 받게 될 채널 구독 useEffect(() => { const destination = `/chairman/${roomId}`; From 270bfb7676cb3d5ce5b8a3f6a44fd3a0d24ab5df Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:43:24 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[FIX]=20=EC=B2=AD=EC=A4=91=20=ED=83=AD=20?= =?UTF-8?q?=EB=B3=B5=EA=B7=80=20=EC=8B=9C=20=ED=98=84=EC=9E=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=9E=AC=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백그라운드 탭에서는 브라우저가 타이머와 네트워크를 억제해 사회자 상태와 어긋난 채로 돌아올 수 있다. 구독이 유지되면 서버의 상태 공유 요청도 다시 일어나지 않아 스스로 맞출 방법이 없다. 탭이 돌아오면 룸 채널을 다시 구독한다. 서버는 청중이 구독할 때 사회자에게 상태 공유를 요청하므로, 재구독이 그대로 재동기화 요청이 된다. 연결이 끊긴 채로 돌아왔다면 다시 연결한다. 불필요한 구독 왕복을 줄이기 위해 재동기화에 최소 간격을 둔다. Co-Authored-By: Claude Opus 5 --- src/hooks/sockets/useAudienceSocket.test.ts | 75 +++++++++++++++++++++ src/hooks/sockets/useAudienceSocket.ts | 74 ++++++++++++++++---- 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts index d37f82ee..6723272d 100644 --- a/src/hooks/sockets/useAudienceSocket.test.ts +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -10,6 +10,7 @@ import { MockInstance, } from 'vitest'; import type { SocketMessage } from '../../apis/sockets/type'; +import { socketManager } from '../../apis/sockets/SocketManager'; import useAudienceSocket from './useAudienceSocket'; const useSocketMock = vi.hoisted(() => vi.fn()); @@ -604,4 +605,78 @@ describe('useAudienceSocket', () => { }); }); }); + + describe('탭 복귀', () => { + const setVisibilityState = (state: DocumentVisibilityState) => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); + }; + + const returnToForeground = () => { + act(() => { + setVisibilityState('visible'); + document.dispatchEvent(new Event('visibilitychange')); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-25T10:00:00Z')); + setVisibilityState('visible'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('연결이 살아 있으면 복귀 시 채널을 다시 구독해 재동기화를 요청해야 한다', () => { + vi.spyOn(socketManager, 'isConnected').mockReturnValue(true); + + renderHook(() => useAudienceSocket(123)); + subscribe.mockClear(); + + returnToForeground(); + + expect(unsubscribe).toHaveBeenCalledWith('/room/123'); + expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function)); + }); + + it('연결이 끊겼으면 복귀 시 다시 연결해야 한다', () => { + vi.spyOn(socketManager, 'isConnected').mockReturnValue(false); + + renderHook(() => useAudienceSocket(123)); + subscribe.mockClear(); + + returnToForeground(); + + expect(connect).toHaveBeenCalledTimes(1); + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('짧은 간격으로 복귀를 반복해도 한 번만 재동기화를 요청해야 한다', () => { + vi.spyOn(socketManager, 'isConnected').mockReturnValue(true); + + renderHook(() => useAudienceSocket(123)); + subscribe.mockClear(); + + returnToForeground(); + returnToForeground(); + + expect(subscribe).toHaveBeenCalledTimes(1); + }); + + it('비활성 상태에서는 복귀해도 재동기화하지 않아야 한다', () => { + vi.spyOn(socketManager, 'isConnected').mockReturnValue(true); + + renderHook(() => useAudienceSocket(123, { enabled: false })); + subscribe.mockClear(); + + returnToForeground(); + + expect(subscribe).not.toHaveBeenCalled(); + expect(connect).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts index 1d96ddfe..73975d72 100644 --- a/src/hooks/sockets/useAudienceSocket.ts +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -1,8 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { IMessage } from '@stomp/stompjs'; import useSocket from './useSocket'; +import { socketManager } from '../../apis/sockets/SocketManager'; import { SocketMessage } from '../../apis/sockets/type'; import { isSocketMessage } from '../../apis/sockets/util'; +import useDocumentVisibility from '../useDocumentVisibility'; + +// 탭이 돌아왔을 때 재동기화를 다시 시도하는 최소 간격 +// 서버도 룸별로 상태 공유 요청 간격을 제한하지만, 불필요한 구독 왕복을 먼저 줄인다 +const VISIBILITY_RESYNC_THROTTLE_MS = 3000; /** * 청중 전용 웹소켓 훅입니다. @@ -101,18 +107,8 @@ export default function useAudienceSocket( return addConnectionListener(resetMessage); }, [addConnectionListener, resetMessage]); - useEffect(() => { - if (!enabled) { - resetMessage(); - return; - } - - const destination = `/room/${roomId}`; - - resetMessage(); - - // 토론 이벤트를 발행하는 채널 구독 및 메시지 수신 시 상태 업데이트 - subscribe(destination, (message: IMessage) => { + const handleRoomMessage = useCallback( + (message: IMessage) => { try { const parsedData = JSON.parse(message.body); if (isSocketMessage(parsedData)) { @@ -150,14 +146,64 @@ export default function useAudienceSocket( } catch (e) { console.log('메시지 파싱 오류:', e); } - }); + }, + [], + ); + + useEffect(() => { + if (!enabled) { + resetMessage(); + return; + } + + const destination = `/room/${roomId}`; + + resetMessage(); + + // 토론 이벤트를 발행하는 채널 구독 및 메시지 수신 시 상태 업데이트 + subscribe(destination, handleRoomMessage); // 컴포넌트 언마운트 또는 roomId 변경 시 해당 채널 구독 해제 return () => { resetMessage(); unsubscribe(destination); }; - }, [enabled, roomId, resetMessage, subscribe, unsubscribe]); + }, [enabled, roomId, handleRoomMessage, resetMessage, subscribe, unsubscribe]); + + /** + * 백그라운드 탭에서 돌아오면 현재 상태를 다시 받아온다. + * 연결이 살아 있으면 룸 채널을 다시 구독한다. 서버는 청중이 구독할 때 사회자에게 + * 상태 공유를 요청하므로, 재구독이 곧 재동기화 요청이 된다. + * 연결이 끊긴 채로 돌아왔다면 다시 연결한다. + */ + const handleVisible = useCallback(() => { + if (!enabled) { + return; + } + + if (!socketManager.isConnected()) { + connectAudienceSocket(); + return; + } + + const destination = `/room/${roomId}`; + unsubscribe(destination); + resetMessage(); + subscribe(destination, handleRoomMessage); + }, [ + connectAudienceSocket, + enabled, + handleRoomMessage, + resetMessage, + roomId, + subscribe, + unsubscribe, + ]); + + useDocumentVisibility(handleVisible, { + throttleMs: VISIBILITY_RESYNC_THROTTLE_MS, + enabled, + }); return { latestMessage, From 83d9e26c345584f9028e90588d0bee75c366576a Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:45:49 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[FIX]=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=86=8C=EC=A7=84=20=EC=8B=9C=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=9C=A0=EC=A7=80=ED=95=9C=20?= =?UTF-8?q?=EC=B1=84=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재연결 시도를 모두 소진하면 소켓이 스스로 되살아나지 않는다. 지금은 이때 전체 오류 화면으로 바뀌어 청중이 보던 타이머 맥락을 통째로 잃는다. 보여주던 화면이 있으면 오류로 넘기지 않고, 마지막 타이머를 남긴 채 새로고침 안내 오버레이를 덮는다. 카운트다운은 사회자 부재와 같은 방식으로 마지막 값에서 멈춘다. 한 번도 화면을 띄우지 못했다면 기존 오류 화면을 쓴다. 서버 여유가 넉넉하지 않아 수동 재연결 버튼 대신 새로고침으로 유도한다. Co-Authored-By: Claude Opus 5 --- src/hooks/sockets/useAudienceSocket.ts | 76 ++++++------- .../AudienceSharePage.test.tsx | 101 ++++++++++++++++++ .../AudienceSharePage/AudienceSharePage.tsx | 18 +++- .../components/ConnectionLostNotice.tsx | 38 +++++++ .../hooks/useAudienceShareState.test.ts | 89 +++++++++++++++ .../hooks/useAudienceShareState.ts | 36 ++++++- 6 files changed, 314 insertions(+), 44 deletions(-) create mode 100644 src/page/AudienceSharePage/components/ConnectionLostNotice.tsx diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts index 73975d72..e3584e3d 100644 --- a/src/hooks/sockets/useAudienceSocket.ts +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -107,48 +107,45 @@ export default function useAudienceSocket( return addConnectionListener(resetMessage); }, [addConnectionListener, resetMessage]); - const handleRoomMessage = useCallback( - (message: IMessage) => { - try { - const parsedData = JSON.parse(message.body); - if (isSocketMessage(parsedData)) { - // 사회자 부재 알림은 서버가 보낸 것이므로 사회자 메시지 수신 기록과 version 기준에 반영하지 않는다 - if (parsedData.eventType === 'CHAIRMAN_ABSENT') { - setChairmanAbsentAt(Date.now()); - return; - } + const handleRoomMessage = useCallback((message: IMessage) => { + try { + const parsedData = JSON.parse(message.body); + if (isSocketMessage(parsedData)) { + // 사회자 부재 알림은 서버가 보낸 것이므로 사회자 메시지 수신 기록과 version 기준에 반영하지 않는다 + if (parsedData.eventType === 'CHAIRMAN_ABSENT') { + setChairmanAbsentAt(Date.now()); + return; + } - // 오래된 version이라 반영하지 않더라도 사회자가 메시지를 보내고 있다는 신호로 본다 - const receivedAt = Date.now(); - setLastReceivedAt(receivedAt); + // 오래된 version이라 반영하지 않더라도 사회자가 메시지를 보내고 있다는 신호로 본다 + const receivedAt = Date.now(); + setLastReceivedAt(receivedAt); - const { version } = parsedData; - const lastVersion = lastVersionRef.current; - const hasVersion = version !== undefined && version !== null; + const { version } = parsedData; + const lastVersion = lastVersionRef.current; + const hasVersion = version !== undefined && version !== null; - // version 없는 메시지는 기준이 생기기 전(하위 호환, 종료된 룸 입장 등)에만 반영 - if (!hasVersion && lastVersion !== null) { - return; - } + // version 없는 메시지는 기준이 생기기 전(하위 호환, 종료된 룸 입장 등)에만 반영 + if (!hasVersion && lastVersion !== null) { + return; + } - if (hasVersion) { - if (lastVersion !== null && version <= lastVersion) { - return; - } - lastVersionRef.current = version; + if (hasVersion) { + if (lastVersion !== null && version <= lastVersion) { + return; } - - setLatestMessage(parsedData); - setLatestMessageReceivedAt(receivedAt); - } else { - console.log('잘못된 소켓 메시지 형식입니다:', parsedData); + lastVersionRef.current = version; } - } catch (e) { - console.log('메시지 파싱 오류:', e); + + setLatestMessage(parsedData); + setLatestMessageReceivedAt(receivedAt); + } else { + console.log('잘못된 소켓 메시지 형식입니다:', parsedData); } - }, - [], - ); + } catch (e) { + console.log('메시지 파싱 오류:', e); + } + }, []); useEffect(() => { if (!enabled) { @@ -168,7 +165,14 @@ export default function useAudienceSocket( resetMessage(); unsubscribe(destination); }; - }, [enabled, roomId, handleRoomMessage, resetMessage, subscribe, unsubscribe]); + }, [ + enabled, + roomId, + handleRoomMessage, + resetMessage, + subscribe, + unsubscribe, + ]); /** * 백그라운드 탭에서 돌아오면 현재 상태를 다시 받아온다. diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index 4f6682f0..255e3617 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Component, ReactNode } from 'react'; @@ -144,6 +145,7 @@ describe('AudienceSharePage', () => { ); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'connecting', error: null, }); @@ -201,6 +203,7 @@ describe('AudienceSharePage', () => { it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'connecting', error: null, }); @@ -223,6 +226,7 @@ describe('AudienceSharePage', () => { it('waiting 상태에서는 서버 데이터 대기 번역 문구가 표시된다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'waiting', error: null, }); @@ -264,6 +268,7 @@ describe('AudienceSharePage', () => { ); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'waiting', error: null, }); @@ -279,6 +284,7 @@ describe('AudienceSharePage', () => { it('모바일에서는 헤더 대신 본문 상단에 주제를 표시한다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'waiting', error: null, }); @@ -296,6 +302,7 @@ describe('AudienceSharePage', () => { it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -325,6 +332,7 @@ describe('AudienceSharePage', () => { it('NORMAL sequence의 CONS 항목에 반대 팀 정보를 연결한다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -351,6 +359,7 @@ describe('AudienceSharePage', () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -387,6 +396,7 @@ describe('AudienceSharePage', () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -422,6 +432,7 @@ describe('AudienceSharePage', () => { it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -456,6 +467,7 @@ describe('AudienceSharePage', () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -502,6 +514,7 @@ describe('AudienceSharePage', () => { it('finished 상태가 되면 종료 안내 페이지로 이동한다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'finished', error: null, }); @@ -547,6 +560,7 @@ describe('AudienceSharePage', () => { ); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -585,6 +599,7 @@ describe('AudienceSharePage', () => { ); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -637,6 +652,7 @@ describe('AudienceSharePage', () => { it('사회자 메시지를 기다리는 중이면 타이머와 함께 대기 안내를 표시한다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'waiting', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -652,6 +668,7 @@ describe('AudienceSharePage', () => { it('사회자가 연결되어 있으면 안내를 표시하지 않는다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -667,6 +684,7 @@ describe('AudienceSharePage', () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -687,6 +705,7 @@ describe('AudienceSharePage', () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'absent', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -708,6 +727,7 @@ describe('AudienceSharePage', () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'absent', + connectionStatus: 'connected', status: 'displaying', syncedAt: null, error: null, @@ -762,6 +782,7 @@ describe('AudienceSharePage', () => { it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', async () => { mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'connecting', error: new AudienceShareError('SOCKET_STOMP_ERROR'), }); @@ -784,6 +805,7 @@ describe('AudienceSharePage', () => { ); mockUseAudienceShareState.mockReturnValue({ chairmanPresence: 'present', + connectionStatus: 'connected', status: 'connecting', error: new AudienceShareError('SOCKET_STOMP_ERROR'), }); @@ -800,4 +822,83 @@ describe('AudienceSharePage', () => { ).not.toBeInTheDocument(); }); }); + + describe('서버 연결 끊김 안내', () => { + const CONNECTION_LOST_MESSAGE = '서버 연결이 끊겼어요. 새로고침 해주세요.'; + + const normalDisplayData = { + timerType: 'NORMAL' as const, + currentTeam: null, + isRunning: true, + singleTime: 10, + sequence: 0, + }; + + it('연결이 끊기면 타이머를 남긴 채 새로고침 안내를 표시한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'present', + connectionStatus: 'lost', + status: 'displaying', + syncedAt: null, + error: null, + displayData: normalDisplayData, + }); + + renderPage('/live/123'); + + expect( + await screen.findByText(CONNECTION_LOST_MESSAGE), + ).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '새로고침' }), + ).toBeInTheDocument(); + }); + + it('새로고침 버튼을 누르면 페이지를 다시 불러온다', async () => { + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload }, + }); + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'present', + connectionStatus: 'lost', + status: 'displaying', + syncedAt: null, + error: null, + displayData: normalDisplayData, + }); + + renderPage('/live/123'); + const reloadButton = await screen.findByRole('button', { + name: '새로고침', + }); + await userEvent.click(reloadButton); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('연결이 끊기면 사회자 부재 안내는 표시하지 않는다', async () => { + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'absent', + connectionStatus: 'lost', + status: 'displaying', + syncedAt: null, + error: null, + displayData: normalDisplayData, + }); + + renderPage('/live/123'); + + expect( + await screen.findByText(CONNECTION_LOST_MESSAGE), + ).toBeInTheDocument(); + expect( + screen.queryByText( + '사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.', + ), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 11274e6f..110f1dae 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -8,6 +8,7 @@ import { useAudienceTimeBasedCountdown } from './hooks/useAudienceTimeBasedCount import AudienceNormalTimer from './components/AudienceNormalTimer'; import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; import ChairmanStatusNotice from './components/ChairmanStatusNotice'; +import ConnectionLostNotice from './components/ConnectionLostNotice'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import LoadingSpinner from '../../components/LoadingSpinner'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; @@ -88,8 +89,12 @@ export default function AudienceSharePage() { t, ); - // 사회자 연결이 끊기면 마지막으로 보던 시간에서 카운트다운을 멈춘다 - const isChairmanAbsent = state.chairmanPresence === 'absent'; + // 서버와의 연결이 끊겨 스스로 복구할 수 없는 상태 (새로고침이 필요) + const isConnectionLost = state.connectionStatus === 'lost'; + + // 사회자 연결이 끊기거나 서버 연결이 끊기면 마지막으로 보던 시간에서 카운트다운을 멈춘다 + const isChairmanAbsent = + state.chairmanPresence === 'absent' || isConnectionLost; // 네트워크 지연 보정 기준 시각 const syncedAt = state.status === 'displaying' ? state.syncedAt : null; @@ -233,8 +238,10 @@ export default function AudienceSharePage() { const isTimerVisible = viewState.type === 'NORMAL_TIMER' || viewState.type === 'TIME_BASED_TIMER'; const shouldShowWaitingNotice = - isTimerVisible && state.chairmanPresence === 'waiting'; - const shouldShowAbsentNotice = isTimerVisible && isChairmanAbsent; + isTimerVisible && state.chairmanPresence === 'waiting' && !isConnectionLost; + const shouldShowConnectionLostNotice = isTimerVisible && isConnectionLost; + const shouldShowAbsentNotice = + isTimerVisible && isChairmanAbsent && !isConnectionLost; const isHeaderVisible = viewState.type === 'WAITING' || @@ -286,6 +293,9 @@ export default function AudienceSharePage() { {shouldShowAbsentNotice ? ( ) : null} + {shouldShowConnectionLostNotice ? ( + + ) : null} diff --git a/src/page/AudienceSharePage/components/ConnectionLostNotice.tsx b/src/page/AudienceSharePage/components/ConnectionLostNotice.tsx new file mode 100644 index 00000000..760af58a --- /dev/null +++ b/src/page/AudienceSharePage/components/ConnectionLostNotice.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next'; +import { MdWifiOff } from 'react-icons/md'; + +interface ConnectionLostNoticeProps { + onReload: () => void; +} + +/** + * 재연결 시도를 모두 소진해 스스로 복구할 수 없을 때, 보고 있던 화면 위에 새로고침을 안내합니다. + * 마지막으로 받은 타이머를 지우지 않고 덮어, 청중이 어디까지 진행됐는지 잃지 않게 합니다. + */ +export default function ConnectionLostNotice({ + onReload, +}: ConnectionLostNoticeProps) { + const { t } = useTranslation(); + + return ( +
+
+ ); +} diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index 71bcbed4..b66ec210 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -1110,4 +1110,93 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('waiting'); }); }); + + describe('재연결 소진', () => { + const playMessage: SocketMessage = { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 60 }, + }; + + it('보여주던 화면이 있으면 오류 대신 연결 끊김으로 표시하고 화면을 유지한다.', () => { + setSocketState({ + isConnected: true, + latestMessage: playMessage, + latestMessageReceivedAt: Date.now(), + lastReceivedAt: Date.now(), + }); + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + expect(result.current.status).toBe('displaying'); + + setSocketState({ + isConnected: false, + latestMessage: playMessage, + error: new SocketError('SOCKET_RETRY_EXHAUSTED', '재연결 소진'), + }); + rerender(); + + expect(result.current.status).toBe('displaying'); + expect(result.current.connectionStatus).toBe('lost'); + expect(result.current.error).toBeNull(); + }); + + it('연결 끊김 상태에서는 소켓을 다시 끊지 않는다.', () => { + setSocketState({ + isConnected: true, + latestMessage: playMessage, + latestMessageReceivedAt: Date.now(), + }); + const { rerender } = renderHook(() => useAudienceShareState(1)); + mockDisconnect.mockClear(); + + setSocketState({ + isConnected: false, + latestMessage: playMessage, + error: new SocketError('SOCKET_RETRY_EXHAUSTED', '재연결 소진'), + }); + rerender(); + + expect(mockDisconnect).not.toHaveBeenCalled(); + }); + + it('보여주던 화면이 없으면 기존처럼 오류로 처리한다.', () => { + setSocketState({ + isConnected: false, + latestMessage: null, + error: new SocketError('SOCKET_RETRY_EXHAUSTED', '재연결 소진'), + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.error).not.toBeNull(); + expect(result.current.connectionStatus).toBe('connected'); + }); + + it('다시 연결되면 연결 끊김 표시를 해제한다.', () => { + setSocketState({ + isConnected: true, + latestMessage: playMessage, + latestMessageReceivedAt: Date.now(), + }); + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + setSocketState({ + isConnected: false, + latestMessage: playMessage, + error: new SocketError('SOCKET_RETRY_EXHAUSTED', '재연결 소진'), + }); + rerender(); + expect(result.current.connectionStatus).toBe('lost'); + + setSocketState({ + isConnected: true, + latestMessage: playMessage, + latestMessageReceivedAt: Date.now(), + error: null, + }); + rerender(); + + expect(result.current.connectionStatus).toBe('connected'); + expect(result.current.status).toBe('displaying'); + }); + }); }); diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index c1070593..e79b4264 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -10,6 +10,13 @@ import { } from './EventInterpreter'; import { getNetworkDelayMs } from './getNetworkDelayMs'; +/** + * 서버 연결 상태 + * - `connected`: 연결되어 있거나 자동 재연결을 시도하는 중 + * - `lost`: 재연결 시도를 모두 소진해 스스로 복구할 수 없음 (새로고침이 필요) + */ +export type AudienceConnectionStatus = 'connected' | 'lost'; + export type AudienceShareState = | { status: 'connecting'; error: AudienceShareError | null } | { status: 'waiting'; error: AudienceShareError | null } @@ -51,7 +58,10 @@ interface UseAudienceShareStateOptions { export function useAudienceShareState( roomId: number, options: UseAudienceShareStateOptions = {}, -): AudienceShareState & { chairmanPresence: ChairmanPresence } { +): AudienceShareState & { + chairmanPresence: ChairmanPresence; + connectionStatus: AudienceConnectionStatus; +} { const { enabled = true, table } = options; const { connect, @@ -64,6 +74,10 @@ export function useAudienceShareState( error: socketError, } = useAudienceSocket(roomId, { enabled }); + // 재연결 시도를 모두 소진하면 자동으로 복구되지 않는다 + const isRetryExhausted = + isSocketError(socketError) && socketError.code === 'SOCKET_RETRY_EXHAUSTED'; + const [error, setError] = useState(null); const [isFinished, setIsFinished] = useState(false); @@ -72,6 +86,9 @@ export function useAudienceShareState( ); const [syncedAt, setSyncedAt] = useState(null); + // 보여주던 화면이 있는 상태에서 재연결이 소진되면, 화면을 지우는 대신 새로고침을 안내한다 + const isConnectionLost = isRetryExhausted && displayData !== null; + const [isChairmanAbsent, setIsChairmanAbsent] = useState(false); const [isFirstMessageTimedOut, setIsFirstMessageTimedOut] = useState(false); @@ -93,8 +110,9 @@ export function useAudienceShareState( }, [connect, cleanup, enabled]); // Handle Socket Error + // 재연결 소진은 이미 보여주던 화면이 있으면 오류로 넘기지 않고, 그 화면을 유지한 채 새로고침을 안내한다 useEffect(() => { - if (enabled && socketError) { + if (enabled && socketError && !isConnectionLost) { cleanup(); let code: AudienceShareErrorCode = 'UNKNOWN'; if (isSocketError(socketError)) { @@ -109,12 +127,15 @@ export function useAudienceShareState( ), ); } - }, [enabled, socketError, cleanup]); + }, [enabled, socketError, isConnectionLost, cleanup]); // Handle Messages and Disconnects useEffect(() => { if (!enabled || error || isFinished) return; // Ignore if inactive, already failed or finished + // 연결이 끊긴 채로 남은 화면은 마지막 상태 그대로 둔다 + if (isConnectionLost) return; + if (!isConnected || !latestMessage) { setDisplayData(null); setSyncedAt(null); @@ -151,6 +172,7 @@ export function useAudienceShareState( }, [ enabled, isConnected, + isConnectionLost, latestMessage, latestMessageReceivedAt, error, @@ -233,12 +255,16 @@ export function useAudienceShareState( }; }, [isWaitingFirstMessage, table]); + const connectionStatus: AudienceConnectionStatus = isConnectionLost + ? 'lost' + : 'connected'; + let status: AudienceShareState['status'] = 'connecting'; if (!enabled) { status = 'connecting'; } else if (isFinished) { status = 'finished'; - } else if (!isConnected) { + } else if (!isConnected && !isConnectionLost) { status = 'connecting'; } else if (!displayData) { status = 'waiting'; @@ -253,6 +279,7 @@ export function useAudienceShareState( displayData: displayData!, syncedAt, chairmanPresence, + connectionStatus, }; } @@ -260,5 +287,6 @@ export function useAudienceShareState( status, error, chairmanPresence, + connectionStatus, }; } From ae98e4388d60059f21d24667dc84090bb4a3608c Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:46:41 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[FIX]=20=EC=82=AC=ED=9A=8C=EC=9E=90?= =?UTF-8?q?=EC=97=90=EA=B2=8C=EB=8F=84=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=81=8A=EA=B9=80=EA=B3=BC=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=EC=9D=84=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재연결 시도를 모두 소진해도 모달에는 "연결에 실패했어요"만 떠서, 사회자가 무엇을 해야 복구되는지 알 수 없었다. 소진된 경우를 별도 오류 유형으로 나눠 새로고침을 안내한다. Co-Authored-By: Claude Opus 5 --- .../components/LiveShareModal.test.tsx | 9 ++++++ .../TimerPage/components/LiveShareModal.tsx | 11 +++++-- src/page/TimerPage/hooks/useLiveShare.test.ts | 30 ++++++++++++++++++- src/page/TimerPage/hooks/useLiveShare.ts | 9 +++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/page/TimerPage/components/LiveShareModal.test.tsx b/src/page/TimerPage/components/LiveShareModal.test.tsx index 5d496dfb..0330375e 100644 --- a/src/page/TimerPage/components/LiveShareModal.test.tsx +++ b/src/page/TimerPage/components/LiveShareModal.test.tsx @@ -131,4 +131,13 @@ describe('LiveShareModal', () => { screen.queryByRole('button', { name: '이 화면에서 다시 공유하기' }), ).not.toBeInTheDocument(); }); + + test('연결이 끊긴 오류에서는 새로고침 안내를 보여준다', () => { + renderLiveShareModal({ isError: true, errorType: 'disconnected' }); + + expect(screen.getByText('라이브 연결 끊김')).toBeInTheDocument(); + expect( + screen.getByText('라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.'), + ).toBeInTheDocument(); + }); }); diff --git a/src/page/TimerPage/components/LiveShareModal.tsx b/src/page/TimerPage/components/LiveShareModal.tsx index ea93d559..af2480c3 100644 --- a/src/page/TimerPage/components/LiveShareModal.tsx +++ b/src/page/TimerPage/components/LiveShareModal.tsx @@ -24,14 +24,19 @@ export default function LiveShareModal({ }: LiveShareModalProps) { const { t } = useTranslation(); const isReplaced = errorType === 'replaced'; - const errorTitle = isReplaced - ? t('다른 곳에서 공유 중') - : t('라이브 공유 불가'); + const isDisconnected = errorType === 'disconnected'; + let errorTitle = t('라이브 공유 불가'); + if (isReplaced) { + errorTitle = t('다른 곳에서 공유 중'); + } else if (isDisconnected) { + errorTitle = t('라이브 연결 끊김'); + } const errorMessage = { token: t('사회자 인증 토큰 발급에 실패했어요...'), replaced: t( '다른 탭이나 기기에서 라이브 공유를 시작해서 이 화면의 공유를 멈췄어요.', ), + disconnected: t('라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.'), else: t('라이브 서버 연결에 실패했어요...'), }[errorType]; diff --git a/src/page/TimerPage/hooks/useLiveShare.test.ts b/src/page/TimerPage/hooks/useLiveShare.test.ts index b1dfad36..7e118543 100644 --- a/src/page/TimerPage/hooks/useLiveShare.test.ts +++ b/src/page/TimerPage/hooks/useLiveShare.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SocketError } from '../../../apis/sockets/error'; import { useLiveShare } from './useLiveShare'; const useChairmanSocketMock = vi.hoisted(() => vi.fn()); @@ -43,7 +44,11 @@ describe('useLiveShare', () => { } function mockChairmanSocket( - overrides: Partial<{ isConnected: boolean; isReplaced: boolean }> = {}, + overrides: Partial<{ + isConnected: boolean; + isReplaced: boolean; + error: Error | null; + }> = {}, ) { useChairmanSocketMock.mockReturnValue({ connect, @@ -174,4 +179,27 @@ describe('useLiveShare', () => { expect(refetch).not.toHaveBeenCalled(); }); }); + + describe('재연결 시도를 모두 소진했을 때', () => { + it('새로고침을 안내하는 오류 유형으로 알린다', () => { + mockChairmanSocket({ + error: new SocketError('SOCKET_RETRY_EXHAUSTED', '재연결 소진'), + }); + + const { result } = renderHook(() => useLiveShare(1)); + + expect(result.current.isError).toBe(true); + expect(result.current.errorType).toBe('disconnected'); + }); + + it('그 밖의 소켓 오류는 기존 오류 유형을 유지한다', () => { + mockChairmanSocket({ + error: new SocketError('SOCKET_STOMP_ERROR', 'STOMP 오류'), + }); + + const { result } = renderHook(() => useLiveShare(1)); + + expect(result.current.errorType).toBe('else'); + }); + }); }); diff --git a/src/page/TimerPage/hooks/useLiveShare.ts b/src/page/TimerPage/hooks/useLiveShare.ts index e8923e4a..423499d5 100644 --- a/src/page/TimerPage/hooks/useLiveShare.ts +++ b/src/page/TimerPage/hooks/useLiveShare.ts @@ -3,6 +3,7 @@ import { socketManager } from '../../../apis/sockets/SocketManager'; import useChairmanSocket from '../../../hooks/sockets/useChairmanSocket'; import useGetChairmanToken from '../../../hooks/query/useGetChairmanToken'; import { SocketEventType, TimerDataPayload } from '../../../apis/sockets/type'; +import { isSocketError } from '../../../apis/sockets/error'; import { getJwtExpiresAt } from '../../../util/jwt'; // 사회자 토큰이 만료되기 이 시간 전에 새 토큰을 받아 발행이 끊기지 않게 한다 @@ -12,9 +13,10 @@ export const CHAIRMAN_TOKEN_REFRESH_BEFORE_MS = 60 * 1000; * 라이브 공유 오류 유형 * - `token`: 사회자 토큰 발급 실패 또는 잘못된 테이블 * - `replaced`: 다른 탭/기기에서 공유를 시작해 이 화면의 공유가 멈춤 + * - `disconnected`: 재연결 시도를 모두 소진해 자동으로 복구되지 않음 (새로고침 필요) * - `else`: 그 밖의 소켓 연결 오류 */ -export type LiveShareErrorType = 'token' | 'replaced' | 'else'; +export type LiveShareErrorType = 'token' | 'replaced' | 'disconnected' | 'else'; interface UseLiveShareOptions { /** 서버가 현재 타이머 상태 공유를 요청했을 때 호출됩니다. */ @@ -93,6 +95,11 @@ export function useLiveShare( errorType = 'token'; } else if (isReplaced) { errorType = 'replaced'; + } else if ( + isSocketError(socketError) && + socketError.code === 'SOCKET_RETRY_EXHAUSTED' + ) { + errorType = 'disconnected'; } // 토큰을 첨부하여 토론 이벤트를 전송하는 함수 From d5075b8fc2ef50aba3bf31154f5b1a0ae9f28e7d Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 18:47:21 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[DOCS]=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=86=8C=EC=A7=84=EA=B3=BC=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=ED=83=AD=20=EB=B3=B5=EA=B7=80=20=EA=B7=9C?= =?UTF-8?q?=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 7(재연결 소진)과 Case 9(백그라운드 탭) 대응을 동기화 규약 문서에 반영하고, 새로 추가한 안내 문구의 번역 키를 채운다. Co-Authored-By: Claude Opus 5 --- docs/live-share-timer-sync.md | 23 +++++++++++++++++++++++ public/locales/en/translation.json | 6 +++++- public/locales/ko/translation.json | 6 +++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index 8d9aad8f..963b93c4 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -57,3 +57,26 @@ - 이전/다음 차례·토론 종료 버튼은 한 번 누르면 500ms(`ROUND_CONTROL_COOLDOWN_MS`) 동안 다시 누를 수 없습니다. 세 버튼이 대기 시간을 공유하므로, 마지막 직전 차례에서 다음 차례를 더블클릭해도 같은 자리에 나타난 토론 종료가 눌리지 않습니다. (`RoundControlRow`) - 핫키를 길게 눌러 생기는 반복 입력(`event.repeat`)은 무시합니다. (`useTimerHotkey`) - **서버 검증은 두지 않습니다.** 서버는 룸의 현재 순서를 알지 못하는 중계자이고, 의도적으로 빠르게 넘긴 것과 실수를 구분할 수 없습니다. `NEXT`/`BEFORE`는 이동 전 순서(`sequence`)를 담은 절대값이라 같은 이벤트가 중복 도착해도 청중 결과는 같고, 오래된 이벤트는 `version` 검증이 막습니다. + +## 5. 재연결과 백그라운드 탭 (Case 7, 9) + +### 재연결 시도 소진 (Case 7) + +- 소켓은 끊기면 최대 3회까지 자동으로 재연결합니다. (풀 지터 + 지수 백오프, `SocketManager`) +- 3회를 모두 실패하면 `SOCKET_RETRY_EXHAUSTED` 오류와 함께 클라이언트를 완전히 닫습니다. 이 상태는 자동으로 복구되지 않습니다. +- **청중**: 이미 타이머를 보고 있었다면 화면을 지우지 않고, 마지막 상태를 남긴 채 새로고침 안내 오버레이를 덮습니다. 카운트다운은 사회자 부재와 같은 방식으로 마지막 값에서 멈춥니다. 한 번도 타이머를 표시하지 못했다면 기존 오류 화면을 씁니다. (`useAudienceShareState`, `ConnectionLostNotice`) +- **사회자**: 라이브 공유 모달에 연결이 끊겼다는 안내와 새로고침 안내를 표시합니다. (`LiveShareModal`) +- **수동 재연결 버튼은 두지 않습니다.** 서버 여유가 넉넉하지 않아 재연결 시점을 사용자에게 맡기지 않고, 새로고침으로 상태를 처음부터 다시 맞추게 합니다. + +### 백그라운드 탭 (Case 9) + +브라우저는 백그라운드 탭의 타이머와 네트워크를 억제합니다. 그래서 탭이 돌아오는 시점(`visibilitychange`)에 양쪽 모두 상태를 다시 맞춥니다. (`useDocumentVisibility`) + +| 주체 | 복귀 시 동작 | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 사회자 | 연결이 살아 있으면 heartbeat를 기다리지 않고 즉시 `SYNC`를 발행합니다. 연결이 끊겨 있었다면 공유를 다시 시작합니다. (최소 간격 1초) | +| 청중 | 연결이 살아 있으면 `/room/{roomId}`를 다시 구독합니다. 서버는 청중이 구독할 때 사회자에게 상태 공유를 요청하므로, 재구독이 곧 재동기화 요청입니다. (최소 간격 3초) | +| 청중 | 연결이 끊겨 있었다면 다시 연결합니다. | + +- 사회자 탭이 백그라운드에 있는 동안에는 heartbeat가 늦어져 청중이 "사회자 연결 끊김"으로 볼 수 있습니다. 복귀 즉시 `SYNC`를 보내 이 오해를 빠르게 풉니다. +- 청중이 여럿 동시에 복귀하면 상태 공유 요청이 그만큼 늘어납니다. 서버는 룸별로 요청 최소 간격(500ms)을 두어 사회자의 발행이 증폭되지 않게 합니다. 버려진 요청은 직전 요청에 대한 `SYNC` 브로드캐스트가 대신합니다. diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b7937b18..f02f974d 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -310,5 +310,9 @@ "업데이트 이미지": "Update image", "일주일 간 보지 않기": "Don't show again for a week", "자세히 보기": "View details", - "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "An error occurred while loading the patch note link." + "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "An error occurred while loading the patch note link.", + "서버 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the server was lost. Please refresh the page.", + "새로고침": "Refresh", + "라이브 연결 끊김": "Live sharing disconnected", + "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the live server was lost. Please refresh the page." } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 862f60fd..abaa3703 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -310,5 +310,9 @@ "업데이트 이미지": "업데이트 이미지", "일주일 간 보지 않기": "일주일 간 보지 않기", "자세히 보기": "자세히 보기", - "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다." + "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다.", + "서버 연결이 끊겼어요. 새로고침 해주세요.": "서버 연결이 끊겼어요. 새로고침 해주세요.", + "새로고침": "새로고침", + "라이브 연결 끊김": "라이브 연결 끊김", + "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요." } From c1fda6fa0bc7d2ea80dbd8b5381def44c1319e05 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 19:07:40 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[FIX]=20=EC=83=81=ED=83=9C=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=8B=9C=20=EB=82=A8=EC=9D=80=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EC=8B=9C=EA=B0=81=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사회자 탭이 백그라운드에 있는 동안 브라우저가 인터벌을 억제하면 화면의 남은 시간이 밀린다. 복귀 직후 발행하는 SYNC는 그 밀린 값을 담게 되어, 청중이 이미 지난 시간을 다시 보다가 다음 heartbeat에서야 제자리를 찾았다. 타이머 훅에 목표 시각 기준으로 남은 시간을 즉시 다시 계산하는 보정을 두고, SYNC는 이 값으로 만든다. 사회자 화면의 표시도 같은 시점에 맞춰진다. Co-Authored-By: Claude Opus 5 --- src/page/TimerPage/TimerPage.tsx | 53 ++++++++++++- .../TimerPage/hooks/useNormalTimer.test.ts | 75 ++++++++++++++++++ src/page/TimerPage/hooks/useNormalTimer.ts | 27 +++++++ .../TimerPage/hooks/useTimeBasedTimer.test.ts | 78 +++++++++++++++++++ src/page/TimerPage/hooks/useTimeBasedTimer.ts | 52 +++++++++++++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/page/TimerPage/hooks/useNormalTimer.test.ts create mode 100644 src/page/TimerPage/hooks/useTimeBasedTimer.test.ts diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index d4a32100..a286982e 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -187,6 +187,55 @@ export default function TimerPage() { issueEvent(eventType, payload); }; + // 상태 공유용 남은 시간을 실제 시각 기준으로 보정해 만든다. + // 사회자 탭이 백그라운드에 있는 동안 브라우저가 인터벌을 억제하면 화면의 남은 시간이 + // 밀려 있으므로, 그대로 공유하면 청중이 지난 시간을 다시 보게 된다. + const buildSyncPayload = useCallback(() => { + const caughtUpNormalTime = normalTimer.catchUpToClock(); + const caughtUpProsTimes = timer1.catchUpToClock(); + const caughtUpConsTimes = timer2.catchUpToClock(); + + const caughtUpRemainingTime = getRemainingTimeForShare({ + timerType, + normalTimer: caughtUpNormalTime, + currentTeam: prosConsSelected, + prosTimer: { + ...caughtUpProsTimes, + isSpeakingTimerAvailable: timer1.isSpeakingTimerAvailable, + }, + consTimer: { + ...caughtUpConsTimes, + isSpeakingTimerAvailable: timer2.isSpeakingTimerAvailable, + }, + }); + + return buildTimerPayloadForShare({ + eventType: 'SYNC', + timerType, + sequence: index, + currentTeam: prosConsSelected, + remainingTime: caughtUpRemainingTime, + isCurrentTimerRunning: + timerType === 'NORMAL' + ? isNormalTimerRunning + : prosConsSelected === 'PROS' + ? isProsTimerRunning + : isConsTimerRunning, + prosTotalTime: caughtUpProsTimes.totalTimer, + consTotalTime: caughtUpConsTimes.totalTimer, + }); + }, [ + index, + isConsTimerRunning, + isNormalTimerRunning, + isProsTimerRunning, + normalTimer, + prosConsSelected, + timer1, + timer2, + timerType, + ]); + // 서버의 상태 공유 요청 시 현재 상태를 SYNC로 발행 (이미 종료했다면 FINISHED) // 폐기된 렌더의 상태를 캡처하지 않도록 커밋 이후에 핸들러를 갱신 useEffect(() => { @@ -196,14 +245,14 @@ export default function TimerPage() { return; } - const payload = buildTimerPayload('SYNC'); + const payload = buildSyncPayload(); if (payload === null) { return; } issueEvent('SYNC', payload); }; - }, [buildTimerPayload, issueEvent]); + }, [buildSyncPayload, issueEvent]); useTimerHotkey(state, handleTimerEvent); diff --git a/src/page/TimerPage/hooks/useNormalTimer.test.ts b/src/page/TimerPage/hooks/useNormalTimer.test.ts new file mode 100644 index 00000000..643cd6af --- /dev/null +++ b/src/page/TimerPage/hooks/useNormalTimer.test.ts @@ -0,0 +1,75 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNormalTimer } from './useNormalTimer'; + +describe('useNormalTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-25T10:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('catchUpToClock', () => { + it('인터벌이 밀려 있어도 실제 시각 기준 남은 시간으로 즉시 보정한다', () => { + const { result } = renderHook(() => useNormalTimer()); + + act(() => { + result.current.setTimer(180); + }); + act(() => { + result.current.startTimer(); + }); + + // 인터벌 콜백은 실행하지 않고 시각만 흐르게 해 백그라운드 억제 상황을 만든다 + vi.setSystemTime(new Date('2026-09-25T10:00:30Z')); + expect(result.current.timer).toBe(180); + + let caughtUp: number | null = null; + act(() => { + caughtUp = result.current.catchUpToClock(); + }); + + expect(caughtUp).toBe(150); + expect(result.current.timer).toBe(150); + }); + + it('0초를 지난 뒤에는 초과 시간을 음수로 보정한다', () => { + const { result } = renderHook(() => useNormalTimer()); + + act(() => { + result.current.setTimer(10); + }); + act(() => { + result.current.startTimer(); + }); + + vi.setSystemTime(new Date('2026-09-25T10:00:15Z')); + act(() => { + result.current.catchUpToClock(); + }); + + expect(result.current.timer).toBe(-5); + }); + + it('정지 상태에서는 현재 값을 그대로 돌려준다', () => { + const { result } = renderHook(() => useNormalTimer()); + + act(() => { + result.current.setTimer(120); + }); + + vi.setSystemTime(new Date('2026-09-25T10:00:30Z')); + + let caughtUp: number | null = null; + act(() => { + caughtUp = result.current.catchUpToClock(); + }); + + expect(caughtUp).toBe(120); + expect(result.current.timer).toBe(120); + }); + }); +}); diff --git a/src/page/TimerPage/hooks/useNormalTimer.ts b/src/page/TimerPage/hooks/useNormalTimer.ts index d1e4e94a..9d420d85 100644 --- a/src/page/TimerPage/hooks/useNormalTimer.ts +++ b/src/page/TimerPage/hooks/useNormalTimer.ts @@ -28,6 +28,12 @@ export function useNormalTimer(): NormalTimerLogics { // 실제 시간 계산용 레퍼런스 const targetTimeRef = useRef(null); + // 콜백에서 최신 남은 시간을 읽기 위한 레퍼런스 + const timerRef = useRef(null); + useEffect(() => { + timerRef.current = timer; + }, [timer]); + /** * 타이머를 1초마다 1씩 감소시키며 시작 * 이미 동작중이면 재시작하지 않음 @@ -118,6 +124,25 @@ export function useNormalTimer(): NormalTimerLogics { setIsAdditionalTimerOn(false); }, []); + /** + * 남은 시간을 실제 시각 기준으로 즉시 다시 계산합니다. + * 백그라운드 탭에서는 브라우저가 인터벌을 억제해 표시가 밀리므로, + * 탭이 돌아왔을 때 다음 인터벌을 기다리지 않고 맞추는 데 사용합니다. + * @returns 보정된 남은 시간 (정지 상태면 현재 값 그대로) + */ + const catchUpToClock = useCallback((): number | null => { + if (intervalRef.current === null || targetTimeRef.current === null) { + return timerRef.current; + } + + const remainingSeconds = Math.ceil( + (targetTimeRef.current - Date.now()) / 1000, + ); + timerRef.current = remainingSeconds; + setTimer(remainingSeconds); + return remainingSeconds; + }, []); + useEffect(() => () => pauseTimer(), [pauseTimer]); return { @@ -131,6 +156,7 @@ export function useNormalTimer(): NormalTimerLogics { resetTimer, setDefaultTimer, clearTimer, + catchUpToClock, handleChangeAdditionalTimer, handleCloseAdditionalTimer, }; @@ -147,6 +173,7 @@ export interface NormalTimerLogics { resetTimer: (value?: number) => void; setDefaultTimer: Dispatch>; clearTimer: () => void; + catchUpToClock: () => number | null; handleChangeAdditionalTimer: () => void; handleCloseAdditionalTimer: () => void; } diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts new file mode 100644 index 00000000..26697cbb --- /dev/null +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.test.ts @@ -0,0 +1,78 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTimeBasedTimer } from './useTimeBasedTimer'; + +describe('useTimeBasedTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-25T10:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('catchUpToClock', () => { + it('인터벌이 밀려 있어도 전체/발언 남은 시간을 실제 시각 기준으로 보정한다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setTimers(300, 60); + }); + act(() => { + result.current.startTimer(); + }); + + // 인터벌 콜백은 실행하지 않고 시각만 흐르게 해 백그라운드 억제 상황을 만든다 + vi.setSystemTime(new Date('2026-09-25T10:00:20Z')); + expect(result.current.totalTimer).toBe(300); + + act(() => { + result.current.catchUpToClock(); + }); + + expect(result.current.totalTimer).toBe(280); + expect(result.current.speakingTimer).toBe(40); + }); + + it('시간이 다 지나면 0으로 보정한다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setTimers(30, 10); + }); + act(() => { + result.current.startTimer(); + }); + + vi.setSystemTime(new Date('2026-09-25T10:01:00Z')); + act(() => { + result.current.catchUpToClock(); + }); + + expect(result.current.totalTimer).toBe(0); + expect(result.current.speakingTimer).toBe(0); + }); + + it('정지 상태에서는 현재 값을 그대로 돌려준다', () => { + const { result } = renderHook(() => useTimeBasedTimer()); + + act(() => { + result.current.setTimers(300, 60); + }); + + vi.setSystemTime(new Date('2026-09-25T10:00:20Z')); + + let caughtUp: { + totalTimer: number | null; + speakingTimer: number | null; + } = { totalTimer: null, speakingTimer: null }; + act(() => { + caughtUp = result.current.catchUpToClock(); + }); + + expect(caughtUp).toEqual({ totalTimer: 300, speakingTimer: 60 }); + expect(result.current.totalTimer).toBe(300); + }); + }); +}); diff --git a/src/page/TimerPage/hooks/useTimeBasedTimer.ts b/src/page/TimerPage/hooks/useTimeBasedTimer.ts index d9957547..0e9f6bfd 100644 --- a/src/page/TimerPage/hooks/useTimeBasedTimer.ts +++ b/src/page/TimerPage/hooks/useTimeBasedTimer.ts @@ -38,6 +38,16 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { const targetTimeRef = useRef(null); const speakingTargetTimeRef = useRef(null); + // 콜백에서 최신 남은 시간을 읽기 위한 레퍼런스 + const totalTimerRef = useRef(null); + const speakingTimerRef = useRef(null); + useEffect(() => { + totalTimerRef.current = totalTimer; + }, [totalTimer]); + useEffect(() => { + speakingTimerRef.current = speakingTimer; + }, [speakingTimer]); + /** * 타이머 시작을 위해 사용하는 저수준 함수 */ @@ -260,6 +270,41 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { intervalRef.current = null; }, [pauseTimer]); + /** + * 남은 시간을 실제 시각 기준으로 즉시 다시 계산합니다. + * 백그라운드 탭에서는 브라우저가 인터벌을 억제해 표시가 밀리므로, + * 탭이 돌아왔을 때 다음 인터벌을 기다리지 않고 맞추는 데 사용합니다. + * @returns 보정된 전체/발언 남은 시간 (정지 상태면 현재 값 그대로) + */ + const catchUpToClock = useCallback((): CaughtUpTimes => { + if (intervalRef.current === null || targetTimeRef.current === null) { + return { + totalTimer: totalTimerRef.current, + speakingTimer: speakingTimerRef.current, + }; + } + + const now = Date.now(); + const remainingTotal = Math.max( + 0, + Math.ceil((targetTimeRef.current - now) / 1000), + ); + totalTimerRef.current = remainingTotal; + setTotalTimer(remainingTotal); + + if (speakingTargetTimeRef.current === null) { + return { totalTimer: remainingTotal, speakingTimer: null }; + } + + const remainingSpeaking = Math.max( + 0, + Math.ceil((speakingTargetTimeRef.current - now) / 1000), + ); + speakingTimerRef.current = remainingSpeaking; + setSpeakingTimer(remainingSpeaking); + return { totalTimer: remainingTotal, speakingTimer: remainingSpeaking }; + }, []); + useEffect(() => () => pauseTimer(), [pauseTimer]); return { @@ -278,9 +323,15 @@ export function useTimeBasedTimer(): TimeBasedTimerLogics { setDefaultTime, setIsDone, clearTimer, + catchUpToClock, }; } +export interface CaughtUpTimes { + totalTimer: number | null; + speakingTimer: number | null; +} + export interface TimeBasedTimerLogics { totalTimer: number | null; speakingTimer: number | null; @@ -305,4 +356,5 @@ export interface TimeBasedTimerLogics { >; setIsDone: Dispatch>; clearTimer: () => void; + catchUpToClock: () => CaughtUpTimes; } From f80453b41286540451d4f6df9ff4911654e87387 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 19:24:28 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[FIX]=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EB=8D=98=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 내려가면 소켓뿐 아니라 테이블 정보 재조회도 실패한다. 지금은 이때 조회 오류 화면이 우선해서, 연결 끊김 안내와 마지막 타이머를 함께 덮어버렸다. 이미 받아둔 테이블 정보가 있으면 재조회 실패로 화면을 바꾸지 않는다. 조회 오류 화면은 한 번도 데이터를 받지 못한 경우에만 쓴다. 안내 문구는 문장 단위로 줄을 나누고 가운데 정렬한다. Co-Authored-By: Claude Opus 5 --- public/locales/en/translation.json | 4 ++- public/locales/ko/translation.json | 4 ++- .../AudienceSharePage/AudienceSharePage.tsx | 2 +- .../hooks/AudienceScreenState.test.ts | 26 ++++++++++++++++++- .../hooks/AudienceScreenState.ts | 6 +++-- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f02f974d..54107c56 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -314,5 +314,7 @@ "서버 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the server was lost. Please refresh the page.", "새로고침": "Refresh", "라이브 연결 끊김": "Live sharing disconnected", - "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the live server was lost. Please refresh the page." + "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "The connection to the live server was lost. Please refresh the page.", + "필요한 데이터를 불러오지 못했어요.": "We couldn't load the data we need.", + "다시 시도해보세요.": "Please try again." } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index abaa3703..d6a07fa8 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -314,5 +314,7 @@ "서버 연결이 끊겼어요. 새로고침 해주세요.": "서버 연결이 끊겼어요. 새로고침 해주세요.", "새로고침": "새로고침", "라이브 연결 끊김": "라이브 연결 끊김", - "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요." + "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.", + "필요한 데이터를 불러오지 못했어요.": "필요한 데이터를 불러오지 못했어요.", + "다시 시도해보세요.": "다시 시도해보세요." } diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 110f1dae..21729ddd 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -47,7 +47,7 @@ function ErrorContent({ message, onReload }: ErrorContentProps) { data-testid="audience-share-error-icon" aria-hidden="true" /> -

+

{message}

); From 5e0dad8ef2df67f0d835ff5d38e3197ee43f2b97 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 22:27:39 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[FIX]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0=EC=97=90=202=EB=B6=84=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=8B=9C=EA=B0=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 횟수 제한을 없앤 뒤로는 서버가 끝내 돌아오지 않아도 사회자가 조용히 재시도만 반복해, 공유가 멈춘 사실을 알기 어려웠다. 첫 끊김부터 2분 동안은 대기 간격을 늘려가며 계속 시도하고, 그 안에 살아나지 않으면 포기해 새로고침을 안내한다. 재연결에 성공하면 제한 시간 기준을 다시 잡는다. Co-Authored-By: Claude Opus 5 --- src/apis/sockets/SocketManager.test.ts | 47 +++++++++++++++++++++ src/apis/sockets/SocketManager.ts | 34 +++++++++++++-- src/hooks/sockets/useChairmanSocket.test.ts | 6 ++- src/hooks/sockets/useChairmanSocket.ts | 6 ++- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/apis/sockets/SocketManager.test.ts b/src/apis/sockets/SocketManager.test.ts index 2632f643..3bc8a7b4 100644 --- a/src/apis/sockets/SocketManager.test.ts +++ b/src/apis/sockets/SocketManager.test.ts @@ -525,6 +525,53 @@ describe('소켓 관리자 테스트', () => { expect(client.deactivate).not.toHaveBeenCalled(); }); + it('제한 시간이 지나면 횟수가 남아 있어도 재시도를 멈춰야 한다', () => { + const listener = vi.fn(); + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000_000); + socketManager.onErrorEvent(listener); + socketManager.connect({ maxRetries: null, retryDeadlineMs: 120000 }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 + + // 첫 끊김: 제한 시간 기준이 시작된다 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + + // 제한 시간 직전: 아직 재시도한다 + nowSpy.mockReturnValue(1_000_000 + 119_999); + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + + // 제한 시간 경과: 포기한다 + nowSpy.mockReturnValue(1_000_000 + 120_000); + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); + expect(error.message).toBe('재연결 제한 시간을 초과했습니다.'); + }); + + it('재연결에 성공하면 제한 시간 기준을 다시 시작해야 한다', () => { + const listener = vi.fn(); + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000_000); + socketManager.onErrorEvent(listener); + socketManager.connect({ maxRetries: null, retryDeadlineMs: 120000 }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); + client.config.onWebSocketClose?.({} as CloseEvent); + + // 재연결 성공 후 한참 뒤에 다시 끊겨도 곧바로 포기하지 않는다 + nowSpy.mockReturnValue(1_000_000 + 200_000); + client.config.onConnect?.({} as IFrame); + client.config.onWebSocketClose?.({} as CloseEvent); + + expect(listener).not.toHaveBeenCalled(); + }); + it('재시도 대기 시간은 상한을 넘지 않아야 한다', () => { socketManager.connect({ maxRetries: null, diff --git a/src/apis/sockets/SocketManager.ts b/src/apis/sockets/SocketManager.ts index 4cbd16a0..eaae5edc 100644 --- a/src/apis/sockets/SocketManager.ts +++ b/src/apis/sockets/SocketManager.ts @@ -8,6 +8,7 @@ import { SocketError } from './error'; * - `maxRetries` 최대 재시도 횟수 (기본값 3회, `null`이면 제한 없음) * - `baseRetryDelayMs` 기본 재시도 대기 시간 (기본값 1000 ms) * - `maxRetryDelayMs` 재시도 대기 시간 상한 (기본값 30000 ms) + * - `retryDeadlineMs` 재시도를 포기하기까지의 제한 시간 (기본값 없음) * - `heartbeatInMs` 수신 하트비트 주기 (기본값 10000 ms) * - `heartbeatOutMs` 발신 하트비트 주기 (기본값 10000 ms) */ @@ -27,6 +28,12 @@ export interface SocketOptions { /** 재시도 대기 시간의 상한 (기본값: 30000ms) */ maxRetryDelayMs?: number; + /** + * 재시도를 포기하기까지의 제한 시간 (기본값: `null`, 제한 없음) + * 첫 끊김 이후 이 시간이 지나면 횟수가 남아 있어도 재연결을 멈춘다. + */ + retryDeadlineMs?: number | null; + /** 수신 하트비트 주기 (기본값: 10000ms) */ heartbeatInMs?: number; @@ -41,6 +48,7 @@ type ResolvedSocketOptions = { maxRetries: number | null; baseRetryDelayMs: number; maxRetryDelayMs: number; + retryDeadlineMs: number | null; heartbeatInMs: number; heartbeatOutMs: number; }; @@ -49,6 +57,7 @@ const DEFAULT_OPTIONS: ResolvedSocketOptions = { maxRetries: 3, baseRetryDelayMs: 1000, maxRetryDelayMs: 30000, + retryDeadlineMs: null, heartbeatInMs: 10000, heartbeatOutMs: 10000, }; @@ -61,6 +70,9 @@ class SocketManager { private currentOptions: ResolvedSocketOptions = DEFAULT_OPTIONS; // 소켓 설정을 저장하는 변수 private retryCount: number = 0; + // 현재 끊김 구간에서 처음 재시도를 시작한 시각 (제한 시간 판단 기준) + private retryStartedAt: number | null = null; + // 싱글톤 패턴 적용 // - 생성자를 차단하여 외부에서의 객체 생성을 막음 private constructor() {} @@ -169,6 +181,7 @@ class SocketManager { // 사용자가 지정한 옵션이 있다면 덮어쓰기 this.retryCount = 0; + this.retryStartedAt = null; this.hasConnected = false; this.currentOptions = { ...DEFAULT_OPTIONS, ...options }; @@ -202,6 +215,7 @@ class SocketManager { onConnect: () => { console.log('✅ 웹 소켓(STOMP) 연결 성공'); this.retryCount = 0; + this.retryStartedAt = null; this.hasConnected = true; // 모든 관찰자에게 연결이 수립되었다고 알림 @@ -279,6 +293,7 @@ class SocketManager { // 각종 설정 초기화 this.retryCount = 0; + this.retryStartedAt = null; this.client = null; this.currentOptions = DEFAULT_OPTIONS; // 리스너는 등록한 훅이 언마운트될 때 직접 제거하므로 유지한다 @@ -339,13 +354,24 @@ class SocketManager { return; } - // 재시도 횟수를 초과했을 경우 연결을 즉시 종료 (횟수 제한이 없으면 건너뛴다) - const { maxRetries } = this.currentOptions; - if (maxRetries !== null && this.retryCount >= maxRetries) { + const now = Date.now(); + if (this.retryStartedAt === null) { + this.retryStartedAt = now; + } + + // 횟수를 초과했거나 제한 시간이 지났으면 연결을 즉시 종료 + const { maxRetries, retryDeadlineMs } = this.currentOptions; + const hasExceededCount = + maxRetries !== null && this.retryCount >= maxRetries; + const hasExceededDeadline = + retryDeadlineMs !== null && now - this.retryStartedAt >= retryDeadlineMs; + if (hasExceededCount || hasExceededDeadline) { this.reportSocketError( new SocketError( 'SOCKET_RETRY_EXHAUSTED', - '최대 재연결 시도 횟수를 초과했습니다.', + hasExceededDeadline + ? '재연결 제한 시간을 초과했습니다.' + : '최대 재연결 시도 횟수를 초과했습니다.', ), ); this.client.reconnectDelay = 0; diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts index 97a4a12e..01f18877 100644 --- a/src/hooks/sockets/useChairmanSocket.test.ts +++ b/src/hooks/sockets/useChairmanSocket.test.ts @@ -616,7 +616,11 @@ describe('useChairmanSocket', () => { }); expect(connect).toHaveBeenCalledWith( - expect.objectContaining({ maxRetries: null, maxRetryDelayMs: 30000 }), + expect.objectContaining({ + maxRetries: null, + maxRetryDelayMs: 30000, + retryDeadlineMs: 120000, + }), ); }); diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts index 26305388..8b1f54b3 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -22,12 +22,14 @@ const VISIBILITY_SYNC_THROTTLE_MS = 1000; /** * 사회자 소켓의 재연결 정책 * 서버는 활성 사회자 등록을 메모리로만 들고 있어, 사회자 연결이 끊긴 채 남으면 청중이 - * 새로고침해도 복구되지 않는다. 룸당 사회자는 한 명뿐이라 계속 재시도해도 서버 부담이 작으므로, - * 횟수를 제한하지 않고 대기 시간만 상한을 둔다. + * 새로고침해도 복구되지 않는다. 룸당 사회자는 한 명뿐이라 자주 재시도해도 서버 부담이 작으므로, + * 횟수 대신 제한 시간을 두고 그동안 대기 간격을 늘려가며 계속 시도한다. + * 2분 안에 서버가 살아나지 않으면 포기하고, 사회자에게 새로고침을 안내한다. */ const CHAIRMAN_SOCKET_OPTIONS = { maxRetries: null, maxRetryDelayMs: 30000, + retryDeadlineMs: 2 * 60 * 1000, } as const; /** From f0cda459c9820693035b3d41d97a71c6de904a9e Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 22:38:58 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[FIX]=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EB=82=AE=EC=95=84=EC=A7=88=20=EB=95=8C=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=EC=9D=B4=20=ED=97=A4=EB=8D=94=20=EB=B0=96?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=B9=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 창 높이가 낮아지면 헤더 높이는 56px로 줄지만 제목 글자 크기는 그대로여서, 토론 논제와 테이블 이름이 헤더 아래로 삐져나왔다. 낮은 화면에서는 제목 글자 크기도 함께 줄이고 줄 높이를 좁힌다. 헤더 자체도 넘치는 내용을 잘라내 어떤 경우에도 밖으로 나오지 않게 한다. Co-Authored-By: Claude Opus 5 --- src/components/HeaderTableInfo/HeaderTableInfo.tsx | 4 +++- src/components/HeaderTitle/HeaderTitle.tsx | 2 +- src/layout/components/header/StickyTriSectionHeader.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/HeaderTableInfo/HeaderTableInfo.tsx b/src/components/HeaderTableInfo/HeaderTableInfo.tsx index bb3a8f26..181471d1 100644 --- a/src/components/HeaderTableInfo/HeaderTableInfo.tsx +++ b/src/components/HeaderTableInfo/HeaderTableInfo.tsx @@ -18,7 +18,9 @@ export default function HeaderTableInfo(props: HeaderTitleProps) { )} {!isLoading && (
-

{name}

+

+ {name} +

)} diff --git a/src/components/HeaderTitle/HeaderTitle.tsx b/src/components/HeaderTitle/HeaderTitle.tsx index a8992b18..8f59b719 100644 --- a/src/components/HeaderTitle/HeaderTitle.tsx +++ b/src/components/HeaderTitle/HeaderTitle.tsx @@ -16,7 +16,7 @@ export default function HeaderTitle(props: HeaderTitleProps) { )} {!isLoading && ( -

+

{title}

)} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 17f30c62..d740a575 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -27,7 +27,7 @@ function StickyTriSectionHeader(props: PropsWithChildren) { const { children } = props; return ( -
+
{children}
From 538860765a7fe4356a81b6476ce242509b881fe6 Mon Sep 17 00:00:00 2001 From: coli Date: Fri, 25 Sep 2026 23:09:39 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[DOCS]=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EA=B7=9C=EC=95=BD=EC=9D=84=20=EC=B2=AD=EC=A4=91=EA=B3=BC=20?= =?UTF-8?q?=EC=82=AC=ED=9A=8C=EC=9E=90=EB=A1=9C=20=EB=82=98=EB=88=84?= =?UTF-8?q?=EC=96=B4=20=EA=B8=B0=EC=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사회자 소켓은 횟수 제한 없이 2분 동안 재시도하는데, 문서에는 모든 소켓이 3회 재시도 후 종료된다고만 적혀 있었다. Co-Authored-By: Claude Opus 5 --- docs/live-share-timer-sync.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index 963b93c4..f064980b 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -62,8 +62,10 @@ ### 재연결 시도 소진 (Case 7) -- 소켓은 끊기면 최대 3회까지 자동으로 재연결합니다. (풀 지터 + 지수 백오프, `SocketManager`) -- 3회를 모두 실패하면 `SOCKET_RETRY_EXHAUSTED` 오류와 함께 클라이언트를 완전히 닫습니다. 이 상태는 자동으로 복구되지 않습니다. +- 소켓은 끊기면 자동으로 재연결합니다. (풀 지터 + 지수 백오프, 대기 상한 30초, `SocketManager`) + - 청중: 최대 3회까지 재시도합니다. 청중은 수가 많아 동시 재접속 부담이 큽니다. + - 사회자: 횟수 제한 없이 첫 끊김부터 2분 동안 재시도합니다. (`useChairmanSocket`) 서버는 활성 사회자 등록을 메모리로만 들고 있어, 사회자 연결이 끊긴 채 남으면 청중이 새로고침해도 복구되지 않기 때문입니다. 2분 안에 서버가 살아나면 재구독으로 활성 사회자가 다시 등록되고, 곧바로 현재 상태를 공유해 청중도 함께 복구됩니다. +- 재시도 조건을 넘기면 `SOCKET_RETRY_EXHAUSTED` 오류와 함께 클라이언트를 완전히 닫습니다. 이 상태는 자동으로 복구되지 않습니다. - **청중**: 이미 타이머를 보고 있었다면 화면을 지우지 않고, 마지막 상태를 남긴 채 새로고침 안내 오버레이를 덮습니다. 카운트다운은 사회자 부재와 같은 방식으로 마지막 값에서 멈춥니다. 한 번도 타이머를 표시하지 못했다면 기존 오류 화면을 씁니다. (`useAudienceShareState`, `ConnectionLostNotice`) - **사회자**: 라이브 공유 모달에 연결이 끊겼다는 안내와 새로고침 안내를 표시합니다. (`LiveShareModal`) - **수동 재연결 버튼은 두지 않습니다.** 서버 여유가 넉넉하지 않아 재연결 시점을 사용자에게 맡기지 않고, 새로고침으로 상태를 처음부터 다시 맞추게 합니다.