실시간 기능이 필요한 화면이라면 폴링보다 WebSocket이 비용 효율적이고 반응성이 좋습니다. 이 글은 React에서 브라우저 네이티브 WebSocket API로 안정적인 연결을 구현하는 실전 가이드입니다.
1. 기본 연결과 정리(cleanup)
핵심은 컴포넌트 마운트 시 연결, 언마운트 시 닫기입니다. 메시지는 JSON으로 직렬화/역직렬화를 권장합니다.
import { useEffect, useRef, useState } from "react";
function Chat() {
const [messages, setMessages] = useState([]);
const wsRef = useRef(null);
useEffect(() => {
const ws = new WebSocket("wss://example.com/ws");
wsRef.current = ws;
ws.onopen = () => {
console.log("WS open");
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
setMessages((prev) => [...prev, data]);
} catch {
// 텍스트 등 비-JSON 메시지 처리
setMessages((prev) => [...prev, { type: "text", payload: e.data }]);
}
};
ws.onerror = (e) => {
console.error("WS error", e);
};
ws.onclose = (e) => {
console.log("WS close", e.code, e.reason);
};
return () => {
// 정상 종료 코드 1000으로 닫습니다.
ws.close(1000, "component unmount");
};
}, []);
const send = (payload) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
return null;
}
주의: React 18 개발 모드의 StrictMode는 useEffect를 한 번 더 실행합니다. 위 코드는 cleanup에서 닫기 때문에 문제 없이 동작합니다.
2. 재연결(Exponential backoff)
네트워크가 불안정할 때 자동 재연결이 필요합니다. 정상 종료(코드 1000)에는 재연결하지 않는 것이 일반적입니다.
import { useEffect, useRef } from "react";
function useAutoReconnect(url) {
const wsRef = useRef(null);
const retryRef = useRef(0);
const timerRef = useRef(null);
const connect = () => {
if (wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryRef.current = 0; // 성공 시 리셋
};
ws.onclose = (e) => {
// 1000(정상 종료)은 재연결하지 않습니다.
if (e.code !== 1000) {
const delay = Math.min(1000 * 2 ** retryRef.current, 15000); // 1s → 2s → 4s ... max 15s
retryRef.current += 1;
timerRef.current = setTimeout(connect, delay);
}
};
};
useEffect(() => {
connect();
return () => {
clearTimeout(timerRef.current);
wsRef.current?.close(1000, "unmount");
};
}, [url]);
}
백오프 상한을 두고, 서버 과부하를 방지합니다.
3. 하트비트로 유휴 연결 유지
브라우저는 프로토콜 수준 ping 프레임을 직접 보낼 수 없습니다. 대신 애플리케이션 레벨 ping/pong 메시지를 사용합니다. 일정 주기로 ping을 전송하고 pong이 없으면 연결을 닫아 재연결합니다.
function startHeartbeat(ws, { interval = 25000 } = {}) {
let expectPong = false;
const id = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
if (expectPong) {
// pong 미수신 → 타임아웃 처리
ws.close(4000, "pong timeout");
return;
}
expectPong = true;
ws.send("ping");
}, interval);
ws.addEventListener("message", (e) => {
if (e.data === "pong") {
expectPong = false;
}
});
return () => clearInterval(id);
}
서버는 ping 문자열을 받으면 pong으로 응답하도록 구현해야 합니다.
4. 재사용 가능한 훅: useWebSocket
메시지 큐, 재연결, 하트비트까지 포함한 훅 예시입니다. onMessage는 최신 참조를 유지해 불필요한 재연결을 막습니다.
import { useCallback, useEffect, useRef, useState } from "react";
export function useWebSocket({
url,
protocols,
onMessage,
shouldReconnect = (e) => e.code !== 1000,
heartbeatInterval = 25000,
maxBackoff = 15000,
}) {
const wsRef = useRef(null);
const retryRef = useRef(0);
const timerRef = useRef(null);
const hbRef = useRef(null);
const queueRef = useRef([]);
const onMessageRef = useRef(onMessage);
const [status, setStatus] = useState("closed"); // connecting | open | closed
const [latestMessage, setLatestMessage] = useState(null);
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
const stopHeartbeat = () => {
if (hbRef.current) {
clearInterval(hbRef.current.id);
hbRef.current = null;
}
};
const startHeartbeat = (ws) => {
stopHeartbeat();
let expectPong = false;
const id = setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (expectPong) {
ws.close(4000, "pong timeout");
return;
}
expectPong = true;
ws.send("ping");
}, heartbeatInterval);
const onMsg = (e) => {
if (e.data === "pong") expectPong = false;
};
ws.addEventListener("message", onMsg);
hbRef.current = { id, onMsg, ws };
};
const connect = useCallback(() => {
if (wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
const ws = new WebSocket(url, protocols);
wsRef.current = ws;
setStatus("connecting");
ws.onopen = () => {
setStatus("open");
retryRef.current = 0;
// 큐 플러시
while (queueRef.current.length) {
ws.send(queueRef.current.shift());
}
startHeartbeat(ws);
};
ws.onmessage = (e) => {
if (e.data === "pong") return; // 하트비트 처리
let data = null;
try {
data = JSON.parse(e.data);
} catch {
data = e.data;
}
setLatestMessage(data);
onMessageRef.current && onMessageRef.current(data);
};
ws.onerror = (e) => {
console.error("WebSocket error", e);
};
ws.onclose = (e) => {
setStatus("closed");
// 하트비트 리스너 제거
if (hbRef.current && hbRef.current.ws === ws) {
ws.removeEventListener("message", hbRef.current.onMsg);
}
stopHeartbeat();
if (shouldReconnect && shouldReconnect(e)) {
const delay = Math.min(1000 * 2 ** retryRef.current, maxBackoff);
retryRef.current += 1;
timerRef.current = setTimeout(connect, delay);
}
};
}, [url, protocols, shouldReconnect, heartbeatInterval, maxBackoff]);
const disconnect = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
stopHeartbeat();
wsRef.current?.close(1000, "manual disconnect");
}, []);
const send = useCallback((payload) => {
const msg = typeof payload === "string" ? payload : JSON.stringify(payload);
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg);
else queueRef.current.push(msg);
}, []);
useEffect(() => {
connect();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
stopHeartbeat();
wsRef.current?.close(1000, "unmount");
wsRef.current = null;
};
}, [connect]);
return { status, latestMessage, send, connect, disconnect };
}
url, onMessage가 자주 바뀌면 불필요한 재연결이 발생합니다. 필요 시 상위에서 메모이제이션하거나 onMessage를 ref로 관리하는 패턴을 사용합니다.
5. 사용 예시
토큰을 쿼리스트링으로 전달하고 새 알림을 상태에 반영하는 간단한 예시입니다.
import { useEffect, useState } from "react";
import { useWebSocket } from "./useWebSocket";
export default function Notifications({ token }) {
const [items, setItems] = useState([]);
const { status, latestMessage, send } = useWebSocket({
url: `wss://example.com/notifications?token=${token}`,
onMessage: (msg) => {
// 리스트 반영
setItems((prev) => [...prev, msg]);
},
});
useEffect(() => {
if (latestMessage?.type === "alert") {
// 예: 토스트 띄우기
console.log("ALERT:", latestMessage.text);
}
}, [latestMessage]);
// 서버로 상태 확인 요청 예시
const requestSync = () => send({ type: "sync" });
return null;
}
6. 인증, 보안, 운영 팁
브라우저 WebSocket은 커스텀 헤더를 보낼 수 없습니다. 인증은 주로 쿠키, 쿼리스트링, 혹은 서브프로토콜을 사용합니다. 예: wss://host/ws?token=JWT. 토큰 갱신 시에는 기존 소켓을 정상 종료(1000)하고 새 토큰으로 재연결합니다.
프로덕션은 반드시 wss를 사용합니다. 프록시/로드밸런서가 아이들 타임아웃을 짧게 두는 경우 하트비트 간격을 그보다 짧게 설정합니다.
여러 컴포넌트에서 같은 스트림을 소비한다면 소켓을 전역(예: Context, Zustand)으로 단일 생성해 브로드캐스팅하는 구조가 메모리, 연결 수 측면에서 유리합니다.
대용량 이진 데이터 수신 시 ws.binaryType = "arraybuffer"를 설정하고, 메시지 처리 시간 동안 메인 스레드를 블로킹하지 않도록 주의합니다.
7. 에러 처리와 종료 코드
서버가 인증 실패 등으로 연결을 끊을 때는 커스텀 종료 코드를 사용하고, 클라이언트는 재연결을 중단해야 합니다. 예: 4001(Unauthorized) 수신 시 shouldReconnect에서 false를 반환합니다.
const shouldReconnect = (e) => {
if (e.code === 1000) return false; // 정상 종료
if (e.code === 4001) return false; // 인증 실패 → 재연결 중단, 로그인 요구
return true;
};
8. 디버깅 체크리스트
개발자 도구 Network 탭에서 WS 프레임을 확인합니다. 서버 타임아웃 설정과 하트비트 간격이 맞는지 점검합니다. React StrictMode 개발 중 중복 연결이 보이면 cleanup이 정상 동작하는지 확인합니다. 메시지 파싱 오류는 try/catch로 방어하고 서버 포맷과 동기화합니다.
이 가이드를 바탕으로, 실서비스에서도 안정적으로 동작하는 WebSocket 연결을 구현할 수 있습니다. 먼저 최소 구현으로 시작하고, 재연결·하트비트·전역 공유 등 요구사항에 맞춰 단계적으로 확장하시기 바랍니다.
'React' 카테고리의 다른 글
| React에서 Infinite Scroll 구현하기 (0) | 2026.04.20 |
|---|---|
| React 앱 성능 최적화를 위한 메모이제이션 기법 (0) | 2026.04.19 |
| React와 Redux Toolkit으로 상태 관리 구조화하기 (1) | 2026.04.17 |
| React Testing Library로 컴포넌트 단위 테스트 작성하기 (0) | 2026.04.17 |
| React에서 Error Boundary로 오류 처리하기 (1) | 2026.04.16 |