TIL

🧩 WebSocket, Recoil로 관리해도 될까?

inz1234 2025. 3. 22. 22:33

실시간 투표서비스를 개발하며 WebSocket을 사용 중에
현재 서비스에서 전역상태 관리로 Recoil을 사용 중이니 웹소켓도 recoil로 관리할까? 싶었다.
하지만 WebSocket은 단순한 값과는 성격이 다르기 때문에, Recoil로 관리하는 것이 오히려 문제가 될 수 있다는 걸 발견했다.


📌 WebSocket은 "값"이 아닌 "리소스 객체"

WebSocket은 단순한 데이터가 아니라, 서버와의 연결을 유지하는 연결된 객체다.

연결된 객체란?

  • life-cyle(연결, 해제, 에러, 브로드캐스트) 상태를 가진 객체
  • onopen, onmessage, onerror, onclose 등 이벤트 루프에 등록된 이벤트 핸들러도 가지고 있음
  • 지속적으로 외부와 연결되어 있는 상태로, 명시적으로 해제-close() 하지 않으면 메모리 누수 발생 가능
  • 다중 연결 시 서버 리소스를 과도하게 사용하게 될 수도 있음

따라서 이런 객체는 단순히 상태처럼 관리하기보다는, 생명주기를 관리할 수 있는 구조가 필요하다.


❌ Recoil로 WebSocket을 관리할 때 발생할 수 있는 문제

1. 생명주기 관리 어려움

Recoil은 "premitive 값" 기반의 상태를 관리하는 철학에 맞춰 설계되어 있음
  • 이 때, 상태는 주로 JSON-serializable한 값들이어야 하고
  • atom은 순수한 값 (number, string, array, object 등)을 저장
  • 이를 기반으로 파생된 상태(selector)나 비동기 상태를 선언적으로 만들 수 있게 되어 있음.

→ 그런데 웹소켓은 단순값이 아니라 "연결된 객체"
즉, 지속적으로 외부와 연결되어 있는 상태 = 이벤트 루프에 등록된 핸들러 등 관리가 필요한 리소스를 가지는 상태

2. 메모리 누수

  • Recoil에서 atom은 선언되는 순간부터 내부 store에 등록됨
  • 전역상태관리이기 때문에, 구독자가 0명이 되어도 기본적으로 메모리에 유지됨
  • 기본적으로 atom 값을 캐시로 유지 - 컴포넌트가 언마운트되더라도 명시적으로 reset하거나 RecoilRoot를 destroy하지 않는 이상, atom에 저장된 WebSocket 인스턴스는 메모리에 남아있음

명시적인 cleanup 없이는 GC 대상이 되지 않음

3. 구조적 책임 경계 모호

만약 recoil에서 명시적으로 clean-up을 한다면?
  • 여러 컴포넌트(A, B, C)가 같은 socket atom을 구독하고 있다고 가정해보자.
  • 이 중 A가 먼저 언마운트되면서 cleanup 용도로 socket.close()를 호출하게 되면,
  • B와 C는 여전히 socket이 필요함에도 예기치 않은 종료를 경험할 수 있다.

→ 즉, 누가 socket을 close할 책임을 갖는가?가 명확하지 않음

4. 다중 연결 및 리소스 낭비 가능성

각 컴포넌트(A, B, C)가 각각 WebSocket을 연결한다면?
  • 컴포넌트마다 따로 socket을 열게 되면, 불필요한 복수 연결이 발생할 수 있다.

서버 리소스 낭비 및 클라이언트 메모리 누수로 이어짐


✅ 그래서 Context API로 분리 관리

Context API를 통해 별도로 관리하는 것이 적절하다.

1. 명확한 사용 범위 설정

  • 현재 서비스에서는 WebSocket이 전체 앱에서 필요한 것이 아니라, 일부 하위 컴포넌트 트리에서만 필요한 상황
  • Context Provider로 필요한 범위만 감싸면, 불필요한 전역 상태화 없이 책임 범위를 명확히 분리할 수 있다.

2. 생명주기 기반 관리 가능

  • Provider 내부에서 useEffect로 socket을 생성하고, return 시점에 cleap-up 함수로 socket.close()로 해제하면
  • Provider  컴포넌트가 언마운트되는 순간 WebSocket도 함께 정리된다.

→ 즉, WebSocket 인스턴스도 자연스럽게 GC의 대상이 됨

3. 안전하고 예측 가능한 구조

  • 특정 구간에서만 WebSocket을 생성/관리하므로 복수 연결 위험이 줄고,
  • cleanup 타이밍이 명확하여 메모리 누수도 예방 가능

🔚 결론

WebSocket은 값처럼 다루기에는 너무 많은 상태와 핸들러를 가진 객체다.
Recoil은 전역 값을 관리하는 데 최적화되어 있지만, 리소스 객체를 다루기에는 부적절하다.

WebSocket과 같이 연결 상태가 존재하고, 명확한 연결/해제가 필요한 객체는
Context API를 통해 명확한 생명주기와 책임 범위 내에서 관리하는 것이 구조적으로 더 안전하다.

값 중심의 상태는 Recoil로,
연결 중심의 리소스 객체는 Context로 분리해서 관리하는 것이
생명주기, 책임 범위, 안정성 측면에서 더 바람직하다고 판단했다.


Context API를 활용한 WebSocket 관리 코드

const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
  const [connected, setConnected] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const clientRef = useRef<Client | null>(null);

  const initializeWebSocket = () => {
    if (isVoteExpired()) {
      console.log("Vote has expired, skipping WebSocket connection.");
      return;
    }

    if (clientRef.current?.active) {
      clientRef.current.deactivate();
      clientRef.current = null;
    }

    const client = new Client({
      brokerURL: `wss://${process.env.API_URL}/ws/votes`,
      debug: (msg) => console.log(msg),
      reconnectDelay: 500000000,
      onConnect: () => {
        console.log("WebSocket connected successfully");
        subscribeWebsocket(client);
        setConnected(true);
        setError(null);
        clientRef.current = client;
      },
      onWebSocketClose: () => {
        console.log("WebSocket closed");
        setConnected(false);
      },
      onStompError: (frame) => {
        console.error("STOMP Error:", frame);
        setError(`STOMP Error: ${frame.headers?.message || "Unknown error"}`);
      },
    });

    try {
      client.activate();
    } catch (error) {
      console.error("WebSocket activation failed:", error);
      setError("Failed to initialize WebSocket");
    }
  };

  const subscribeWebsocket = (client: Client) => {
    const storedVoteUuid = storage.getItem("voteUuid");
    client.publish({
      destination: `/app/vote/connect`,
    });
    client.subscribe(`/user/queue/${storedVoteUuid}/initialResponse`, (message: { body: string }) => {
	...
    });
  };

  useEffect(() => {
    initializeWebSocket();
    return () => {
      if (clientRef.current?.active) {
        clientRef.current.deactivate();
        clientRef.current = null;
      }
    };
  }, []); // clean-up

  const connectWebSocket = () => {
    initializeWebSocket(); // 수동으로 WebSocket을 연결할 수 있도록 추가
  };

  const disconnect = () => {
    if (clientRef.current?.active) {
      clientRef.current.deactivate();
      clientRef.current = null;
      setConnected(false);
      setError(null);
    }
  };

  return (
    <WebSocketContext.Provider
      value={{
        client: clientRef.current,
        connected,
        error,
        disconnect,
        connectWebSocket,
      }}
    >
      {children}
    </WebSocketContext.Provider>
  );
};

export const useWebSocket = () => {
  const context = useContext(WebSocketContext);
  if (!context) {
    throw new Error("useWebSocket must be used within a WebSocketProvider");
  }
  return context;
};