실시간 투표서비스를 개발하며 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;
};
'TIL' 카테고리의 다른 글
🧩Webpack + SharedWorker + TypeScript 환경에서 발생한 트러블슈팅 기록 (0) | 2025.03.30 |
---|---|
TimePicker 외부 클릭 시 다시 열리는 현상 (0) | 2025.03.22 |
GitHub Actions + Docker 배포 트러블슈팅: .env 파일이 컨테이너에 들어가지 않는 이유? (0) | 2025.03.21 |
SSL 인증서 발급과 IP 주소 (0) | 2025.03.03 |
요청에 쿠키가 안 담겨요.. SameSite=None? Strict? (0) | 2025.03.02 |