Skip to content

Commit 8d7531a

Browse files
[feat] 웹소켓 메시지 복구 기능 설계 (#1075)
* feat: WebSocketSuccess 타입에 id 필드 추가 * feat: WebSocket 메시지 처리 시 streamId 저장 로직 추가 * feat: Recovery API 호출 및 streamId 관리 로직 추가 * feat: WebSocket 복구 로직 및 화면 전환 기능 추가 * refactor: WebSocket 복구 로직 WebSocketProvider로 통합 및 재연결 처리 개선 * refactor: WebSocket 관련 주석 정리 및 코드 간소화 * feat: 메시지 복구 재시도 로직 및 RecoveryMessage 타입 수정 * feat: subscriptionRegistry 유틸 추가 및 WebSocket 복구 로직 연동 * fix: Recovery API 호출 경로 수정 * feat: 복구 로직 상태 관리 및 WebSocket 메시지 처리 개선
1 parent e3a47d7 commit 8d7531a

File tree

10 files changed

+339
-15
lines changed

10 files changed

+339
-15
lines changed

frontend/src/apis/rest/recovery.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { WebSocketMessage } from '../websocket/constants/constants';
2+
import { api } from './api';
3+
4+
export type RecoveryMessage = {
5+
streamId: string;
6+
destination: string;
7+
response: WebSocketMessage<unknown>;
8+
timestamp: number;
9+
};
10+
11+
type RecoveryResponse = {
12+
success: boolean;
13+
messages: RecoveryMessage[];
14+
errorMessage?: string;
15+
};
16+
17+
export const fetchRecoveryMessages = async (
18+
joinCode: string,
19+
playerName: string,
20+
lastStreamId: string
21+
): Promise<RecoveryMessage[]> => {
22+
try {
23+
const response = await api.post<RecoveryResponse, undefined>(
24+
`/api/rooms/${joinCode}/recovery?playerName=${encodeURIComponent(playerName)}&lastId=${encodeURIComponent(lastStreamId)}`
25+
);
26+
27+
if (!response.success) {
28+
console.warn('Recovery API 실패:', response.errorMessage);
29+
return [];
30+
}
31+
32+
return response.messages ?? [];
33+
} catch (error) {
34+
console.error('Recovery API 호출 실패:', error);
35+
return [];
36+
}
37+
};
38+
39+
export const getLastStreamId = (joinCode: string, playerName: string): string | null => {
40+
try {
41+
return localStorage.getItem(`lastStreamId:${joinCode}:${playerName}`);
42+
} catch {
43+
return null;
44+
}
45+
};
46+
47+
export const saveLastStreamId = (joinCode: string, playerName: string, streamId: string): void => {
48+
try {
49+
localStorage.setItem(`lastStreamId:${joinCode}:${playerName}`, streamId);
50+
} catch {
51+
// ignore
52+
}
53+
};
54+
55+
export const clearLastStreamId = (joinCode: string, playerName: string): void => {
56+
try {
57+
localStorage.removeItem(`lastStreamId:${joinCode}:${playerName}`);
58+
} catch {
59+
// ignore
60+
}
61+
};

frontend/src/apis/websocket/constants/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type WebSocketSuccess<T> = {
77
success: true;
88
data: T;
99
errorMessage: null;
10+
id: string | null;
1011
};
1112

1213
export type WebSocketError = {

frontend/src/apis/websocket/contexts/WebSocketContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type WebSocketContextType = {
1313
isConnected: boolean;
1414
client: Client | null;
1515
sessionId: string | null;
16+
isRecovering: boolean;
1617
};
1718

1819
export const WebSocketContext = createContext<WebSocketContextType | null>(null);

frontend/src/apis/websocket/contexts/WebSocketProvider.tsx

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,183 @@
1-
import { PropsWithChildren } from 'react';
1+
import { PropsWithChildren, useCallback, useRef, useSyncExternalStore } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import {
4+
fetchRecoveryMessages,
5+
getLastStreamId,
6+
RecoveryMessage,
7+
saveLastStreamId,
8+
} from '@/apis/rest/recovery';
9+
import { useIdentifier } from '@/contexts/Identifier/IdentifierContext';
210
import { useStompSessionWatcher } from '../hooks/useStompSessionWatcher';
311
import { useWebSocketConnection } from '../hooks/useWebSocketConnection';
412
import { useWebSocketMessaging } from '../hooks/useWebSocketMessaging';
513
import { useWebSocketReconnection } from '../hooks/useWebSocketReconnection';
14+
import { subscriptionRegistry } from '../utils/subscriptionRegistry';
615
import { WebSocketContext, WebSocketContextType } from './WebSocketContext';
716

17+
const TOPIC_PREFIX = '/topic';
18+
19+
const SCREEN_TRANSITION_PATTERNS = ['/roulette', '/winner', '/round'] as const;
20+
21+
const isScreenTransitionMessage = (destination: string): boolean => {
22+
return SCREEN_TRANSITION_PATTERNS.some((pattern) => destination.includes(pattern));
23+
};
24+
25+
const extractSubscriptionPath = (destination: string): string => {
26+
return destination.replace(TOPIC_PREFIX, '');
27+
};
28+
29+
// 모듈 레벨 변수로 동기적 접근 보장
30+
let isRecoveringGlobal = false;
31+
let listeners: Array<() => void> = [];
32+
33+
const setIsRecoveringGlobal = (value: boolean) => {
34+
isRecoveringGlobal = value;
35+
listeners.forEach((listener) => listener());
36+
};
37+
38+
const subscribeToRecovering = (listener: () => void) => {
39+
listeners.push(listener);
40+
return () => {
41+
listeners = listeners.filter((l) => l !== listener);
42+
};
43+
};
44+
45+
const getIsRecoveringSnapshot = () => isRecoveringGlobal;
46+
47+
// 외부에서 동기적으로 접근 가능한 함수 export
48+
export const getIsRecovering = () => isRecoveringGlobal;
49+
850
export const WebSocketProvider = ({ children }: PropsWithChildren) => {
51+
const navigate = useNavigate();
52+
const { joinCode, myName } = useIdentifier();
53+
54+
const isRecovering = useSyncExternalStore(subscribeToRecovering, getIsRecoveringSnapshot);
55+
const recoveryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
56+
957
const { client, isConnected, startSocket, stopSocket, connectedFrame } = useWebSocketConnection();
1058
const { sessionId } = useStompSessionWatcher(client, connectedFrame);
59+
const { subscribe, send } = useWebSocketMessaging({ client, isConnected, playerName: myName });
60+
61+
const routeRecoveryMessage = useCallback(
62+
(destination: string) => {
63+
const navOptions = { replace: true, state: { fromInternal: true } };
64+
65+
if (destination.includes('/roulette') && !destination.includes('/winner')) {
66+
console.log('🔄 복구: 룰렛 화면으로 이동');
67+
navigate(`/room/${joinCode}/roulette/play`, navOptions);
68+
return true;
69+
}
70+
71+
if (destination.includes('/winner')) {
72+
console.log('🔄 복구: 당첨자 화면으로 이동');
73+
navigate(`/room/${joinCode}/roulette/result`, navOptions);
74+
return true;
75+
}
76+
77+
if (destination.includes('/round')) {
78+
console.log('🔄 복구: 게임 시작 - 핸들러에게 위임');
79+
return false;
80+
}
81+
82+
return false;
83+
},
84+
[joinCode, navigate]
85+
);
86+
87+
const dispatchToSubscribers = useCallback((msg: RecoveryMessage) => {
88+
const { destination, response } = msg;
89+
const subscriptionPath = extractSubscriptionPath(destination);
90+
91+
if (response.success && response.data !== null) {
92+
const dispatched = subscriptionRegistry.dispatch(subscriptionPath, response.data);
93+
if (dispatched) {
94+
console.log('🔄 복구 메시지 핸들러에 전달:', subscriptionPath);
95+
}
96+
return dispatched;
97+
}
98+
return false;
99+
}, []);
100+
101+
const handleReconnected = useCallback(async () => {
102+
if (!joinCode || !myName) {
103+
console.log('⚠️ 복구 스킵: joinCode 또는 myName 없음');
104+
return;
105+
}
106+
107+
const lastStreamId = getLastStreamId(joinCode, myName);
108+
if (!lastStreamId) {
109+
console.log('⚠️ 복구 스킵: lastStreamId 없음');
110+
return;
111+
}
112+
113+
// 동기적으로 설정
114+
setIsRecoveringGlobal(true);
115+
116+
await new Promise((resolve) => setTimeout(resolve, 500));
117+
118+
console.log('🔄 메시지 복구 시작:', { joinCode, myName, lastStreamId });
119+
120+
const MAX_RETRY = 3;
121+
let messages: RecoveryMessage[] = [];
122+
123+
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
124+
messages = await fetchRecoveryMessages(joinCode, myName, lastStreamId);
125+
126+
if (messages.length > 0 || attempt === MAX_RETRY - 1) {
127+
break;
128+
}
129+
130+
console.log(`🔄 복구 재시도 ${attempt + 1}/${MAX_RETRY}`);
131+
await new Promise((resolve) => setTimeout(resolve, 300));
132+
}
133+
134+
if (messages.length === 0) {
135+
console.log('✅ 복구할 메시지 없음');
136+
setIsRecoveringGlobal(false);
137+
return;
138+
}
139+
140+
console.log(`🔄 복구 메시지 ${messages.length}개 처리`);
141+
142+
let lastScreenTransitionMsg: RecoveryMessage | null = null;
143+
144+
for (const msg of messages) {
145+
const { destination } = msg;
146+
147+
if (isScreenTransitionMessage(destination)) {
148+
lastScreenTransitionMsg = msg;
149+
} else {
150+
dispatchToSubscribers(msg);
151+
}
152+
153+
saveLastStreamId(joinCode, myName, msg.streamId);
154+
}
155+
156+
if (lastScreenTransitionMsg) {
157+
const handled = routeRecoveryMessage(lastScreenTransitionMsg.destination);
158+
159+
if (!handled) {
160+
dispatchToSubscribers(lastScreenTransitionMsg);
161+
}
162+
}
163+
164+
console.log('✅ 메시지 복구 완료');
165+
166+
// 이전 타이머 정리
167+
if (recoveryTimeoutRef.current) {
168+
clearTimeout(recoveryTimeoutRef.current);
169+
}
11170

12-
const { subscribe, send } = useWebSocketMessaging({ client, isConnected });
171+
recoveryTimeoutRef.current = setTimeout(() => {
172+
setIsRecoveringGlobal(false);
173+
}, 2000);
174+
}, [joinCode, myName, routeRecoveryMessage, dispatchToSubscribers]);
13175

14176
useWebSocketReconnection({
15177
isConnected,
16178
startSocket,
17179
stopSocket,
180+
onReconnected: handleReconnected,
18181
});
19182

20183
const contextValue: WebSocketContextType = {
@@ -25,6 +188,7 @@ export const WebSocketProvider = ({ children }: PropsWithChildren) => {
25188
isConnected,
26189
client,
27190
sessionId,
191+
isRecovering,
28192
};
29193

30194
return <WebSocketContext.Provider value={contextValue}>{children}</WebSocketContext.Provider>;

frontend/src/apis/websocket/hooks/useWebSocketMessaging.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { Client } from '@stomp/stompjs';
22
import { useCallback } from 'react';
33
import { WEBSOCKET_CONFIG, WebSocketMessage } from '../constants/constants';
4+
import { saveLastStreamId } from '@/apis/rest/recovery';
45
import WebSocketErrorHandler from '../utils/WebSocketErrorHandler';
56

67
type Props = {
78
client: Client | null;
89
isConnected: boolean;
10+
playerName: string | null;
911
};
1012

11-
export const useWebSocketMessaging = ({ client, isConnected }: Props) => {
13+
const extractJoinCodeFromDestination = (destination: string): string | null => {
14+
const match = destination.match(/\/room\/([^/]+)/);
15+
return match ? match[1] : null;
16+
};
17+
18+
export const useWebSocketMessaging = ({ client, isConnected, playerName }: Props) => {
1219
const subscribe = useCallback(
1320
<T>(url: string, onData: (data: T) => void, onError?: (error: Error) => void) => {
1421
if (!client || !isConnected) {
@@ -38,6 +45,13 @@ export const useWebSocketMessaging = ({ client, isConnected }: Props) => {
3845
return;
3946
}
4047

48+
if (parsedMessage.id && playerName) {
49+
const joinCode = extractJoinCodeFromDestination(url);
50+
if (joinCode) {
51+
saveLastStreamId(joinCode, playerName, parsedMessage.id);
52+
}
53+
}
54+
4155
onData(parsedMessage.data);
4256
} catch (error) {
4357
WebSocketErrorHandler.handleParsingError({
@@ -49,7 +63,7 @@ export const useWebSocketMessaging = ({ client, isConnected }: Props) => {
4963
}
5064
});
5165
},
52-
[client, isConnected]
66+
[client, isConnected, playerName]
5367
);
5468

5569
const send = useCallback(

frontend/src/apis/websocket/hooks/useWebSocketReconnection.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1+
import { useCallback, useEffect, useRef } from 'react';
12
import { useIdentifier } from '@/contexts/Identifier/IdentifierContext';
23
import { usePageVisibility } from '@/hooks/usePageVisibility';
3-
import { useCallback, useEffect, useRef } from 'react';
44

55
type Props = {
66
isConnected: boolean;
77
startSocket: (joinCode: string, myName: string) => void;
88
stopSocket: () => void;
9+
onReconnected?: () => void;
910
};
1011

11-
export const useWebSocketReconnection = ({ isConnected, startSocket, stopSocket }: Props) => {
12+
export const useWebSocketReconnection = ({
13+
isConnected,
14+
startSocket,
15+
stopSocket,
16+
onReconnected,
17+
}: Props) => {
1218
const { isVisible } = usePageVisibility();
1319
const { joinCode, myName } = useIdentifier();
1420
const reconnectTimerRef = useRef<number | null>(null);
1521
const wasBackgrounded = useRef(false);
1622
const hasCheckedRefresh = useRef(false);
23+
const wasDisconnectedRef = useRef(false);
1724

1825
const clearReconnectTimer = useCallback(() => {
1926
if (reconnectTimerRef.current) {
@@ -24,14 +31,20 @@ export const useWebSocketReconnection = ({ isConnected, startSocket, stopSocket
2431

2532
const scheduleReconnect = useCallback(() => {
2633
clearReconnectTimer();
34+
wasDisconnectedRef.current = true;
2735
reconnectTimerRef.current = window.setTimeout(() => {
2836
if (joinCode && myName) startSocket(joinCode, myName);
2937
}, 200);
3038
}, [joinCode, myName, startSocket, clearReconnectTimer]);
3139

32-
/**
33-
* 새로고침 감지
34-
*/
40+
useEffect(() => {
41+
if (isConnected && wasDisconnectedRef.current) {
42+
console.log('🔄 재연결 완료 - 복구 시작');
43+
wasDisconnectedRef.current = false;
44+
onReconnected?.();
45+
}
46+
}, [isConnected, onReconnected]);
47+
3548
useEffect(() => {
3649
if (hasCheckedRefresh.current) return;
3750

@@ -50,13 +63,11 @@ export const useWebSocketReconnection = ({ isConnected, startSocket, stopSocket
5063
if (isReload && !isConnected && joinCode && myName && startSocket) {
5164
console.log('🔄 새로고침 감지 - 웹소켓 재연결 시도:', { myName, joinCode });
5265
hasCheckedRefresh.current = true;
66+
wasDisconnectedRef.current = true;
5367
startSocket(joinCode, myName);
5468
}
5569
}, [myName, joinCode, isConnected, startSocket]);
5670

57-
/**
58-
* 백그라운드 ↔ 포그라운드 감지
59-
*/
6071
useEffect(() => {
6172
if (!isVisible && isConnected) {
6273
console.log('📱 백그라운드 전환 - 소켓 연결 해제');
@@ -74,9 +85,6 @@ export const useWebSocketReconnection = ({ isConnected, startSocket, stopSocket
7485
return () => clearReconnectTimer();
7586
}, [isVisible, isConnected, stopSocket, scheduleReconnect, clearReconnectTimer]);
7687

77-
/**
78-
* 온라인/오프라인 감지
79-
*/
8088
useEffect(() => {
8189
const handleOnline = () => {
8290
if (!isConnected) {

0 commit comments

Comments
 (0)