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 (
-