모바일 환경에서 WebSocket/STOMP 연결이 유지되지 않는 문제가 있군요. 이런 문제는 주로 다음과 같은 원인에서 발생합니다:
코드를 분석해 보니 개선이 필요한 부분이 몇 가지 있습니다. 다음과 같이 수정해 보세요:
tsx
[ ]
// WebSocketProvider.tsx 개선
import React, { createContext, useRef, useEffect, useState, useCallback } from 'react'
import { Client } from '@stomp/stompjs'
import SockJS from 'sockjs-client'
import { getCookie } from '@/hooks/useCookieAuth'
const TOKEN_NAME = 'accessToken'
const RECONNECT_DELAY = 5000
const MAX_RECONNECT_ATTEMPTS = 10
type WebSocketContextType = {
client: Client | null
isConnected: boolean
reconnect: () => void
}
export const WebSocketContext = createContext<WebSocketContextType>({
client: null,
isConnected: false,
reconnect: () => {},
})
export const WebSocketProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const token = getCookie(TOKEN_NAME)
const [isConnected, setIsConnected] = useState(false)
const clientRef = useRef<Client | null>(null)
const reconnectAttemptsRef = useRef(0)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// WebSocket 연결 함수
const connectWebSocket = useCallback(() => {
// 이미 연결 중이거나 활성화된 클라이언트가 있다면 중복 연결 방지
if (clientRef.current?.active) {
console.log('[STOMP] Client already active, not reconnecting')
return
}
// 이전 재연결 타이머가 있다면 제거
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
const socket = new SockJS('<https://j12e202.p.ssafy.io/chat/ws-endpoint>')
const client = new Client({
webSocketFactory: () => socket,
connectHeaders: {
Authorization: token ? `Bearer ${token}` : '',
},
debug: (str) => console.log('[STOMP]', str),
reconnectDelay: RECONNECT_DELAY,
heartbeatIncoming: 4000,// 하트비트 간격 줄임
heartbeatOutgoing: 4000,// 하트비트 간격 줄임
onConnect: () => {
console.log('[STOMP] connected')
setIsConnected(true)
reconnectAttemptsRef.current = 0// 연결 성공 시 재시도 카운터 초기화
},
onDisconnect: () => {
console.log('[STOMP] disconnected')
setIsConnected(false)
scheduleReconnect()
},
onStompError: (frame) => {
console.error('[STOMP ERROR]', frame)
scheduleReconnect()
},
onWebSocketError: (err) => {
console.error('[WEBSOCKET ERROR]', err)
scheduleReconnect()
},
onWebSocketClose: () => {
console.log('[WEBSOCKET] closed')
setIsConnected(false)
scheduleReconnect()
},
})
client.activate()
clientRef.current = client
}, [token])
// 재연결 스케줄링 함수
const scheduleReconnect = useCallback(() => {
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.log('[STOMP] Max reconnect attempts reached')
return
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
reconnectAttemptsRef.current += 1
const backoffDelay = RECONNECT_DELAY * Math.min(reconnectAttemptsRef.current, 5)// 지수 백오프 적용
console.log(`[STOMP] Scheduling reconnect in ${backoffDelay}ms (attempt ${reconnectAttemptsRef.current})`)
reconnectTimeoutRef.current = setTimeout(() => {
console.log('[STOMP] Attempting to reconnect...')
connectWebSocket()
}, backoffDelay)
}, [connectWebSocket])
// 외부에서 호출 가능한 재연결 함수
const reconnect = useCallback(() => {
console.log('[STOMP] Manual reconnection triggered')
if (clientRef.current?.active) {
clientRef.current.deactivate()
}
connectWebSocket()
}, [connectWebSocket])
// 네트워크 상태 변화 감지
useEffect(() => {
const handleOnline = () => {
console.log('[NETWORK] Browser is online, reconnecting...')
reconnect()
}
const handleOffline = () => {
console.log('[NETWORK] Browser is offline')
setIsConnected(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [reconnect])
// 페이지 가시성 변경 감지 (개선)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
console.log('[VISIBILITY] Page is visible again')
// 연결이 끊겼거나 활성화되지 않은 상태라면 재연결
if (!clientRef.current?.active || !isConnected) {
console.log('[STOMP] Reconnecting on visibility change...')
reconnect()
} else {
// 연결은 되어 있지만 상태 체크를 위해 핑 전송
try {
clientRef.current?.publish({
destination: '/app/ping',
body: JSON.stringify({ timestamp: new Date().getTime() }),
headers: { priority: '9' }
})
console.log('[STOMP] Sent ping to verify connection')
} catch (e) {
console.error('[STOMP] Failed to send ping, reconnecting...', e)
reconnect()
}
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// 주기적 연결 상태 확인 (5분마다)
const connectionCheckInterval = setInterval(() => {
if (clientRef.current?.active && isConnected) {
console.log('[STOMP] Checking connection status...')
try {
clientRef.current?.publish({
destination: '/app/ping',
body: JSON.stringify({ timestamp: new Date().getTime() }),
headers: { priority: '9' }
})
} catch (e) {
console.error('[STOMP] Failed to send ping during status check, reconnecting...', e)
reconnect()
}
} else if (!clientRef.current?.active && document.visibilityState === 'visible') {
console.log('[STOMP] Client inactive during status check, reconnecting...')
reconnect()
}
}, 5 * 60 * 1000)// 5분마다 체크
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
clearInterval(connectionCheckInterval)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
clientRef.current?.deactivate()
}
}, [isConnected, reconnect])
// 초기 연결
useEffect(() => {
connectWebSocket()
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
clientRef.current?.deactivate()
}
}, [connectWebSocket])
// 토큰 변경 시 재연결
useEffect(() => {
if (clientRef.current?.active) {
console.log('[STOMP] Token changed, reconnecting...')
reconnect()
}
}, [token, reconnect])
return (
<WebSocketContext.Provider
value={{
client: clientRef.current,
isConnected,
reconnect
}}
>
{children}
</WebSocketContext.Provider>
)
}
이제 useChatSubscription
훅도 개선합니다:
tsx
[ ]
// useChatSubscription.tsx
import { useContext, useEffect, useCallback, useRef } from 'react'
import { WebSocketContext } from '@/services/WebSocketProvider'
import { useChatConnectStore } from '@/stores/chatStore'
export const useChatSubscription = ({
roomId,
onMessage,
}: {
roomId: string
onMessage?: (msg: any) => void
}) => {
const { client, isConnected, reconnect } = useContext(WebSocketContext)
const subscribeToRoom = useChatConnectStore((state) => state.subscribeToRoom)
const subscribedRef = useRef(false)
const handleSubscription = useCallback(() => {
if (!client || !isConnected || !roomId) return
if (subscribedRef.current) return
try {
subscribeToRoom(roomId, client, onMessage || (() => {}))
subscribedRef.current = true
console.log(`[CHAT] Successfully subscribed to room: ${roomId}`)
} catch (err) {
console.error(`[CHAT] Failed to subscribe to room: ${roomId}`, err)
subscribedRef.current = false
// 구독 실패 시 웹소켓 재연결 시도
setTimeout(reconnect, 1000)
}
}, [client, isConnected, roomId, onMessage, subscribeToRoom, reconnect])
// 연결 상태가 변경될 때마다 구독 시도
useEffect(() => {
handleSubscription()
}, [handleSubscription, isConnected])
// 채팅룸이 변경될 때 구독 상태 초기화
useEffect(() => {
subscribedRef.current = false
handleSubscription()
}, [roomId, handleSubscription])
// 컴포넌트가 언마운트될 때 구독 상태 초기화
useEffect(() => {
return () => {
subscribedRef.current = false
}
}, [])
}
마지막으로 chatStore.ts
도 수정합니다:
tsx
[ ]
// chatStore.ts
import { BoardItemUsingInfo } from '@/interfaces/BoardInterface'
import { ReceiptData } from '@/interfaces/ChatInterfaces'
import { Client } from '@stomp/stompjs'
import { create } from 'zustand'
// 채팅
interface ChatConnectStore {
connected: boolean
setConnected: (status: boolean) => void
subscribedRooms: Record<string, {
subscriptionId: string;
active: boolean;
}>;
subscribeToRoom: (
roomId: string,
client: Client,
onMessage: (msg: any) => void,
) => void;
unsubscribeFromRoom: (roomId: string, client: Client) => void;
refreshSubscription: (roomId: string, client: Client, onMessage: (msg: any) => void) => void;
}
export const useChatConnectStore = create<ChatConnectStore>((set, get) => ({
connected: false,
setConnected: (status) => set({ connected: status }),
subscribedRooms: {},
subscribeToRoom: (roomId, client, onMessage) => {
const { subscribedRooms } = get()
// 이미 활성화된 구독이 있으면 중복 구독 방지
if (subscribedRooms[roomId]?.active) {
console.log(`[CHAT] Room ${roomId} already subscribed`)
return
}
try {
// 기존 구독이 있지만 비활성 상태라면 제거
if (subscribedRooms[roomId] && !subscribedRooms[roomId].active) {
try {
client.unsubscribe(subscribedRooms[roomId].subscriptionId)
console.log(`[CHAT] Cleaned up inactive subscription for room ${roomId}`)
} catch (err) {
console.warn(`[CHAT] Failed to clean up old subscription for room ${roomId}`, err)
}
}
// 새 구독 시작
const subscription = client.subscribe(`/topic/chat/${roomId}`, (message) => {
if (onMessage) {
try {
const body = JSON.parse(message.body)
console.log('[CHAT] Message received', body)
onMessage(body)
} catch (err) {
console.error('[CHAT] Failed to parse message', err, message.body)
}
}
})
// 입장 메시지 발행
client.publish({
destination: '/app/chat.enter',
body: JSON.stringify({ roomId }),
})
// 구독 정보 저장
set((state) => ({
subscribedRooms: {
...state.subscribedRooms,
[roomId]: {
subscriptionId: subscription.id,
active: true
}
},
}))
console.log(`[CHAT] Successfully subscribed to room ${roomId} with ID ${subscription.id}`)
} catch (err) {
console.error(`[CHAT] Failed to subscribe to room ${roomId}`, err)
throw err;// 상위 호출자에게 에러 전파
}
},
unsubscribeFromRoom: (roomId, client) => {
const { subscribedRooms } = get()
if (!subscribedRooms[roomId]) return
try {
client.unsubscribe(subscribedRooms[roomId].subscriptionId)
// 구독 정보 업데이트
set((state) => ({
subscribedRooms: {
...state.subscribedRooms,
[roomId]: {
...state.subscribedRooms[roomId],
active: false
}
},
}))
console.log(`[CHAT] Unsubscribed from room ${roomId}`)
} catch (err) {
console.error(`[CHAT] Failed to unsubscribe from room ${roomId}`, err)
}
},
refreshSubscription: (roomId, client, onMessage) => {
const { subscribedRooms, unsubscribeFromRoom, subscribeToRoom } = get()
if (subscribedRooms[roomId]) {
unsubscribeFromRoom(roomId, client)
}
subscribeToRoom(roomId, client, onMessage)
}
}))
이 변경사항들을 적용하면 다음과 같은 개선 효과를 얻을 수 있습니다:
이러한 변경사항을 통해 모바일 환경에서 화면이 꺼졌다가 켜지거나, 장시간 앱을 켜둔 상태에서의 WebSocket 연결 유지 문제를 해결할 수 있을 것입니다.