From 4bb1586003de22d62203e0d9b6ece1039112c2d0 Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:36:08 +0900
Subject: [PATCH 1/7] =?UTF-8?q?[FEAT]=20=EC=B2=AD=EC=A4=91=20=EC=86=8C?=
=?UTF-8?q?=EC=BC=93=EC=97=90=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=A9=94?=
=?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=8B=A0=20=EC=8B=9C=EA=B0=81=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 형식이 올바른 메시지를 받을 때마다 lastReceivedAt 갱신
- version이 오래되어 반영하지 않은 메시지도 사회자 연결 신호로 보고 갱신
- 연결, 재연결, 룸 변경 시 초기화
Co-Authored-By: Claude Opus 5
---
src/hooks/sockets/useAudienceSocket.test.ts | 90 +++++++++++++++++++
src/hooks/sockets/useAudienceSocket.ts | 9 ++
.../hooks/useAudienceShareState.test.ts | 2 +
3 files changed, 101 insertions(+)
diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts
index 0f2374e6..beed46e2 100644
--- a/src/hooks/sockets/useAudienceSocket.test.ts
+++ b/src/hooks/sockets/useAudienceSocket.test.ts
@@ -447,4 +447,94 @@ describe('useAudienceSocket', () => {
expect(result.current.latestMessage?.eventType).toBe('PLAY');
});
});
+
+ describe('마지막 수신 시각', () => {
+ const createMessage = (version: number): SocketMessage => ({
+ eventType: 'SYNC',
+ data: {
+ timerType: 'NORMAL',
+ sequence: 0,
+ remainingTime: 30,
+ isRunning: true,
+ },
+ version,
+ });
+
+ const setup = () => {
+ let handleMessage: (message: IMessage) => void = () => undefined;
+ let handleConnection: () => void = () => undefined;
+ subscribe.mockImplementation(
+ (_destination: string, callback: (message: IMessage) => void) => {
+ handleMessage = callback;
+ },
+ );
+ addConnectionListener.mockImplementation((listener: () => void) => {
+ handleConnection = listener;
+ return vi.fn();
+ });
+
+ const hook = renderHook(() => useAudienceSocket(123));
+ const receive = (body: string) =>
+ act(() => {
+ handleMessage({ body } as IMessage);
+ });
+
+ return { ...hook, receive, reconnect: () => act(handleConnection) };
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-09-22T00:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('메시지를 받기 전에는 lastReceivedAt이 null이어야 한다', () => {
+ const { result } = setup();
+
+ expect(result.current.lastReceivedAt).toBeNull();
+ });
+
+ it('유효한 메시지를 받으면 수신 시각으로 lastReceivedAt을 갱신해야 한다', () => {
+ const { result, receive } = setup();
+
+ receive(JSON.stringify(createMessage(10)));
+ expect(result.current.lastReceivedAt).toBe(Date.now());
+
+ vi.advanceTimersByTime(5000);
+ receive(JSON.stringify(createMessage(11)));
+ expect(result.current.lastReceivedAt).toBe(Date.now());
+ });
+
+ it('version이 오래되어 무시된 메시지도 수신 시각은 갱신해야 한다', () => {
+ const { result, receive } = setup();
+
+ receive(JSON.stringify(createMessage(10)));
+ vi.advanceTimersByTime(5000);
+ receive(JSON.stringify(createMessage(9)));
+
+ expect(result.current.latestMessage).toEqual(createMessage(10));
+ expect(result.current.lastReceivedAt).toBe(Date.now());
+ });
+
+ it('형식이 잘못된 메시지는 수신 시각을 갱신하지 않아야 한다', () => {
+ const { result, receive } = setup();
+
+ receive('invalid-json');
+ receive(JSON.stringify({ eventType: 'UNKNOWN', data: null }));
+
+ expect(result.current.lastReceivedAt).toBeNull();
+ });
+
+ it('재연결되면 lastReceivedAt을 초기화해야 한다', () => {
+ const { result, receive, reconnect } = setup();
+
+ receive(JSON.stringify(createMessage(10)));
+ reconnect();
+
+ expect(result.current.lastReceivedAt).toBeNull();
+ });
+ });
});
diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts
index 249084da..b6d204e2 100644
--- a/src/hooks/sockets/useAudienceSocket.ts
+++ b/src/hooks/sockets/useAudienceSocket.ts
@@ -17,6 +17,7 @@ import { isSocketMessage } from '../../apis/sockets/util';
* @param {UseAudienceSocketOptions} options - 소켓 채널 구독 활성화 옵션
* @returns {Object} 청중 소켓 상태와 제어 함수를 반환합니다.
* @returns {SocketMessage | null} returns.latestMessage - 검증된 가장 최근의 수신 메시지입니다.
+ * @returns {number | null} returns.lastReceivedAt - 형식이 올바른 메시지를 마지막으로 수신한 시각입니다. version이 오래되어 반영하지 않은 메시지도 포함합니다.
* @returns {boolean} returns.isConnected - 소켓 연결 상태입니다.
* @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 메시지를 초기화합니다.
* @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다.
@@ -44,6 +45,9 @@ export default function useAudienceSocket(
null,
);
+ // 형식이 올바른 메시지를 마지막으로 수신한 시각 (사회자 연결 여부 판단에 사용)
+ const [lastReceivedAt, setLastReceivedAt] = useState(null);
+
// 마지막으로 반영한 메시지의 version (이보다 작거나 같은 메시지는 오래된 것으로 간주)
const lastVersionRef = useRef(null);
@@ -54,6 +58,7 @@ export default function useAudienceSocket(
*/
const resetMessage = useCallback(() => {
setLatestMessage(null);
+ setLastReceivedAt(null);
lastVersionRef.current = null;
}, []);
@@ -99,6 +104,9 @@ export default function useAudienceSocket(
try {
const parsedData = JSON.parse(message.body);
if (isSocketMessage(parsedData)) {
+ // 오래된 version이라 반영하지 않더라도 사회자가 메시지를 보내고 있다는 신호로 본다
+ setLastReceivedAt(Date.now());
+
const { version } = parsedData;
const lastVersion = lastVersionRef.current;
const hasVersion = version !== undefined && version !== null;
@@ -133,6 +141,7 @@ export default function useAudienceSocket(
return {
latestMessage,
+ lastReceivedAt,
isConnected,
error,
connect: connectAudienceSocket,
diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
index e6316bf1..6afee7a5 100644
--- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
+++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
@@ -43,6 +43,7 @@ describe('useAudienceShareState', () => {
connect: mockConnect as unknown as (options?: unknown) => void,
disconnect: mockDisconnect as unknown as () => void,
latestMessage: null,
+ lastReceivedAt: null,
isConnected: false,
error: null,
});
@@ -60,6 +61,7 @@ describe('useAudienceShareState', () => {
connect: mockConnect as unknown as (options?: unknown) => void,
disconnect: mockDisconnect as unknown as () => void,
latestMessage: null,
+ lastReceivedAt: null,
isConnected: true,
error: null,
...state,
From 12460e440412f00fa0f8a0e3eb89acf7b56a4e84 Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:37:01 +0900
Subject: [PATCH 2/7] =?UTF-8?q?[FEAT]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?=
=?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EC=A4=91=205?=
=?UTF-8?q?=EC=B4=88=20=EC=A3=BC=EA=B8=B0=20=EC=83=81=ED=83=9C=20=EA=B3=B5?=
=?UTF-8?q?=EC=9C=A0(heartbeat)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 연결된 동안 CHAIRMAN_HEARTBEAT_INTERVAL_MS(5초)마다 onSyncRequest 호출
- 기존 SYNC/FINISHED 응답 로직을 그대로 재사용해 청중이 사회자 연결 여부를 판단할 수 있게 함
- 연결 해제, 언마운트 시 주기 호출 정리
Co-Authored-By: Claude Opus 5
---
src/hooks/sockets/useChairmanSocket.test.ts | 96 +++++++++++++++++++++
src/hooks/sockets/useChairmanSocket.ts | 23 ++++-
2 files changed, 118 insertions(+), 1 deletion(-)
diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts
index 03d2a219..cdb64a1d 100644
--- a/src/hooks/sockets/useChairmanSocket.test.ts
+++ b/src/hooks/sockets/useChairmanSocket.test.ts
@@ -341,4 +341,100 @@ describe('useChairmanSocket', () => {
expect(onSyncRequest).toHaveBeenCalledTimes(1);
});
+
+ describe('heartbeat', () => {
+ const setConnected = (isConnected: boolean) => {
+ useSocketMock.mockReturnValue({
+ connect,
+ disconnect,
+ subscribe,
+ unsubscribe,
+ publish,
+ addConnectionListener,
+ isConnected,
+ error: null,
+ });
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('연결된 동안 5초마다 onSyncRequest를 호출해야 한다', () => {
+ setConnected(true);
+ const onSyncRequest = vi.fn();
+
+ renderHook(() => useChairmanSocket(123, { onSyncRequest }));
+
+ act(() => {
+ vi.advanceTimersByTime(4999);
+ });
+ expect(onSyncRequest).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(onSyncRequest).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+ expect(onSyncRequest).toHaveBeenCalledTimes(3);
+ });
+
+ it('연결되지 않은 상태에서는 onSyncRequest를 주기적으로 호출하지 않아야 한다', () => {
+ setConnected(false);
+ const onSyncRequest = vi.fn();
+
+ renderHook(() => useChairmanSocket(123, { onSyncRequest }));
+
+ act(() => {
+ vi.advanceTimersByTime(20000);
+ });
+
+ expect(onSyncRequest).not.toHaveBeenCalled();
+ });
+
+ it('연결이 끊기면 주기 호출을 멈춰야 한다', () => {
+ setConnected(true);
+ const onSyncRequest = vi.fn();
+
+ const { rerender } = renderHook(() =>
+ useChairmanSocket(123, { onSyncRequest }),
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(onSyncRequest).toHaveBeenCalledTimes(1);
+
+ setConnected(false);
+ rerender();
+
+ act(() => {
+ vi.advanceTimersByTime(20000);
+ });
+ expect(onSyncRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it('언마운트되면 주기 호출을 멈춰야 한다', () => {
+ setConnected(true);
+ const onSyncRequest = vi.fn();
+
+ const { unmount } = renderHook(() =>
+ useChairmanSocket(123, { onSyncRequest }),
+ );
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(20000);
+ });
+
+ expect(onSyncRequest).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts
index f68588d9..e8a241d3 100644
--- a/src/hooks/sockets/useChairmanSocket.ts
+++ b/src/hooks/sockets/useChairmanSocket.ts
@@ -9,6 +9,9 @@ import { isTimerEventType } from '../../apis/sockets/util';
import { chairmanTokenQueryKey } from '../query/useGetChairmanToken';
import useSocket from './useSocket';
+// 청중이 사회자 연결 여부를 판단할 수 있도록 현재 상태를 주기적으로 공유하는 간격
+export const CHAIRMAN_HEARTBEAT_INTERVAL_MS = 5000;
+
/**
* 사회자 전용 웹소켓 훅입니다.
*
@@ -29,7 +32,10 @@ import useSocket from './useSocket';
* @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다.
*/
interface UseChairmanSocketOptions {
- /** 소켓이 연결(재연결 포함)되거나, 서버가 `/chairman/{roomId}`로 현재 상태 공유를 요청했을 때 호출됩니다. */
+ /**
+ * 소켓이 연결(재연결 포함)되거나, 서버가 `/chairman/{roomId}`로 현재 상태 공유를 요청했을 때 호출됩니다.
+ * 연결된 동안에는 heartbeat로 `CHAIRMAN_HEARTBEAT_INTERVAL_MS`마다 호출됩니다.
+ */
onSyncRequest?: () => void;
}
@@ -129,6 +135,21 @@ export default function useChairmanSocket(
};
}, [roomId, resetSignalState, subscribe, unsubscribe]);
+ // 연결된 동안 현재 상태를 주기적으로 공유해 청중이 사회자 연결 끊김을 감지할 수 있게 한다
+ useEffect(() => {
+ if (!isConnected) {
+ return;
+ }
+
+ const heartbeat = setInterval(() => {
+ onSyncRequestRef.current?.();
+ }, CHAIRMAN_HEARTBEAT_INTERVAL_MS);
+
+ return () => {
+ clearInterval(heartbeat);
+ };
+ }, [isConnected]);
+
useEffect(() => {
if (!error) {
return;
From d0de951424089be8433955e1ff872d26c7daf64d Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:38:05 +0900
Subject: [PATCH 3/7] =?UTF-8?q?[FEAT]=20=EC=B2=AD=EC=A4=91=20=ED=99=94?=
=?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=82=AC=ED=9A=8C=EC=9E=90=20=EC=97=B0?=
=?UTF-8?q?=EA=B2=B0=20=EC=83=81=ED=83=9C=20=ED=8C=90=EB=8B=A8=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20600=EC=B4=88=20=EC=9D=B4=EB=B2=A4?=
=?UTF-8?q?=ED=8A=B8=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- chairmanPresence(waiting/present/absent) 반환
- 마지막 수신 후 15초(사회자 heartbeat 3회) 동안 메시지가 없으면 absent로 판단하고, 다시 수신하면 present로 복구
- 사회자가 오래 없거나 장시간 정지해도 오류 화면으로 끊지 않도록 EVENT_TIMEOUT 오류 제거
Co-Authored-By: Claude Opus 5
---
.../AudienceSharePage.test.tsx | 16 ++
src/page/AudienceSharePage/error.test.ts | 4 +-
src/page/AudienceSharePage/error.ts | 1 -
.../hooks/useAudienceShareState.test.ts | 161 ++++++++++++++++--
.../hooks/useAudienceShareState.ts | 76 +++++----
5 files changed, 209 insertions(+), 49 deletions(-)
diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx
index e20245a8..63a6faa4 100644
--- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx
+++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx
@@ -137,6 +137,7 @@ describe('AudienceSharePage', () => {
}),
);
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'connecting',
error: null,
});
@@ -193,6 +194,7 @@ describe('AudienceSharePage', () => {
it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'connecting',
error: null,
});
@@ -214,6 +216,7 @@ describe('AudienceSharePage', () => {
it('waiting 상태에서는 서버 데이터 대기 번역 문구가 표시된다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'waiting',
error: null,
});
@@ -254,6 +257,7 @@ describe('AudienceSharePage', () => {
}),
);
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'waiting',
error: null,
});
@@ -268,6 +272,7 @@ describe('AudienceSharePage', () => {
it('모바일에서는 헤더 대신 본문 상단에 주제를 표시한다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'waiting',
error: null,
});
@@ -284,6 +289,7 @@ describe('AudienceSharePage', () => {
it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -311,6 +317,7 @@ describe('AudienceSharePage', () => {
it('NORMAL sequence의 CONS 항목에 반대 팀 정보를 연결한다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -335,6 +342,7 @@ describe('AudienceSharePage', () => {
it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', async () => {
vi.useFakeTimers();
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -369,6 +377,7 @@ describe('AudienceSharePage', () => {
it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', async () => {
vi.useFakeTimers();
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -402,6 +411,7 @@ describe('AudienceSharePage', () => {
it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -434,6 +444,7 @@ describe('AudienceSharePage', () => {
it('PLAY 상태의 TIME_BASED 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', async () => {
vi.useFakeTimers();
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -478,6 +489,7 @@ describe('AudienceSharePage', () => {
it('finished 상태가 되면 종료 안내 페이지로 이동한다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'finished',
error: null,
});
@@ -522,6 +534,7 @@ describe('AudienceSharePage', () => {
}),
);
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -558,6 +571,7 @@ describe('AudienceSharePage', () => {
}),
);
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'displaying',
error: null,
displayData: {
@@ -604,6 +618,7 @@ describe('AudienceSharePage', () => {
it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', async () => {
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'connecting',
error: new AudienceShareError('SOCKET_STOMP_ERROR'),
});
@@ -625,6 +640,7 @@ describe('AudienceSharePage', () => {
}),
);
mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
status: 'connecting',
error: new AudienceShareError('SOCKET_STOMP_ERROR'),
});
diff --git a/src/page/AudienceSharePage/error.test.ts b/src/page/AudienceSharePage/error.test.ts
index 57b00d0d..d1064360 100644
--- a/src/page/AudienceSharePage/error.test.ts
+++ b/src/page/AudienceSharePage/error.test.ts
@@ -3,9 +3,9 @@ import { SocketError } from '../../apis/sockets/error';
describe('AudienceShareError 오류 객체', () => {
it('올바른 코드와 이름으로 초기화된다', () => {
- const error = new AudienceShareError('EVENT_TIMEOUT');
+ const error = new AudienceShareError('SERVER_ERROR');
expect(error.name).toBe('AudienceShareError');
- expect(error.code).toBe('EVENT_TIMEOUT');
+ expect(error.code).toBe('SERVER_ERROR');
expect(error.technicalError).toBeNull();
});
diff --git a/src/page/AudienceSharePage/error.ts b/src/page/AudienceSharePage/error.ts
index cd618e8f..e0392e94 100644
--- a/src/page/AudienceSharePage/error.ts
+++ b/src/page/AudienceSharePage/error.ts
@@ -2,7 +2,6 @@ import { SocketErrorCode } from '../../apis/sockets/error';
export type AudienceShareErrorCode =
| SocketErrorCode
- | 'EVENT_TIMEOUT'
| 'SERVER_ERROR'
| 'UNKNOWN';
diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
index 6afee7a5..427182c6 100644
--- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
+++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts
@@ -592,44 +592,175 @@ describe('useAudienceShareState', () => {
expect(result.current.prosCountdown.currentSeconds).toBe(8);
});
- it('유효한 토론 이벤트가 600초 동안 없으면 오류 상태가 생성되고 소켓 및 시간 자원이 정리된다.', () => {
+ it('유효한 토론 이벤트가 600초 동안 없어도 오류로 처리하지 않고 소켓을 유지한다.', () => {
setSocketState({
isConnected: true,
latestMessage: {
eventType: 'PLAY',
data: { timerType: 'NORMAL', sequence: 0, remainingTime: 60 },
},
+ lastReceivedAt: Date.now(),
});
- const { result, rerender } = renderHook(() => useAudienceShareState(1));
+ const { result } = renderHook(() => useAudienceShareState(1));
expect(result.current.status).toBe('displaying');
act(() => {
vi.advanceTimersByTime(600 * 1000);
});
- rerender();
- expect(result.current.status).toBe('displaying'); // wait, the error is set but what is the status?
- // Wait, earlier I set it to just keep status as is and append error? Or does it change status to 'displaying' but with error?
- // "오류 상태가 생성되고" -> error 필드가 갱신됨.
- expect(result.current.error?.code).toBe('EVENT_TIMEOUT');
- expect(mockDisconnect).toHaveBeenCalled();
+ expect(result.current.status).toBe('displaying');
+ expect(result.current.error).toBeNull();
+ expect(mockDisconnect).not.toHaveBeenCalled();
});
- it('소켓 연결 후 첫 이벤트가 600초 동안 없으면 EVENT_TIMEOUT 오류가 생성되고 소켓이 정리된다.', () => {
+ it('소켓 연결 후 첫 이벤트가 600초 동안 없어도 오류로 처리하지 않고 소켓을 유지한다.', () => {
setSocketState({ isConnected: true, latestMessage: null });
- const { result, rerender } = renderHook(() => useAudienceShareState(1));
- expect(result.current.status).toBe('waiting');
+ const { result } = renderHook(() =>
+ useAudienceShareState(1, { table: normalTable }),
+ );
act(() => {
vi.advanceTimersByTime(600 * 1000);
});
- rerender();
- expect(result.current.status).toBe('waiting');
- expect(result.current.error?.code).toBe('EVENT_TIMEOUT');
- expect(mockDisconnect).toHaveBeenCalled();
+ expect(result.current.status).toBe('displaying');
+ expect(result.current.error).toBeNull();
+ expect(mockDisconnect).not.toHaveBeenCalled();
+ });
+
+ describe('사회자 연결 상태', () => {
+ const syncMessage: SocketMessage = {
+ eventType: 'SYNC',
+ data: {
+ timerType: 'NORMAL',
+ sequence: 0,
+ remainingTime: 60,
+ isRunning: true,
+ },
+ version: 1,
+ };
+
+ it('연결 후 사회자 메시지를 한 번도 받지 못하면 waiting이다', () => {
+ setSocketState({ isConnected: true, latestMessage: null });
+
+ const { result } = renderHook(() =>
+ useAudienceShareState(1, { table: normalTable }),
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(60 * 1000);
+ });
+
+ expect(result.current.chairmanPresence).toBe('waiting');
+ });
+
+ it('사회자 메시지를 받으면 present이다', () => {
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+
+ const { result } = renderHook(() => useAudienceShareState(1));
+
+ expect(result.current.chairmanPresence).toBe('present');
+ });
+
+ it('마지막 수신 후 15초 동안 메시지가 없으면 absent가 된다', () => {
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+
+ const { result } = renderHook(() => useAudienceShareState(1));
+
+ act(() => {
+ vi.advanceTimersByTime(14999);
+ });
+ expect(result.current.chairmanPresence).toBe('present');
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current.chairmanPresence).toBe('absent');
+ });
+
+ it('수신이 이어지면 판정 시간이 다시 시작된다', () => {
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+
+ const { result, rerender } = renderHook(() => useAudienceShareState(1));
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+ rerender();
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+
+ expect(result.current.chairmanPresence).toBe('present');
+ });
+
+ it('absent 상태에서 다시 메시지를 받으면 present로 복구된다', () => {
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+
+ const { result, rerender } = renderHook(() => useAudienceShareState(1));
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+ expect(result.current.chairmanPresence).toBe('absent');
+
+ setSocketState({
+ isConnected: true,
+ latestMessage: { ...syncMessage, version: 2 },
+ lastReceivedAt: Date.now(),
+ });
+ rerender();
+
+ expect(result.current.chairmanPresence).toBe('present');
+ });
+
+ it('재연결로 수신 기록이 초기화되면 waiting으로 돌아간다', () => {
+ setSocketState({
+ isConnected: true,
+ latestMessage: syncMessage,
+ lastReceivedAt: Date.now(),
+ });
+
+ const { result, rerender } = renderHook(() => useAudienceShareState(1));
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+ expect(result.current.chairmanPresence).toBe('absent');
+
+ setSocketState({
+ isConnected: true,
+ latestMessage: null,
+ lastReceivedAt: null,
+ });
+ rerender();
+
+ expect(result.current.chairmanPresence).toBe('waiting');
+ });
});
it('ERROR 메시지와 소켓 오류 처리', () => {
diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts
index 29c2b756..4d54e28c 100644
--- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts
+++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import useAudienceSocket from '../../../hooks/sockets/useAudienceSocket';
import { AudienceShareError, AudienceShareErrorCode } from '../error';
import { TimeBoxInfo } from '../../../type/type';
@@ -19,7 +19,16 @@ export type AudienceShareState =
}
| { status: 'finished'; error: AudienceShareError | null };
-const EVENT_TIMEOUT_MS = 600 * 1000;
+/**
+ * 사회자 연결 상태
+ * - `waiting`: 연결 후 사회자 메시지를 아직 한 번도 받지 못함
+ * - `present`: 최근 `CHAIRMAN_ABSENT_TIMEOUT_MS` 안에 사회자 메시지를 받음
+ * - `absent`: 사회자 메시지를 받은 적은 있으나 `CHAIRMAN_ABSENT_TIMEOUT_MS` 동안 수신이 없음
+ */
+export type ChairmanPresence = 'waiting' | 'present' | 'absent';
+
+// 사회자 heartbeat(5초) 3회를 놓치면 사회자 연결이 끊긴 것으로 판단
+export const CHAIRMAN_ABSENT_TIMEOUT_MS = 15 * 1000;
// 연결 후 이 시간 동안 메시지가 없으면 첫 순서 타이머를 정지 상태로 먼저 표시
const INITIAL_DISPLAY_DELAY_MS = 1000;
@@ -32,12 +41,13 @@ interface UseAudienceShareStateOptions {
export function useAudienceShareState(
roomId: number,
options: UseAudienceShareStateOptions = {},
-): AudienceShareState {
+): AudienceShareState & { chairmanPresence: ChairmanPresence } {
const { enabled = true, table } = options;
const {
connect,
disconnect,
latestMessage,
+ lastReceivedAt,
isConnected,
error: socketError,
} = useAudienceSocket(roomId, { enabled });
@@ -49,13 +59,9 @@ export function useAudienceShareState(
null,
);
- const timeoutRef = useRef | null>(null);
+ const [isChairmanAbsent, setIsChairmanAbsent] = useState(false);
const cleanup = useCallback(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- }
disconnect();
}, [disconnect]);
@@ -94,33 +100,11 @@ export function useAudienceShareState(
useEffect(() => {
if (!enabled || error || isFinished) return; // Ignore if inactive, already failed or finished
- if (!isConnected) {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- }
- setDisplayData(null);
- return;
- }
-
- const resetEventTimeout = () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- timeoutRef.current = setTimeout(() => {
- cleanup();
- setError(new AudienceShareError('EVENT_TIMEOUT'));
- }, EVENT_TIMEOUT_MS);
- };
-
- if (!latestMessage) {
- resetEventTimeout();
+ if (!isConnected || !latestMessage) {
setDisplayData(null);
return;
}
- resetEventTimeout();
-
if (latestMessage.eventType === 'ERROR') {
cleanup();
setError(new AudienceShareError('SERVER_ERROR'));
@@ -141,6 +125,34 @@ export function useAudienceShareState(
);
}, [enabled, isConnected, latestMessage, error, isFinished, cleanup, table]);
+ // 마지막 수신 후 일정 시간 동안 메시지가 없으면 사회자 연결이 끊긴 것으로 판단
+ useEffect(() => {
+ setIsChairmanAbsent(false);
+
+ if (lastReceivedAt === null) {
+ return;
+ }
+
+ const elapsed = Date.now() - lastReceivedAt;
+ const absentTimeout = setTimeout(
+ () => {
+ setIsChairmanAbsent(true);
+ },
+ Math.max(CHAIRMAN_ABSENT_TIMEOUT_MS - elapsed, 0),
+ );
+
+ return () => {
+ clearTimeout(absentTimeout);
+ };
+ }, [lastReceivedAt]);
+
+ let chairmanPresence: ChairmanPresence = 'present';
+ if (lastReceivedAt === null) {
+ chairmanPresence = 'waiting';
+ } else if (isChairmanAbsent) {
+ chairmanPresence = 'absent';
+ }
+
// 사회자 응답을 기다리는 동안 빈 화면 대신 첫 순서 타이머를 정지 상태로 표시
// 이후 SYNC 등 메시지를 받으면 받은 상태로 바뀐다
const isWaitingFirstMessage =
@@ -180,11 +192,13 @@ export function useAudienceShareState(
status,
error,
displayData: displayData!,
+ chairmanPresence,
};
}
return {
status,
error,
+ chairmanPresence,
};
}
From 8c7c9d0b7afbe6d171b8536e217c50b28d586efb Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:41:23 +0900
Subject: [PATCH 4/7] =?UTF-8?q?[FEAT]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?=
=?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=83=81=ED=83=9C=20=EC=95=88=EB=82=B4=20?=
=?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EB=AC=B8?=
=?UTF-8?q?=EA=B5=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- waiting: 타이머 위 대기 배너, absent: 타이머를 덮는 반투명 끊김 오버레이
- role="status"로 스크린 리더에 상태 변화 안내
- ko/en 번역 추가
Co-Authored-By: Claude Opus 5
---
public/locales/en/translation.json | 2 +
public/locales/ko/translation.json | 2 +
.../components/ChairmanStatusNotice.tsx | 51 +++++++++++++++++++
3 files changed, 55 insertions(+)
create mode 100644 src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index a94ef374..620e874b 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -283,6 +283,8 @@
"서버에서 토론 진행 오류를 전달했어요.": "The server reported a debate progress error.",
"알 수 없는 실시간 오류가 발생했어요.": "An unknown live error occurred.",
"토론 시작을 대기 중입니다.": "Waiting for the debate to start.",
+ "사회자 연결을 기다리는 중이에요.": "Waiting for the moderator to connect.",
+ "사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.": "The moderator has disconnected. Waiting for them to reconnect.",
"토론이 종료되었습니다.": "The debate has ended.",
"페이지 닫기": "Close page",
"참여해 주셔서 감사합니다.": "Thank you for joining.",
diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json
index aae6a24a..b9def1ca 100644
--- a/public/locales/ko/translation.json
+++ b/public/locales/ko/translation.json
@@ -283,6 +283,8 @@
"서버에서 토론 진행 오류를 전달했어요.": "서버에서 토론 진행 오류를 전달했어요.",
"알 수 없는 실시간 오류가 발생했어요.": "알 수 없는 실시간 오류가 발생했어요.",
"토론 시작을 대기 중입니다.": "토론 시작을 대기 중입니다.",
+ "사회자 연결을 기다리는 중이에요.": "사회자 연결을 기다리는 중이에요.",
+ "사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.": "사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.",
"토론이 종료되었습니다.": "토론이 종료되었습니다.",
"페이지 닫기": "페이지 닫기",
"참여해 주셔서 감사합니다.": "참여해 주셔서 감사합니다.",
diff --git a/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx b/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
new file mode 100644
index 00000000..c746519f
--- /dev/null
+++ b/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
@@ -0,0 +1,51 @@
+import { useTranslation } from 'react-i18next';
+import { MdWifiOff } from 'react-icons/md';
+
+export type ChairmanStatusNoticeVariant = 'waiting' | 'absent';
+
+interface ChairmanStatusNoticeProps {
+ variant: ChairmanStatusNoticeVariant;
+}
+
+/**
+ * 청중 화면에 사회자 연결 상태를 안내합니다.
+ * - `waiting`: 타이머 위에 사회자 연결을 기다리는 중이라는 배너를 표시합니다.
+ * - `absent`: 타이머 위를 덮는 반투명 오버레이로 사회자 연결이 끊겼음을 표시합니다.
+ */
+export default function ChairmanStatusNotice({
+ variant,
+}: ChairmanStatusNoticeProps) {
+ const { t } = useTranslation();
+
+ if (variant === 'waiting') {
+ return (
+
+
+
+ {t('사회자 연결을 기다리는 중이에요.')}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t('사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.')}
+
+
+ );
+}
From 58894fb3771baf8bbd8c376f2e9cba36307bfd78 Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:41:23 +0900
Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=EC=B2=AD=EC=A4=91=20=ED=99=94?=
=?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=82=AC=ED=9A=8C=EC=9E=90=20=EB=8C=80?=
=?UTF-8?q?=EA=B8=B0/=EB=81=8A=EA=B9=80=20=EC=95=88=EB=82=B4=20=ED=91=9C?=
=?UTF-8?q?=EC=8B=9C=20=EB=B0=8F=20=EB=81=8A=EA=B9=80=20=EC=8B=9C=20?=
=?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=EB=8B=A4=EC=9A=B4=20=EC=A0=95?=
=?UTF-8?q?=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 사회자 메시지 수신 전에는 정지된 첫 타이머와 함께 대기 배너 표시
- 사회자 연결이 끊기면 오버레이를 띄우고 일반/자유토론 카운트다운을 현재 값에서 정지
- 재접속 후 SYNC를 받으면 받은 상태로 자동 복구
Co-Authored-By: Claude Opus 5
---
.../AudienceSharePage.test.tsx | 134 +++++++++++++++++-
.../AudienceSharePage/AudienceSharePage.tsx | 32 ++++-
2 files changed, 161 insertions(+), 5 deletions(-)
diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx
index 63a6faa4..df11a31c 100644
--- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx
+++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx
@@ -112,7 +112,7 @@ describe('AudienceSharePage', () => {
},
});
- return render(
+ const createPage = () => (
@@ -125,8 +125,14 @@ describe('AudienceSharePage', () => {
- ,
+
);
+ const result = render(createPage());
+
+ return {
+ ...result,
+ rerenderPage: () => result.rerender(createPage()),
+ };
};
beforeEach(() => {
@@ -592,6 +598,130 @@ describe('AudienceSharePage', () => {
});
});
+ describe('사회자 연결 상태 안내', () => {
+ const WAITING_MESSAGE = '사회자 연결을 기다리는 중이에요.';
+ const ABSENT_MESSAGE =
+ '사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.';
+
+ const normalDisplayData = {
+ timerType: 'NORMAL' as const,
+ currentTeam: null,
+ isRunning: true,
+ singleTime: 10,
+ sequence: 0,
+ };
+
+ const timeBasedDisplayData = {
+ timerType: 'TIME_BASED' as const,
+ currentTeam: 'PROS' as const,
+ isRunning: true,
+ prosTime: 10,
+ consTime: 20,
+ sequence: 2,
+ eventType: 'PLAY' as const,
+ revision: 1,
+ };
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('사회자 메시지를 기다리는 중이면 타이머와 함께 대기 안내를 표시한다', async () => {
+ mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'waiting',
+ status: 'displaying',
+ error: null,
+ displayData: { ...normalDisplayData, isRunning: false },
+ });
+ renderPage('/live/123');
+
+ expect(await screen.findByText(WAITING_MESSAGE)).toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent(WAITING_MESSAGE);
+ expect(screen.getByTestId('timer-value')).toBeInTheDocument();
+ });
+
+ it('사회자가 연결되어 있으면 안내를 표시하지 않는다', async () => {
+ mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
+ status: 'displaying',
+ error: null,
+ displayData: normalDisplayData,
+ });
+ renderPage('/live/123');
+
+ expect(await screen.findByTestId('timer-value')).toBeInTheDocument();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('사회자 연결이 끊기면 끊김 안내를 표시하고 NORMAL 카운트다운을 현재 값에서 멈춘다', async () => {
+ vi.useFakeTimers();
+ mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'present',
+ status: 'displaying',
+ error: null,
+ displayData: normalDisplayData,
+ });
+ const { rerenderPage } = renderPage('/live/123');
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+ expect(screen.getByTestId('timer-value')).toHaveAttribute(
+ 'aria-label',
+ '00 : 08',
+ );
+
+ mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'absent',
+ status: 'displaying',
+ error: null,
+ displayData: normalDisplayData,
+ });
+ rerenderPage();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+
+ expect(screen.getByRole('status')).toHaveTextContent(ABSENT_MESSAGE);
+ expect(screen.getByTestId('timer-value')).toHaveAttribute(
+ 'aria-label',
+ '00 : 08',
+ );
+ });
+
+ it('사회자 연결이 끊기면 TIME_BASED 카운트다운을 멈춘다', async () => {
+ vi.useFakeTimers();
+ mockUseAudienceShareState.mockReturnValue({
+ chairmanPresence: 'absent',
+ status: 'displaying',
+ error: null,
+ displayData: timeBasedDisplayData,
+ });
+ renderPage('/live/123');
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(screen.getByTestId('pros-current-timer')).toHaveAttribute(
+ 'aria-label',
+ '00 : 10',
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000);
+ });
+
+ expect(screen.getByRole('status')).toHaveTextContent(ABSENT_MESSAGE);
+ expect(screen.getByTestId('pros-current-timer')).toHaveAttribute(
+ 'aria-label',
+ '00 : 10',
+ );
+ });
+ });
+
describe('오류 처리', () => {
it('API 조회에 실패하면 페이지 중앙에 데이터 오류와 새로고침 버튼을 표시한다', async () => {
server.use(
diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx
index 98df4cb8..7cac994f 100644
--- a/src/page/AudienceSharePage/AudienceSharePage.tsx
+++ b/src/page/AudienceSharePage/AudienceSharePage.tsx
@@ -7,6 +7,7 @@ import { useAudienceCountdown } from './hooks/useAudienceCountdown';
import { useAudienceTimeBasedCountdown } from './hooks/useAudienceTimeBasedCountdown';
import AudienceNormalTimer from './components/AudienceNormalTimer';
import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer';
+import ChairmanStatusNotice from './components/ChairmanStatusNotice';
import DefaultLayout from '../../layout/defaultLayout/DefaultLayout';
import LoadingSpinner from '../../components/LoadingSpinner';
import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo';
@@ -87,6 +88,9 @@ export default function AudienceSharePage() {
t,
);
+ // 사회자 연결이 끊기면 마지막으로 보던 시간에서 카운트다운을 멈춘다
+ const isChairmanAbsent = state.chairmanPresence === 'absent';
+
const normalCountdown = useAudienceCountdown({
receivedTime:
viewState.type === 'NORMAL_TIMER'
@@ -94,12 +98,18 @@ export default function AudienceSharePage() {
: null,
isRunning:
viewState.type === 'NORMAL_TIMER'
- ? viewState.displayData.isRunning
+ ? viewState.displayData.isRunning && !isChairmanAbsent
: false,
+ // 끊김으로 멈출 때는 마지막 수신 시간으로 되돌리지 않고 현재 값을 유지
+ shouldResetOnRunStateChange: !isChairmanAbsent,
});
const timeBasedCountdown = useAudienceTimeBasedCountdown({
displayData:
- viewState.type === 'TIME_BASED_TIMER' ? viewState.displayData : null,
+ viewState.type === 'TIME_BASED_TIMER'
+ ? isChairmanAbsent
+ ? { ...viewState.displayData, isRunning: false }
+ : viewState.displayData
+ : null,
timePerTeam:
viewState.type === 'TIME_BASED_TIMER'
? viewState.timeBox.timePerTeam
@@ -215,6 +225,12 @@ export default function AudienceSharePage() {
}
};
+ const isTimerVisible =
+ viewState.type === 'NORMAL_TIMER' || viewState.type === 'TIME_BASED_TIMER';
+ const shouldShowWaitingNotice =
+ isTimerVisible && state.chairmanPresence === 'waiting';
+ const shouldShowAbsentNotice = isTimerVisible && isChairmanAbsent;
+
const isHeaderVisible =
viewState.type === 'WAITING' ||
viewState.type === 'NORMAL_TIMER' ||
@@ -255,7 +271,17 @@ export default function AudienceSharePage() {
{agendaLabel}
) : null}
- {renderContent()}
+ {shouldShowWaitingNotice ? (
+
+
+
+ ) : null}
+
+ {renderContent()}
+ {shouldShowAbsentNotice ? (
+
+ ) : null}
+
From b0167c67dd96c4555bf56d240c6d5634f5444621 Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 21:46:43 +0900
Subject: [PATCH 6/7] =?UTF-8?q?[DOCS]=20=EB=A1=9C=EC=BB=AC=20BE=20?=
=?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20OAu?=
=?UTF-8?q?th=20=EC=9A=B0=ED=9A=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?=
=?UTF-8?q?=EB=B0=A9=EB=B2=95=20=EB=B0=8F=20=EC=8A=A4=ED=81=AC=EB=A6=BD?=
=?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- scripts/local-login-token.mjs: H2 콘솔로 테스트 회원을 넣고 local JWT secret으로 액세스 토큰 발급
- docs/local-backend-login.md: 로컬 BE 실행(CORS 인자), 토큰 발급, localStorage 설정 절차
Co-Authored-By: Claude Opus 5
---
docs/local-backend-login.md | 61 ++++++++++++++++++++
scripts/local-login-token.mjs | 105 ++++++++++++++++++++++++++++++++++
2 files changed, 166 insertions(+)
create mode 100644 docs/local-backend-login.md
create mode 100644 scripts/local-login-token.mjs
diff --git a/docs/local-backend-login.md b/docs/local-backend-login.md
new file mode 100644
index 00000000..d05da820
--- /dev/null
+++ b/docs/local-backend-login.md
@@ -0,0 +1,61 @@
+# 로컬 BE 연동 테스트 (OAuth 우회 로그인)
+
+로컬 BE(`debate-timer-be`, `local` 프로필)와 FE를 함께 띄워 테스트할 때 쓰는 방법입니다.
+로컬에서는 Google OAuth 로그인이 동작하지 않으므로, 테스트 회원을 직접 넣고 local JWT secret으로 서명한 액세스 토큰을 `localStorage`에 넣어 로그인한 상태로 만듭니다.
+
+## 1. 로컬 BE 실행
+
+`local` 프로필은 H2 메모리 DB(`jdbc:h2:mem:database`)를 사용하므로 **BE를 재시작하면 회원·테이블 데이터가 모두 사라집니다.** 재시작 후에는 2단계부터 다시 진행하세요.
+
+`application-local.yml`의 CORS 허용 origin은 `http://localhost:8080`뿐이라 FE 개발 서버(`3000`)의 요청과 SockJS 연결이 막힙니다. 실행 인자로 `3000`을 추가합니다.
+
+```bash
+cd ../debate_timer/debate-timer-be
+sh ./gradlew bootRun --args='--cors.origin.cors-origin=http://localhost:3000,http://localhost:8080'
+```
+
+- `gradlew`에 실행 권한이 없으면 `./gradlew` 대신 `sh ./gradlew`로 실행합니다.
+- `Started DebateTimerApplication` 로그가 보이면 준비된 것입니다.
+
+## 2. FE 실행
+
+`.env.local`의 `VITE_API_BASE_URL`이 `http://localhost:8080`인지 확인한 뒤 실행합니다.
+
+```bash
+npm run dev # http://localhost:3000
+```
+
+## 3. 테스트 회원 생성 + 액세스 토큰 발급
+
+```bash
+node scripts/local-login-token.mjs # 기본 이메일: local-tester@debate.local
+node scripts/local-login-token.mjs me@test.dev # 이메일 지정
+```
+
+스크립트가 하는 일:
+
+1. H2 콘솔(`/h2-console`)로 `MEMBER` 테이블에 회원을 넣습니다. 같은 이메일이 있으면 그대로 둡니다.
+ BE는 토큰의 이메일(`sub`)로 회원을 조회하므로 회원 행이 반드시 있어야 합니다.
+2. local secret(`jwt.secret_key`)으로 `type: ACCESS_TOKEN` JWT(HS256)를 서명합니다.
+3. `GET /api/table`로 토큰이 통과하는지 확인합니다.
+4. 브라우저 콘솔에 붙여넣을 코드를 출력합니다.
+
+환경 변수로 기본값을 바꿀 수 있습니다: `LOCAL_API_BASE_URL`, `LOCAL_JWT_SECRET`, `LOCAL_TOKEN_TTL_SECONDS`(기본 86400초).
+
+## 4. 브라우저에 토큰 넣기
+
+`http://localhost:3000`을 열고 개발자 도구 콘솔에 스크립트가 출력한 코드를 붙여넣습니다.
+
+```js
+localStorage.setItem('accessToken', '');
+localStorage.setItem('memberId', '');
+location.reload();
+```
+
+FE는 `localStorage.accessToken`을 `Authorization` 헤더에 그대로 붙여 보냅니다(`src/util/accessToken.ts`, `src/apis/axiosInstance.ts`).
+
+## 참고
+
+- refresh token 쿠키는 없으므로 토큰이 만료되면 3~4단계를 다시 하세요.
+- 청중 화면(`/live/{tableId}`)은 로그인이 필요 없으므로 시크릿 창이나 다른 브라우저로 열면 사회자와 청중을 동시에 테스트할 수 있습니다.
+- 사회자 공유는 타이머 페이지(`/table/customize/{tableId}`)에서 공유 모달을 열 때 소켓이 연결됩니다.
diff --git a/scripts/local-login-token.mjs b/scripts/local-login-token.mjs
new file mode 100644
index 00000000..37e2701e
--- /dev/null
+++ b/scripts/local-login-token.mjs
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+// 로컬 BE(local 프로필, H2 메모리 DB)에서 Google OAuth 없이 로그인하기 위한 도구입니다.
+// 1. H2 콘솔로 테스트 회원을 넣고 (이미 있으면 건너뜀)
+// 2. local 프로필의 JWT secret으로 ACCESS_TOKEN을 서명해 출력합니다.
+//
+// 사용: node scripts/local-login-token.mjs [email]
+// 환경 변수:
+// LOCAL_API_BASE_URL (기본 http://localhost:8080)
+// LOCAL_JWT_SECRET (기본값은 BE application-local.yml의 jwt.secret_key)
+// LOCAL_TOKEN_TTL_SECONDS (기본 86400)
+import { createHmac } from 'node:crypto';
+
+const email = process.argv[2] ?? 'local-tester@debate.local';
+const baseUrl = process.env.LOCAL_API_BASE_URL ?? 'http://localhost:8080';
+const secret =
+ process.env.LOCAL_JWT_SECRET ?? 'lnklbdsxjfhruyfidjgjgrjfjfjhfhhh';
+const ttlSeconds = Number(process.env.LOCAL_TOKEN_TTL_SECONDS ?? 86400);
+
+function base64Url(value) {
+ return Buffer.from(value).toString('base64url');
+}
+
+function signAccessToken() {
+ const now = Math.floor(Date.now() / 1000);
+ const header = base64Url(JSON.stringify({ alg: 'HS256' }));
+ const payload = base64Url(
+ JSON.stringify({
+ sub: email,
+ iat: now,
+ exp: now + ttlSeconds,
+ type: 'ACCESS_TOKEN',
+ }),
+ );
+ const signature = createHmac('sha256', secret)
+ .update(`${header}.${payload}`)
+ .digest('base64url');
+ return `${header}.${payload}.${signature}`;
+}
+
+async function runH2Query(sessionId, sql) {
+ const response = await fetch(
+ `${baseUrl}/h2-console/query.do?jsessionid=${sessionId}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ sql }),
+ },
+ );
+ return response.text();
+}
+
+async function ensureMember() {
+ const entry = await fetch(`${baseUrl}/h2-console/`).then((res) => res.text());
+ const sessionId = entry.match(/jsessionid=([a-z0-9]+)/)?.[1];
+ if (!sessionId) {
+ throw new Error(
+ 'H2 콘솔 세션을 만들지 못했습니다. BE가 local 프로필로 실행 중인지 확인하세요.',
+ );
+ }
+
+ await fetch(`${baseUrl}/h2-console/login.do?jsessionid=${sessionId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ driver: 'org.h2.Driver',
+ url: 'jdbc:h2:mem:database',
+ user: 'sa',
+ password: '',
+ }),
+ });
+
+ const escapedEmail = email.replaceAll("'", "''");
+ await runH2Query(
+ sessionId,
+ `MERGE INTO MEMBER (EMAIL, CREATED_AT, MODIFIED_AT) KEY (EMAIL) VALUES ('${escapedEmail}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
+ );
+ const result = await runH2Query(
+ sessionId,
+ `SELECT ID FROM MEMBER WHERE EMAIL = '${escapedEmail}'`,
+ );
+ const memberId = result.replace(/<[^>]*>/g, ' ').match(/\bID\s+(\d+)/)?.[1];
+ if (!memberId) {
+ throw new Error('회원 ID를 조회하지 못했습니다.');
+ }
+ return memberId;
+}
+
+const memberId = await ensureMember();
+const accessToken = signAccessToken();
+
+const check = await fetch(`${baseUrl}/api/table`, {
+ headers: { Authorization: accessToken },
+});
+if (!check.ok) {
+ throw new Error(`발급한 토큰 검증 실패: GET /api/table → ${check.status}`);
+}
+
+console.log(`email: ${email}`);
+console.log(`memberId: ${memberId}`);
+console.log(`accessToken: ${accessToken}`);
+console.log('');
+console.log('브라우저(http://localhost:3000) 개발자 도구 콘솔에 붙여넣기:');
+console.log(
+ `localStorage.setItem('accessToken', '${accessToken}'); localStorage.setItem('memberId', '${memberId}'); location.reload();`,
+);
From 9a434a83e512b6a079466fba83b407fc096c6e75 Mon Sep 17 00:00:00 2001
From: coli
Date: Tue, 22 Sep 2026 22:37:03 +0900
Subject: [PATCH 7/7] =?UTF-8?q?[REFACTOR]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?=
=?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EB=B0=B0=EB=84=88=20pulse=20=EC=95=A0?=
=?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=EC=9D=84=20Framer=20Motion?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Tailwind animate-pulse 대신 motion.span으로 opacity 반복 애니메이션 적용
- 코딩 가이드라인(애니메이션은 Framer Motion 사용) 반영
Co-Authored-By: Claude Opus 5
---
.../AudienceSharePage/components/ChairmanStatusNotice.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx b/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
index c746519f..f5dea719 100644
--- a/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
+++ b/src/page/AudienceSharePage/components/ChairmanStatusNotice.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { MdWifiOff } from 'react-icons/md';
+import { motion } from 'framer-motion';
export type ChairmanStatusNoticeVariant = 'waiting' | 'absent';
@@ -23,8 +24,10 @@ export default function ChairmanStatusNotice({
role="status"
className="mx-auto flex w-fit max-w-full items-center gap-2 rounded-full bg-gray-100 px-4 py-2 text-sm font-semibold text-gray-700 xl:text-base"
>
-