diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts index 7ca73d9d..6a9ffbac 100644 --- a/src/hooks/sockets/useChairmanSocket.test.ts +++ b/src/hooks/sockets/useChairmanSocket.test.ts @@ -330,7 +330,7 @@ describe('useChairmanSocket', () => { expect(latestCallback).toHaveBeenCalledTimes(1); }); - it('소켓이 연결(재연결 포함)되면 onSyncRequest를 호출해 현재 상태를 먼저 공유해야 한다', () => { + it('소켓이 연결(재연결 포함)되면 채널 재구독이 끝난 뒤 onSyncRequest를 호출해 현재 상태를 먼저 공유해야 한다', async () => { const connectionListeners: Array<() => void> = []; addConnectionListener.mockImplementation((listener: () => void) => { connectionListeners.push(listener); @@ -344,6 +344,13 @@ describe('useChairmanSocket', () => { connectionListeners.forEach((listener) => listener()); }); + // 연결 리스너 실행 시점(재구독 전)에는 아직 공유하지 않는다 + expect(onSyncRequest).not.toHaveBeenCalled(); + + await act(async () => { + await Promise.resolve(); + }); + expect(onSyncRequest).toHaveBeenCalledTimes(1); }); @@ -368,6 +375,44 @@ describe('useChairmanSocket', () => { }; } + it('사회자 채널을 구독할 때 최신 사회자 토큰을 첨부해야 한다', () => { + let resolveHeaders: () => Record = () => ({}); + subscribe.mockImplementation( + ( + _destination: string, + _callback: (message: IMessage) => void, + headers: () => Record, + ) => { + resolveHeaders = headers; + }, + ); + let token = 'first-token'; + + renderHook(() => useChairmanSocket(123, { getAuthToken: () => token })); + + expect(resolveHeaders().Authorization).toBe('first-token'); + + token = 'refreshed-token'; + expect(resolveHeaders().Authorization).toBe('refreshed-token'); + }); + + it('사회자 토큰이 없으면 Authorization 헤더를 첨부하지 않아야 한다', () => { + let resolveHeaders: () => Record = () => ({}); + subscribe.mockImplementation( + ( + _destination: string, + _callback: (message: IMessage) => void, + headers: () => Record, + ) => { + resolveHeaders = headers; + }, + ); + + renderHook(() => useChairmanSocket(123, { getAuthToken: () => null })); + + expect(resolveHeaders()).not.toHaveProperty('Authorization'); + }); + it('사회자 채널 구독과 이벤트 발행에 같은 사회자 세션 식별자를 첨부해야 한다', () => { const channel = captureChairmanChannel(); diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts index 2d1c0e1c..2ff13a49 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -62,13 +62,20 @@ interface UseChairmanSocketOptions { * 연결된 동안에는 heartbeat로 `CHAIRMAN_HEARTBEAT_INTERVAL_MS`마다 호출됩니다. */ onSyncRequest?: () => void; + + /** + * 사회자 채널을 구독할 때 첨부할 사회자 토큰을 반환합니다. + * 서버는 유효한 사회자 토큰을 가진 테이블 소유자의 구독만 허용합니다. + * 재연결 후 다시 구독할 때마다 호출되므로 갱신된 토큰이 첨부됩니다. + */ + getAuthToken?: () => string | null | undefined; } export default function useChairmanSocket( roomId: number, options: UseChairmanSocketOptions = {}, ) { - const { onSyncRequest } = options; + const { onSyncRequest, getAuthToken } = options; const queryClient = useQueryClient(); const { connect, @@ -97,6 +104,11 @@ export default function useChairmanSocket( onSyncRequestRef.current = onSyncRequest; }, [onSyncRequest]); + const getAuthTokenRef = useRef(getAuthToken); + useEffect(() => { + getAuthTokenRef.current = getAuthToken; + }, [getAuthToken]); + // 마지막으로 발행한 이벤트의 version const versionRef = useRef(0); @@ -148,7 +160,11 @@ export default function useChairmanSocket( useEffect(() => { return addConnectionListener(() => { resetSignalState(); - onSyncRequestRef.current?.(); + // 연결 리스너는 채널 재구독보다 먼저 실행되므로, 사회자 채널 구독(활성 사회자 등록)이 + // 서버에 먼저 도착하도록 재구독이 끝난 뒤에 현재 상태를 공유한다 + queueMicrotask(() => { + onSyncRequestRef.current?.(); + }); }); }, [addConnectionListener, resetSignalState]); @@ -183,7 +199,13 @@ export default function useChairmanSocket( setLastSignalTime(Date.now()); onSyncRequestRef.current?.(); }, - () => ({ [CHAIRMAN_SESSION_HEADER]: chairmanSessionIdRef.current }), + () => { + const authToken = getAuthTokenRef.current?.(); + return { + [CHAIRMAN_SESSION_HEADER]: chairmanSessionIdRef.current, + ...(authToken ? { Authorization: authToken } : {}), + }; + }, ); return () => { diff --git a/src/page/TimerPage/hooks/useLiveShare.ts b/src/page/TimerPage/hooks/useLiveShare.ts index 21730e3b..e8923e4a 100644 --- a/src/page/TimerPage/hooks/useLiveShare.ts +++ b/src/page/TimerPage/hooks/useLiveShare.ts @@ -47,6 +47,13 @@ export function useLiveShare( isLiveShareModalOpen && isValidTableId, ); + // 사회자 채널을 (재)구독할 때 최신 사회자 토큰을 첨부하기 위해 ref로 보관 + const chairmanTokenRef = useRef(chairmanToken); + useEffect(() => { + chairmanTokenRef.current = chairmanToken; + }, [chairmanToken]); + const getAuthToken = useCallback(() => chairmanTokenRef.current, []); + // 소켓 훅 const { connect, @@ -55,7 +62,7 @@ export function useLiveShare( isReplaced, sendDebateEvent, error: socketError, - } = useChairmanSocket(tableId, { onSyncRequest }); + } = useChairmanSocket(tableId, { onSyncRequest, getAuthToken }); // 공유용 URL const shareUrl = useMemo(() => {