diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index 8d9aad8f..f064980b 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -57,3 +57,28 @@ - 이전/다음 차례·토론 종료 버튼은 한 번 누르면 500ms(`ROUND_CONTROL_COOLDOWN_MS`) 동안 다시 누를 수 없습니다. 세 버튼이 대기 시간을 공유하므로, 마지막 직전 차례에서 다음 차례를 더블클릭해도 같은 자리에 나타난 토론 종료가 눌리지 않습니다. (`RoundControlRow`) - 핫키를 길게 눌러 생기는 반복 입력(`event.repeat`)은 무시합니다. (`useTimerHotkey`) - **서버 검증은 두지 않습니다.** 서버는 룸의 현재 순서를 알지 못하는 중계자이고, 의도적으로 빠르게 넘긴 것과 실수를 구분할 수 없습니다. `NEXT`/`BEFORE`는 이동 전 순서(`sequence`)를 담은 절대값이라 같은 이벤트가 중복 도착해도 청중 결과는 같고, 오래된 이벤트는 `version` 검증이 막습니다. + +## 5. 재연결과 백그라운드 탭 (Case 7, 9) + +### 재연결 시도 소진 (Case 7) + +- 소켓은 끊기면 자동으로 재연결합니다. (풀 지터 + 지수 백오프, 대기 상한 30초, `SocketManager`) + - 청중: 최대 3회까지 재시도합니다. 청중은 수가 많아 동시 재접속 부담이 큽니다. + - 사회자: 횟수 제한 없이 첫 끊김부터 2분 동안 재시도합니다. (`useChairmanSocket`) 서버는 활성 사회자 등록을 메모리로만 들고 있어, 사회자 연결이 끊긴 채 남으면 청중이 새로고침해도 복구되지 않기 때문입니다. 2분 안에 서버가 살아나면 재구독으로 활성 사회자가 다시 등록되고, 곧바로 현재 상태를 공유해 청중도 함께 복구됩니다. +- 재시도 조건을 넘기면 `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..54107c56 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -310,5 +310,11 @@ "업데이트 이미지": "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.", + "필요한 데이터를 불러오지 못했어요.": "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 862f60fd..d6a07fa8 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -310,5 +310,11 @@ "업데이트 이미지": "업데이트 이미지", "일주일 간 보지 않기": "일주일 간 보지 않기", "자세히 보기": "자세히 보기", - "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다." + "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다.", + "서버 연결이 끊겼어요. 새로고침 해주세요.": "서버 연결이 끊겼어요. 새로고침 해주세요.", + "새로고침": "새로고침", + "라이브 연결 끊김": "라이브 연결 끊김", + "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.": "라이브 서버와 연결이 끊겼어요. 새로고침 해주세요.", + "필요한 데이터를 불러오지 못했어요.": "필요한 데이터를 불러오지 못했어요.", + "다시 시도해보세요.": "다시 시도해보세요." } diff --git a/src/apis/sockets/SocketManager.test.ts b/src/apis/sockets/SocketManager.test.ts index cae53257..3bc8a7b4 100644 --- a/src/apis/sockets/SocketManager.test.ts +++ b/src/apis/sockets/SocketManager.test.ts @@ -509,6 +509,85 @@ describe('소켓 관리자 테스트', () => { expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); }); + it('재시도 횟수 제한이 없으면 끊겨도 오류를 발행하지 않고 계속 재시도해야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect({ maxRetries: null }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 + + for (let attempt = 0; attempt < 10; attempt++) { + client.config.onWebSocketClose?.({} as CloseEvent); + } + + expect(listener).not.toHaveBeenCalled(); + 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, + baseRetryDelayMs: 1000, + maxRetryDelayMs: 5000, + }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); + + for (let attempt = 0; attempt < 10; attempt++) { + client.config.onWebSocketClose?.({} as CloseEvent); + expect(client.reconnectDelay).toBeLessThanOrEqual(5000 + 10); + } + }); + it('명시적으로 연결을 끊은 뒤에는 늦게 도착한 이벤트가 오류를 발행하지 않아야 한다', () => { const listener = vi.fn(); socketManager.onErrorEvent(listener); diff --git a/src/apis/sockets/SocketManager.ts b/src/apis/sockets/SocketManager.ts index c92a069b..eaae5edc 100644 --- a/src/apis/sockets/SocketManager.ts +++ b/src/apis/sockets/SocketManager.ts @@ -5,8 +5,10 @@ import { SocketError } from './error'; /** * 소켓 설정을 정하는 인터페이스 - * - `maxRetries` 최대 재시도 횟수 (기본값 3회) + * - `maxRetries` 최대 재시도 횟수 (기본값 3회, `null`이면 제한 없음) * - `baseRetryDelayMs` 기본 재시도 대기 시간 (기본값 1000 ms) + * - `maxRetryDelayMs` 재시도 대기 시간 상한 (기본값 30000 ms) + * - `retryDeadlineMs` 재시도를 포기하기까지의 제한 시간 (기본값 없음) * - `heartbeatInMs` 수신 하트비트 주기 (기본값 10000 ms) * - `heartbeatOutMs` 발신 하트비트 주기 (기본값 10000 ms) */ @@ -17,12 +19,21 @@ export interface SocketOptions { /** SockJS 연결에 사용할 API base URL. 지정하면 `${baseUrl}/ws`로 연결합니다. */ baseUrl?: string; - /** 최대 재시도 횟수 (기본값: 3) */ - maxRetries?: number; + /** 최대 재시도 횟수 (기본값: 3). `null`이면 횟수 제한 없이 계속 재연결합니다. */ + maxRetries?: number | null; /** 지수 백오프 계산의 기준이 되는 초기 지연 시간 (기본값: 1000ms) */ baseRetryDelayMs?: number; + /** 재시도 대기 시간의 상한 (기본값: 30000ms) */ + maxRetryDelayMs?: number; + + /** + * 재시도를 포기하기까지의 제한 시간 (기본값: `null`, 제한 없음) + * 첫 끊김 이후 이 시간이 지나면 횟수가 남아 있어도 재연결을 멈춘다. + */ + retryDeadlineMs?: number | null; + /** 수신 하트비트 주기 (기본값: 10000ms) */ heartbeatInMs?: number; @@ -34,8 +45,10 @@ export interface SocketOptions { type ResolvedSocketOptions = { url?: string; baseUrl?: string; - maxRetries: number; + maxRetries: number | null; baseRetryDelayMs: number; + maxRetryDelayMs: number; + retryDeadlineMs: number | null; heartbeatInMs: number; heartbeatOutMs: number; }; @@ -43,6 +56,8 @@ type ResolvedSocketOptions = { const DEFAULT_OPTIONS: ResolvedSocketOptions = { maxRetries: 3, baseRetryDelayMs: 1000, + maxRetryDelayMs: 30000, + retryDeadlineMs: null, heartbeatInMs: 10000, heartbeatOutMs: 10000, }; @@ -55,6 +70,9 @@ class SocketManager { private currentOptions: ResolvedSocketOptions = DEFAULT_OPTIONS; // 소켓 설정을 저장하는 변수 private retryCount: number = 0; + // 현재 끊김 구간에서 처음 재시도를 시작한 시각 (제한 시간 판단 기준) + private retryStartedAt: number | null = null; + // 싱글톤 패턴 적용 // - 생성자를 차단하여 외부에서의 객체 생성을 막음 private constructor() {} @@ -152,7 +170,7 @@ class SocketManager { * HTTPS에서 WS로 업그레이드 시 청중과 사회자 모두 무인증으로 연결합니다. * * @param options - 소켓 클라이언트 동작을 제어하는 선택적 설정 객체 - * @param options.maxRetries - 최대 재연결 시도 횟수 (기본값: 3) + * @param options.maxRetries - 최대 재연결 시도 횟수 (기본값: 3, `null`이면 제한 없음) * @param options.baseRetryDelayMs - 지수 백오프 계산의 기준이 되는 초기 지연 시간 (기본값: 1000) * @param options.heartbeatInMs - 서버 > 클라이언트 수신 하트비트 주기(ms) (기본값: 10000) * @param options.heartbeatOutMs - 클라이언트 > 서버 발신 하트비트 주기(ms) (기본값: 10000) @@ -163,6 +181,7 @@ class SocketManager { // 사용자가 지정한 옵션이 있다면 덮어쓰기 this.retryCount = 0; + this.retryStartedAt = null; this.hasConnected = false; this.currentOptions = { ...DEFAULT_OPTIONS, ...options }; @@ -196,6 +215,7 @@ class SocketManager { onConnect: () => { console.log('✅ 웹 소켓(STOMP) 연결 성공'); this.retryCount = 0; + this.retryStartedAt = null; this.hasConnected = true; // 모든 관찰자에게 연결이 수립되었다고 알림 @@ -273,6 +293,7 @@ class SocketManager { // 각종 설정 초기화 this.retryCount = 0; + this.retryStartedAt = null; this.client = null; this.currentOptions = DEFAULT_OPTIONS; // 리스너는 등록한 훅이 언마운트될 때 직접 제거하므로 유지한다 @@ -333,12 +354,24 @@ class SocketManager { return; } - // 재시도 횟수를 초과했을 경우 연결을 즉시 종료 - if (this.retryCount >= this.currentOptions.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; @@ -356,8 +389,12 @@ class SocketManager { this.client.reconnectDelay = nextDelay; this.retryCount++; + const attemptLabel = + maxRetries === null + ? `${this.retryCount}회째` + : `${this.retryCount}/${maxRetries}`; console.log( - `🔄 재연결 시도 대기 중... (${this.retryCount}/${this.currentOptions.maxRetries}) - ${nextDelay}ms 후 시도`, + `🔄 재연결 시도 대기 중... (${attemptLabel}) - ${nextDelay}ms 후 시도`, ); } @@ -370,9 +407,11 @@ class SocketManager { * @returns 재연결 요청까지 필요한 딜레이 시간 */ private calculateBackoffDelay(currentRetryCount: number): number { - // 최대 지터 상한선 계산 - const maxExponentialDelay = - this.currentOptions.baseRetryDelayMs * Math.pow(2, currentRetryCount); + // 최대 지터 상한선 계산 (상한을 넘지 않도록 자른다) + const maxExponentialDelay = Math.min( + this.currentOptions.baseRetryDelayMs * Math.pow(2, currentRetryCount), + this.currentOptions.maxRetryDelayMs, + ); // 난수가 0이 될 경우 STOMP가 재연결 비활성화로 파악하는 것을 막기 위해 // 계산한 값에 10 ms를 추가 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/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..e3584e3d 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,6 +107,46 @@ 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; + } + + // 오래된 version이라 반영하지 않더라도 사회자가 메시지를 보내고 있다는 신호로 본다 + const receivedAt = Date.now(); + setLastReceivedAt(receivedAt); + + const { version } = parsedData; + const lastVersion = lastVersionRef.current; + const hasVersion = version !== undefined && version !== null; + + // version 없는 메시지는 기준이 생기기 전(하위 호환, 종료된 룸 입장 등)에만 반영 + if (!hasVersion && lastVersion !== null) { + return; + } + + if (hasVersion) { + if (lastVersion !== null && version <= lastVersion) { + return; + } + lastVersionRef.current = version; + } + + setLatestMessage(parsedData); + setLatestMessageReceivedAt(receivedAt); + } else { + console.log('잘못된 소켓 메시지 형식입니다:', parsedData); + } + } catch (e) { + console.log('메시지 파싱 오류:', e); + } + }, []); + useEffect(() => { if (!enabled) { resetMessage(); @@ -112,52 +158,56 @@ export default function useAudienceSocket( resetMessage(); // 토론 이벤트를 발행하는 채널 구독 및 메시지 수신 시 상태 업데이트 - subscribe(destination, (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); - - const { version } = parsedData; - const lastVersion = lastVersionRef.current; - const hasVersion = version !== undefined && version !== null; - - // version 없는 메시지는 기준이 생기기 전(하위 호환, 종료된 룸 입장 등)에만 반영 - if (!hasVersion && lastVersion !== null) { - return; - } - - if (hasVersion) { - if (lastVersion !== null && version <= lastVersion) { - return; - } - lastVersionRef.current = version; - } - - setLatestMessage(parsedData); - setLatestMessageReceivedAt(receivedAt); - } else { - console.log('잘못된 소켓 메시지 형식입니다:', parsedData); - } - } catch (e) { - console.log('메시지 파싱 오류:', e); - } - }); + 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, diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts index 6a9ffbac..01f18877 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()); @@ -96,7 +97,7 @@ describe('useChairmanSocket', () => { expect(result.current.signalCount).toBe(0); expect(result.current.lastSignalTime).toBeNull(); - expect(connect).toHaveBeenCalledWith(options); + expect(connect).toHaveBeenCalledWith(expect.objectContaining(options)); }); it('disconnect 호출 시 signalCount와 lastSignalTime을 초기화해야 한다', () => { @@ -606,4 +607,128 @@ describe('useChairmanSocket', () => { expect(onSyncRequest).not.toHaveBeenCalled(); }); }); + + it('공유를 시작하면 횟수 제한 없는 재연결 정책으로 연결해야 한다', () => { + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + result.current.connect(); + }); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + maxRetries: null, + maxRetryDelayMs: 30000, + retryDeadlineMs: 120000, + }), + ); + }); + + 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..8b1f54b3 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -7,13 +7,31 @@ 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; + +/** + * 사회자 소켓의 재연결 정책 + * 서버는 활성 사회자 등록을 메모리로만 들고 있어, 사회자 연결이 끊긴 채 남으면 청중이 + * 새로고침해도 복구되지 않는다. 룸당 사회자는 한 명뿐이라 자주 재시도해도 서버 부담이 작으므로, + * 횟수 대신 제한 시간을 두고 그동안 대기 간격을 늘려가며 계속 시도한다. + * 2분 안에 서버가 살아나지 않으면 포기하고, 사회자에게 새로고침을 안내한다. + */ +const CHAIRMAN_SOCKET_OPTIONS = { + maxRetries: null, + maxRetryDelayMs: 30000, + retryDeadlineMs: 2 * 60 * 1000, +} as const; + /** * 공유를 시작할 때마다 새 사회자 세션 식별자를 만든다. * `crypto.randomUUID`는 보안 컨텍스트(HTTPS)에서만 제공되므로 없으면 시각과 난수로 대신한다. @@ -112,6 +130,9 @@ export default function useChairmanSocket( // 마지막으로 발행한 이벤트의 version const versionRef = useRef(0); + // 사용자가 공유를 시작했는지 여부 (탭 복귀 시 자동 재연결 여부를 가른다) + const hasStartedSharingRef = useRef(false); + /** * 현재 사회자 소켓 세션에서 누적된 신호 메타데이터를 초기화합니다. * 세션 간에 오래된 신호 수나 타임스탬프가 남지 않도록, 래핑된 connect 및 @@ -132,7 +153,8 @@ export default function useChairmanSocket( resetSignalState(); chairmanSessionIdRef.current = createChairmanSessionId(); setIsReplaced(false); - connect(options); + hasStartedSharingRef.current = true; + connect({ ...CHAIRMAN_SOCKET_OPTIONS, ...options }); }, [connect, resetSignalState], ); @@ -147,6 +169,7 @@ export default function useChairmanSocket( */ const disconnectChairmanSocket = useCallback(() => { resetSignalState(); + hasStartedSharingRef.current = false; disconnect(); queryClient.removeQueries({ queryKey: chairmanTokenQueryKey(String(roomId)), @@ -175,9 +198,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}`; 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]); +} 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}
diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index 4f6682f0..5996251e 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,131 @@ 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(); + }); + }); + + describe('테이블 재조회 실패', () => { + it('이미 받아둔 테이블 정보가 있으면 소켓을 계속 유지한다', async () => { + let shouldFail = false; + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + if (shouldFail) { + return new HttpResponse(null, { status: 500 }); + } + return HttpResponse.json(mockDebateTableData); + }), + ); + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'present', + connectionStatus: 'connected', + status: 'displaying', + syncedAt: null, + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence: 0, + }, + }); + + const { rerenderPage } = renderPage('/live/123'); + await waitFor(() => { + expect(mockUseAudienceShareState).toHaveBeenCalledWith(123, { + enabled: true, + table: mockDebateTableData.table, + }); + }); + + shouldFail = true; + mockUseAudienceShareState.mockClear(); + rerenderPage(); + + await waitFor(() => { + expect(mockUseAudienceShareState).toHaveBeenCalledWith(123, { + enabled: true, + table: mockDebateTableData.table, + }); + }); + expect(await screen.findByTestId('timer-value')).toBeInTheDocument(); + }); + }); }); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 11274e6f..0f468ed8 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'; @@ -46,7 +47,7 @@ function ErrorContent({ message, onReload }: ErrorContentProps) { data-testid="audience-share-error-icon" aria-hidden="true" /> -

+

{message}

+ + ); +} diff --git a/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts index 84512634..ab635ae9 100644 --- a/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts @@ -170,10 +170,34 @@ describe('audienceScreenState', () => { const result = resolveAudienceScreenState(socketState, queryState, t); expect(result).toEqual({ type: 'ERROR', - message: '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + message: '필요한 데이터를 불러오지 못했어요.\n다시 시도해보세요.', }); }); + it('이미 받아둔 테이블 정보가 있으면 재조회에 실패해도 보던 화면을 유지한다', () => { + const socketState: AudienceShareState = { + status: 'displaying', + error: null, + syncedAt: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence: 0, + }, + }; + const queryState: AudienceQueryState = { + data: mockTableData, + isLoading: false, + isError: true, + }; + + const result = resolveAudienceScreenState(socketState, queryState, t); + + expect(result.type).toBe('NORMAL_TIMER'); + }); + it('쿼리가 로딩 중이면 LOADING 상태를 반환한다', () => { const socketState: AudienceShareState = { status: 'connecting', diff --git a/src/page/AudienceSharePage/hooks/AudienceScreenState.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts index 606d0f03..391fb70e 100644 --- a/src/page/AudienceSharePage/hooks/AudienceScreenState.ts +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts @@ -106,10 +106,12 @@ export function resolveAudienceScreenState( }; } - if (queryState.isError) { + // 이미 받아둔 테이블 정보가 있으면 재조회 실패로 보던 화면을 버리지 않는다 + // (서버가 내려가면 소켓과 함께 재조회도 실패하는데, 이때 연결 끊김 안내가 가려지면 안 된다) + if (queryState.isError && !queryState.data) { return { type: 'ERROR', - message: t('필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.'), + message: `${t('필요한 데이터를 불러오지 못했어요.')}\n${t('다시 시도해보세요.')}`, }; } diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index 71bcbed4..81ee2015 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -886,7 +886,7 @@ describe('useAudienceShareState', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - it('재연결 세션에서는 이전 최신 메시지와 표시 화면이 첫 데이터로 재사용되지 않는다.', () => { + it('재연결로 메시지 기준이 초기화돼도 마지막 화면을 유지하고 새 메시지로 덮어쓴다.', () => { const { result, rerender } = renderHook(() => useAudienceShareState(1)); setSocketState({ @@ -899,13 +899,68 @@ describe('useAudienceShareState', () => { rerender(); expect(result.current.status).toBe('displaying'); - // 끊김 후 재연결, 메시지 null + // 끊김 후 재연결, 메시지 기준 초기화 setSocketState({ isConnected: true, latestMessage: null, }); rerender(); - expect(result.current.status).toBe('waiting'); + + // 빈 화면으로 되돌아가지 않는다 + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.singleTime).toBe(60); + } + + // 재연결 후 도착한 SYNC가 화면을 덮어쓴다 + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'SYNC', + data: { + timerType: 'NORMAL', + sequence: 0, + remainingTime: 30, + isRunning: false, + }, + }, + }); + rerender(); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.singleTime).toBe(30); + } + }); + + it('재연결을 시도하는 동안에도 마지막 화면을 유지한다.', () => { + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 60 }, + }, + }); + rerender(); + expect(result.current.status).toBe('displaying'); + + // 연결이 끊겨 재시도하는 구간 + setSocketState({ + isConnected: false, + latestMessage: null, + }); + rerender(); + + expect(result.current.status).toBe('displaying'); + expect(result.current.connectionStatus).toBe('connected'); }); describe('네트워크 지연 보정 기준 시각(syncedAt)', () => { @@ -1110,4 +1165,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..f1ca43e9 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,15 +127,18 @@ 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; + + // 연결이 끊기거나 재연결로 메시지 기준이 초기화돼도 마지막으로 받은 화면은 지우지 않는다. + // 재연결에 성공하면 곧 도착할 SYNC가 덮어쓰고, 끝내 실패하면 이 화면 위에 새로고침을 안내한다. if (!isConnected || !latestMessage) { - setDisplayData(null); - setSyncedAt(null); return; } @@ -151,6 +172,7 @@ export function useAudienceShareState( }, [ enabled, isConnected, + isConnectionLost, latestMessage, latestMessageReceivedAt, error, @@ -233,12 +255,17 @@ 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 && !displayData) { + // 한 번이라도 화면을 띄운 뒤라면, 재연결하는 동안에도 마지막 화면을 계속 보여준다 status = 'connecting'; } else if (!displayData) { status = 'waiting'; @@ -253,6 +280,7 @@ export function useAudienceShareState( displayData: displayData!, syncedAt, chairmanPresence, + connectionStatus, }; } @@ -260,5 +288,6 @@ export function useAudienceShareState( status, error, chairmanPresence, + connectionStatus, }; } 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/components/LiveShareButton.test.tsx b/src/page/TimerPage/components/LiveShareButton.test.tsx index 620888b9..498a58cb 100644 --- a/src/page/TimerPage/components/LiveShareButton.test.tsx +++ b/src/page/TimerPage/components/LiveShareButton.test.tsx @@ -22,16 +22,18 @@ describe('LiveShareButton', () => { expect(handleClick).toHaveBeenCalledTimes(1); }); - test('공유 중이 아니면 펄스 점을 표시하지 않는다', () => { + test('공유 중이 아니면 깜빡이지 않는 회색 점을 표시한다', () => { render(); expect(screen.queryByTestId('live-share-pulse')).not.toBeInTheDocument(); + expect(screen.getByTestId('live-share-idle-dot')).toBeInTheDocument(); }); test('공유 중이면 펄스 점을 표시하고 공유 중임을 알린다', () => { render(); expect(screen.getByTestId('live-share-pulse')).toBeInTheDocument(); + expect(screen.queryByTestId('live-share-idle-dot')).not.toBeInTheDocument(); expect( screen.getByRole('button', { name: '라이브 공유 중' }), ).toBeInTheDocument(); diff --git a/src/page/TimerPage/components/LiveShareButton.tsx b/src/page/TimerPage/components/LiveShareButton.tsx index a570b96b..9ee5efe9 100644 --- a/src/page/TimerPage/components/LiveShareButton.tsx +++ b/src/page/TimerPage/components/LiveShareButton.tsx @@ -4,7 +4,7 @@ import { ButtonHTMLAttributes } from 'react'; interface LiveShareButtonProps extends ButtonHTMLAttributes { onClick: () => void; - /** 이 화면이 현재 라이브 공유 중인 사회자인지 여부. 참이면 펄스 점을 표시합니다. */ + /** 이 화면이 현재 라이브 공유 중인 사회자인지 여부. 참이면 점이 붉게 깜빡입니다. */ isSharing?: boolean; } @@ -26,16 +26,21 @@ export default function LiveShareButton({
- {isSharing && ( -
); 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'; } // 토큰을 첨부하여 토론 이벤트를 전송하는 함수 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; }