React에서 Socket.IO Client 연결하기: CORS와 useEffect 정리

전면 개정 안내 (2026-07-24)
2021년의 Create React App과 Socket.IO 3.x 예제를 Vite, React, Socket.IO 4.x 기준으로 다시 작성했습니다. CORS 설정뿐 아니라 이벤트 리스너 중복, React Strict Mode, 재연결 상태까지 함께 다룹니다.

React 브라우저 클라이언트가 보안 게이트를 거쳐 Socket.IO 서버와 이벤트를 양방향으로 교환하는 구조

React 개발 서버와 Socket.IO 서버를 서로 다른 포트로 실행하면 브라우저 기준으로 출처(origin)가 달라집니다.

React:    http://localhost:5173
Socket.IO: http://localhost:4001

출처는 프로토콜, 호스트, 포트의 조합입니다. 포트 하나만 달라도 교차 출처 요청이므로 Socket.IO 서버가 React 개발 서버를 명시적으로 허용해야 합니다.

하지만 CORS만 고친다고 React 연동이 완성되는 것은 아닙니다. 컴포넌트가 다시 마운트될 때 리스너를 중복 등록하지 않도록 정리하고, 연결 끊김과 재연결을 UI에 표시해야 합니다.

이번 글에서는 1초마다 서버 시간을 보내는 작은 예제로 이 전체 흐름을 구현합니다.


완성 구조

react-socketio-example/
├─ server/
│  ├─ package.json
│  └─ server.js
└─ client/
   ├─ .env.development
   └─ src/
      ├─ App.jsx
      ├─ App.css
      └─ socket.js
구성 요소 역할
Socket.IO 서버 연결마다 타이머 생성, clock:tick 전송
CORS 허용 목록 React 개발 서버의 정확한 origin만 허용
socket.js Socket.IO 클라이언트 인스턴스를 한곳에서 생성
App.jsx 연결 상태와 이벤트를 React 상태로 반영
useEffect 정리 함수 등록한 리스너 제거와 연결 종료

1. Socket.IO 서버 만들기

먼저 서버 폴더를 준비합니다.

mkdir react-socketio-example
cd react-socketio-example
mkdir server
cd server
npm init -y
npm install socket.io

server/package.json에 ES Modules와 실행 스크립트를 추가합니다.

{
  "name": "socketio-clock-server",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "socket.io": "^4.0.0"
  }
}

server/server.js를 작성합니다.

import crypto from "node:crypto";
import { createServer } from "node:http";
import { Server } from "socket.io";

const port = Number(process.env.PORT ?? 4001);
const clientOrigin =
  process.env.CLIENT_ORIGIN ?? "http://localhost:5173";

const httpServer = createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ ok: true }));
    return;
  }

  response.writeHead(404);
  response.end();
});

const io = new Server(httpServer, {
  cors: {
    origin: [clientOrigin],
    methods: ["GET", "POST"]
  }
});

io.on("connection", (socket) => {
  console.log("connected:", socket.id);

  const timer = setInterval(() => {
    socket.emit("clock:tick", {
      id: crypto.randomUUID(),
      iso: new Date().toISOString()
    });
  }, 1000);

  socket.on("disconnect", (reason) => {
    clearInterval(timer);
    console.log("disconnected:", socket.id, reason);
  });
});

httpServer.listen(port, () => {
  console.log(`Socket.IO server: http://localhost:${port}`);
  console.log(`Allowed origin: ${clientOrigin}`);
});

이 예제는 소켓마다 별도 타이머를 만들고 연결이 끊길 때 해당 타이머만 제거합니다. 원문의 전역 interval 변수처럼 모든 연결이 하나의 타이머를 공유하면 새 사용자가 접속할 때 기존 사용자의 타이머가 사라질 수 있습니다.


2. React 클라이언트 만들기

프로젝트 루트로 돌아가 Vite React 앱을 만듭니다.

cd ..
npm create vite@latest client -- --template react
cd client
npm install
npm install socket.io-client

client/.env.development에 개발용 서버 주소를 넣습니다.

VITE_SOCKET_URL=http://localhost:4001

Vite에서 VITE_로 시작하는 환경 변수는 브라우저 번들에 노출됩니다. 비밀번호, API 비밀 키 또는 서버 전용 토큰을 넣으면 안 됩니다.


3. Socket.IO 인스턴스를 별도 파일로 분리

client/src/socket.js를 만듭니다.

import { io } from "socket.io-client";

const serverUrl =
  import.meta.env.VITE_SOCKET_URL ?? "http://localhost:4001";

export const socket = io(serverUrl, {
  autoConnect: false,
  reconnection: true,
  reconnectionAttempts: 5,
  timeout: 5000
});

컴포넌트 함수 안에서 io()를 호출하면 렌더링이나 마운트 과정에서 연결을 여러 개 만들기 쉽습니다. 클라이언트 인스턴스를 모듈에 한 번만 만들고 필요한 곳에서 가져다 쓰는 편이 안전합니다.

autoConnect: false를 사용했으므로 React Effect에서 연결 시점을 명시적으로 제어합니다.


4. useEffect에서 연결과 리스너 관리

client/src/App.jsx를 다음처럼 작성합니다.

import { useEffect, useState } from "react";
import { socket } from "./socket";
import "./App.css";

export default function App() {
  const [status, setStatus] = useState(
    socket.connected ? "connected" : "disconnected"
  );
  const [serverTime, setServerTime] = useState("");
  const [error, setError] = useState("");

  useEffect(() => {
    function onConnect() {
      setStatus("connected");
      setError("");
    }

    function onDisconnect() {
      setStatus("disconnected");
    }

    function onConnectError(connectionError) {
      setStatus("error");
      setError(connectionError.message);
    }

    function onClockTick(payload) {
      if (
        typeof payload?.id !== "string" ||
        typeof payload?.iso !== "string"
      ) {
        return;
      }

      setServerTime(payload.iso);
    }

    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("connect_error", onConnectError);
    socket.on("clock:tick", onClockTick);
    socket.connect();

    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("connect_error", onConnectError);
      socket.off("clock:tick", onClockTick);
      socket.disconnect();
    };
  }, []);

  return (
    <main className="page">
      <section className="card">
        <p className={`status status--${status}`}>
          {status}
        </p>

        <h1>React + Socket.IO</h1>
        <p>서버가 보내는 현재 시각</p>

        <time dateTime={serverTime}>
          {serverTime
            ? new Date(serverTime).toLocaleString("ko-KR")
            : "이벤트를 기다리는 중입니다."}
        </time>

        {error && (
          <p className="error" role="alert">
            연결 오류: {error}
          </p>
        )}

        <div className="actions">
          <button
            type="button"
            onClick={() => socket.connect()}
            disabled={socket.connected}
          >
            다시 연결
          </button>

          <button
            type="button"
            onClick={() => socket.disconnect()}
            disabled={!socket.connected}
          >
            연결 끊기
          </button>
        </div>
      </section>
    </main>
  );
}

Effect에서 등록한 것과 같은 이름의 함수 참조를 socket.off()에 전달하는 것이 중요합니다.

socket.on("clock:tick", onClockTick);
socket.off("clock:tick", onClockTick);

다음처럼 콜백을 익명 함수로 등록하고 이벤트 이름만 제거하면 다른 컴포넌트가 등록한 리스너까지 지울 수 있습니다.

// 피해야 할 방식
socket.on("clock:tick", (payload) => {
  setServerTime(payload.iso);
});

return () => {
  socket.off("clock:tick");
};

5. 간단한 스타일 적용

client/src/App.css를 작성합니다.

:root {
  color: #e5e7eb;
  background: #071126;
  font-family: Inter, Pretendard, system-ui, sans-serif;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
}

button {
  font: inherit;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.card {
  width: min(560px, 100%);
  padding: clamp(28px, 6vw, 52px);
  border: 1px solid #2b3b5b;
  border-radius: 24px;
  background: #101b31;
  box-shadow: 0 30px 80px rgb(0 0 0 / 35%);
}

.status {
  display: inline-flex;
  margin: 0;
  padding: 6px 10px;
  border-radius: 999px;
  font-weight: 800;
}

.status--connected {
  color: #a7f3d0;
  background: #064e3b;
}

.status--disconnected,
.status--error {
  color: #fecaca;
  background: #7f1d1d;
}

time {
  display: block;
  min-height: 32px;
  margin: 20px 0;
  color: #67e8f9;
  font-family: ui-monospace, monospace;
  font-size: clamp(1rem, 4vw, 1.4rem);
}

.error {
  overflow-wrap: anywhere;
  color: #fca5a5;
}

.actions {
  display: flex;
  gap: 10px;
  margin-top: 24px;
}

button {
  min-height: 44px;
  padding: 0 16px;
  border: 0;
  border-radius: 10px;
  color: white;
  background: #4f46e5;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

6. 서버와 클라이언트 실행

터미널을 두 개 엽니다.

서버:

cd react-socketio-example/server
npm start

클라이언트:

cd react-socketio-example/client
npm run dev

브라우저에서 Vite가 표시한 주소를 열면 연결 상태와 서버 시간이 갱신됩니다.

확인할 항목은 다음과 같습니다.

  1. 상태가 connected로 바뀌는가
  2. 시간이 1초 간격으로 갱신되는가
  3. 연결 끊기 버튼을 누르면 disconnected가 되는가
  4. 다시 연결 버튼으로 새 연결이 만들어지는가
  5. 서버를 종료했다가 재실행하면 자동 재연결되는가
  6. 개발자 도구 콘솔에 CORS 또는 리스너 중복 경고가 없는가

CORS와 Effect 정리를 함께 이해하기

허용된 브라우저 출처만 서버에 연결되고 React 컴포넌트의 이벤트 리스너가 마운트와 언마운트에 맞춰 등록·해제되는 흐름

CORS는 서버에서 설정한다

브라우저 오류를 없애려고 확장 프로그램을 설치하거나 브라우저 보안을 끄면 안 됩니다. 허용할 출처를 Socket.IO 서버의 cors.origin에 정확히 등록합니다.

const io = new Server(httpServer, {
  cors: {
    origin: [
      "http://localhost:5173",
      "https://app.example.com"
    ],
    methods: ["GET", "POST"]
  }
});

프로덕션에서 origin: "*"를 무작정 사용하지 마세요. 특히 쿠키 인증을 위해 credentials: true를 사용한다면 와일드카드 origin과 함께 사용할 수 없습니다.

CORS는 브라우저가 HTTP 요청에 적용하는 정책이며 인증 기능이 아닙니다. Socket.IO에서는 HTTP long-polling에 적용되고 WebSocket 연결 자체에는 같은 방식으로 적용되지 않습니다. 네이티브 클라이언트나 직접 만든 요청까지 막지 못하므로, 접근 제한이 필요하면 allowRequest와 서버 미들웨어에서 출처·토큰·권한을 별도로 검증해야 합니다.

React Strict Mode에서 두 번 연결되는 것처럼 보이는 이유

개발 모드의 Strict Mode는 Effect의 정리 로직이 올바른지 확인하기 위해 setup → cleanup → setup 순서로 한 번 더 실행할 수 있습니다. 따라서 서버 로그에 연결, 해제, 재연결이 연속으로 보일 수 있습니다.

이는 Effect를 제거해야 한다는 뜻이 아닙니다. setup에서 등록한 모든 외부 연결과 리스너를 cleanup에서 정확히 되돌릴 수 있어야 한다는 뜻입니다.

자동 재연결의 한계

Socket.IO는 예상하지 못한 네트워크 단절 후 재연결을 시도합니다. 하지만 사용자가 socket.disconnect()를 직접 호출한 경우에는 자동으로 다시 연결하지 않습니다. 이 예제의 다시 연결 버튼이 socket.connect()를 호출하는 이유입니다.

또한 재연결됐다고 끊긴 동안의 모든 이벤트가 자동 복구되는 것은 아닙니다. 중요한 이벤트는 ID를 부여해 저장하고 마지막 수신 ID 이후의 데이터를 다시 동기화해야 합니다.


CORS 오류를 빠르게 진단하는 방법

에러 메시지가 CORS라고 해서 항상 CORS 설정만 잘못된 것은 아닙니다. 서버가 꺼져 있거나 주소가 틀려도 브라우저에서 비슷하게 보일 수 있습니다.

먼저 Socket.IO의 polling 핸드셰이크가 응답하는지 확인합니다.

curl "http://localhost:4001/socket.io/?EIO=4&transport=polling"

정상이라면 세션 ID, 업그레이드 가능 전송 방식과 ping 설정이 포함된 응답이 옵니다.

증상 확인할 항목
Access-Control-Allow-Origin 없음 서버 cors.origin과 브라우저 origin 비교
계속 connect_error 발생 URL, 포트, 서버 실행 상태, 프록시 확인
이벤트가 여러 번 표시됨 리스너 중복 등록과 cleanup 확인
개발 중 연결이 두 번 보임 Strict Mode의 setup/cleanup 사이클 확인
배포 후 polling만 실패 리버스 프록시 경로와 CORS 헤더 확인
잠시 끊긴 이벤트가 사라짐 전송 보장과 상태 동기화 설계 확인

localhost127.0.0.1도 문자열이 다릅니다. 서버에서 http://localhost:5173만 허용했는데 브라우저는 http://127.0.0.1:5173으로 열었다면 일치하지 않습니다.


운영 환경 체크리스트

  • 프런트엔드 주소는 환경 변수로 관리하고 정확한 origin만 허용합니다.
  • Socket.IO URL도 VITE_SOCKET_URL로 빌드 환경별로 구분합니다.
  • VITE_ 환경 변수에 비밀 값을 저장하지 않습니다.
  • 연결 전 서버 미들웨어에서 인증 토큰과 권한을 검증합니다.
  • 서버가 여러 대라면 로드 밸런서, sticky session과 어댑터를 함께 검토합니다.
  • 이벤트 payload의 형식과 크기를 서버와 클라이언트 양쪽에서 검증합니다.
  • ACK 타임아웃, 중복 처리와 연결 복구 정책을 정합니다.
  • 컴포넌트에서 등록한 리스너는 같은 함수 참조로 해제합니다.
  • 소켓 초기화 파일을 수정한 뒤 HMR로 이전 연결이 남으면 전체 새로고침합니다.
  • HTTPS 페이지에서 http:// Socket.IO 서버로 연결하는 혼합 콘텐츠를 피합니다.

마무리

React와 Socket.IO를 연결할 때는 세 가지를 한 묶음으로 생각하면 됩니다.

  1. 서버가 실제 React origin을 CORS 허용 목록에 등록한다.
  2. Socket.IO 인스턴스를 한곳에서 만들고 Effect에서 연결한다.
  3. Effect가 등록한 리스너와 연결을 cleanup에서 정확히 되돌린다.

이 구조를 지키면 단순한 CORS 오류뿐 아니라 이벤트 중복, 개발 중 유령 연결, 컴포넌트 해제 후 상태 변경 같은 문제도 함께 줄일 수 있습니다.

공식 참고 자료

댓글

이 블로그의 인기 게시물

pyautogui 예제 모니터 특정 위치 색상 구하고 비교해서 클릭 이벤트 하기

vscode 에서 WSL 개발환경 동작하지 않는 경우