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 ( +
+
+ ); + } + + return ( +
+
+ ); +} 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" > -