본문 바로가기

React

React에서 WebSocket 연결 구현하기

실시간 기능이 필요한 화면이라면 폴링보다 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 연결을 구현할 수 있습니다. 먼저 최소 구현으로 시작하고, 재연결·하트비트·전역 공유 등 요구사항에 맞춰 단계적으로 확장하시기 바랍니다.