모바일 환경에서 WebSocket/STOMP 연결이 유지되지 않는 문제가 있군요. 이런 문제는 주로 다음과 같은 원인에서 발생합니다:

  1. 모바일 기기의 절전 모드 또는 백그라운드에서 웹소켓 연결이 종료됨
  2. 네트워크 상태 변화에 대한 적절한 대응 부족
  3. 타임아웃 설정이나 하트비트 관리 문제
  4. 재연결 로직의 불완전함

코드를 분석해 보니 개선이 필요한 부분이 몇 가지 있습니다. 다음과 같이 수정해 보세요:

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)
  }
}))

이 변경사항들을 적용하면 다음과 같은 개선 효과를 얻을 수 있습니다:

  1. 지수 백오프를 통한 재연결 메커니즘 강화: 재연결 시도 간격을 점진적으로 늘려 서버 부하를 줄이고 배터리 소모를 최적화합니다.
  2. 네트워크 상태 모니터링: 브라우저의 온라인/오프라인 이벤트를 감지하여 네트워크 상태 변화에 대응합니다.
  3. 주기적인 연결 상태 확인: 5분마다 연결 상태를 확인하고 필요 시 재연결합니다.
  4. 가시성 변경 개선: 앱이 포그라운드로 돌아올 때 연결 상태를 확인하고 핑을 보내 연결 상태를 검증합니다.
  5. 하트비트 간격 최적화: 하트비트 간격을 10초에서 4초로 줄여 모바일 환경에서의 연결 유지를 개선합니다.
  6. 구독 관리 개선: 구독 상태를 더 세밀하게 관리하고 재연결 시 구독을 복원합니다.

이러한 변경사항을 통해 모바일 환경에서 화면이 꺼졌다가 켜지거나, 장시간 앱을 켜둔 상태에서의 WebSocket 연결 유지 문제를 해결할 수 있을 것입니다.