뒤로가기
WebSocket으로 실시간 채팅 만들면서 배운 것들

June 5, 2025

frontendbackend

"실시간" 기능을 만들어본 적이 없었다. 프론트엔드에서 데이터는 항상 요청-응답 패턴이었다. 사용자가 뭔가 하면 API를 호출하고, 응답을 받아서 화면에 그린다. 그런데 채팅은 내가 요청하지 않아도 상대방의 메시지가 와야 한다. 이 "서버가 먼저 데이터를 보내는" 상황이 처음에는 개념적으로 낯설었다.

사이드 프로젝트로 간단한 채팅 앱을 만들어보기로 했다. 기술 스택은 프론트는 React, 백엔드는 Node.js + Express + ws 라이브러리.

HTTP로 채팅을 만들면?#

WebSocket을 쓰기 전에, HTTP polling으로 먼저 만들어봤다. 왜 WebSocket이 필요한지 직접 느끼고 싶어서.

typescript
// 1초마다 새 메시지가 있는지 서버에 물어본다
function usePollingMessages(roomId: string) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    const interval = setInterval(async () => {
      const newMessages = await fetchMessages(roomId, {
        after: messages[messages.length - 1]?.id,
      });
      if (newMessages.length > 0) {
        setMessages((prev) => [...prev, ...newMessages]);
      }
    }, 1000);

    return () => clearInterval(interval);
  }, [roomId, messages]);

  return messages;
}

동작은 한다. 근데 문제가 많다. 1초마다 서버에 요청을 보내니까 사용자가 100명이면 초당 100번의 API 호출이 발생한다. 메시지가 없어도. 간격을 5초로 늘리면 메시지가 최대 5초 뒤에 보인다. 실시간이 아니다.

Long polling이라는 방법도 있다. 서버가 새 메시지가 올 때까지 응답을 보류하는 건데, 구현이 복잡하고 서버 리소스를 잡아먹는다. 결국 양방향 통신이 필요하면 WebSocket이 답이다.

WebSocket 기초#

WebSocket은 HTTP와 다르게 한 번 연결하면 그 연결을 유지한다. 서버와 클라이언트가 언제든 서로에게 메시지를 보낼 수 있다.

typescript
// 서버 (Node.js + ws)
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
  console.log("새 클라이언트 연결됨");

  ws.on("message", (data) => {
    const message = JSON.parse(data.toString());

    // 연결된 모든 클라이언트에게 메시지 전달 (브로드캐스트)
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });

  ws.on("close", () => {
    console.log("클라이언트 연결 종료");
  });
});
typescript
// 클라이언트
const ws = new WebSocket("ws://localhost:8080");

ws.onopen = () => {
  console.log("연결됨");
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log("메시지 수신:", message);
};

ws.send(JSON.stringify({ text: "안녕하세요", sender: "user1" }));

여기까지는 간단하다. 문서 보고 30분이면 된다. 문제는 여기서 시작이다.

시행착오 1: 연결이 끊어진다#

WebSocket 연결은 여러 이유로 끊어진다. 네트워크 불안정, 서버 재시작, 모바일에서 앱이 백그라운드로 가는 경우 등. 처음에는 "연결 한 번 하면 끝"이라고 생각했는데 전혀 아니었다.

재연결 로직을 직접 구현해야 한다.

typescript
class WebSocketClient {
  private ws: WebSocket | null = null;
  private url: string;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;
  private listeners: Map<string, Set<Function>> = new Map();

  constructor(url: string) {
    this.url = url;
    this.connect();
  }

  private connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("WebSocket 연결됨");
      this.reconnectAttempts = 0;
    };

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.emit("message", data);
    };

    this.ws.onclose = (event) => {
      // 정상 종료가 아닌 경우에만 재연결
      if (event.code !== 1000) {
        this.reconnect();
      }
    };

    this.ws.onerror = () => {
      // onerror 후에 onclose가 호출되므로 여기서는 별도 처리 불필요
    };
  }

  private reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      this.emit("maxRetriesReached", null);
      return;
    }

    const delay = Math.min(
      1000 * Math.pow(2, this.reconnectAttempts),
      30000
    );
    this.reconnectAttempts++;

    console.log(
      `${delay}ms 후 재연결 시도 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`
    );

    setTimeout(() => this.connect(), delay);
  }

  send(data: unknown) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  on(event: string, callback: Function) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
  }

  private emit(event: string, data: unknown) {
    this.listeners.get(event)?.forEach((cb) => cb(data));
  }
}

Math.pow(2, this.reconnectAttempts)는 지수 백오프다. 1초, 2초, 4초, 8초... 간격이 점점 늘어난다. 서버가 다운됐을 때 모든 클라이언트가 동시에 재연결을 시도하면 서버가 복구되자마자 다시 죽을 수 있다. 지수 백오프에 랜덤 jitter를 더하면 더 좋은데, 사이드 프로젝트에서는 여기까지만 했다.

시행착오 2: React와의 통합#

WebSocket 인스턴스를 React 컴포넌트에서 어떻게 관리할지가 고민이었다. useEffect에서 연결하고 cleanup에서 끊으면 될 것 같지만, Strict Mode에서 useEffect가 두 번 실행되면 연결도 두 번 된다.

typescript
function useChatSocket(roomId: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [connectionStatus, setConnectionStatus] = useState<
    "connecting" | "connected" | "disconnected"
  >("connecting");
  const wsRef = useRef<WebSocketClient | null>(null);

  useEffect(() => {
    const client = new WebSocketClient(
      `ws://localhost:8080/rooms/${roomId}`
    );
    wsRef.current = client;

    client.on("message", (msg: Message) => {
      setMessages((prev) => [...prev, msg]);
    });

    client.on("statusChange", (status: string) => {
      setConnectionStatus(status as any);
    });

    return () => {
      client.disconnect();
      wsRef.current = null;
    };
  }, [roomId]);

  const sendMessage = useCallback(
    (text: string) => {
      wsRef.current?.send({
        type: "message",
        text,
        roomId,
        timestamp: Date.now(),
      });
    },
    [roomId]
  );

  return { messages, sendMessage, connectionStatus };
}

useRef로 WebSocket 인스턴스를 보관한다. 렌더링에 영향을 주지 않으면서 인스턴스를 유지하기 위해서.

시행착오 3: 메시지 순서 보장#

네트워크 지연 때문에 메시지가 순서대로 도착하지 않을 수 있다. A가 "안녕"을 보내고 B가 "반가워"를 보냈는데, 내 화면에는 "반가워"가 먼저 보이는 경우.

이걸 해결하려면 메시지에 타임스탬프를 넣고, 클라이언트에서 정렬해야 한다.

typescript
client.on("message", (msg: Message) => {
  setMessages((prev) => {
    const updated = [...prev, msg];
    return updated.sort((a, b) => a.timestamp - b.timestamp);
  });
});

매번 정렬하는 게 비효율적으로 보이지만, 채팅 메시지 배열의 크기가 수천 개 수준이면 성능 문제는 거의 없다. 수만 개가 넘어가면 가상 스크롤을 도입해야 하는데, 그 시점이면 정렬보다 렌더링이 더 큰 병목이다.

시행착오 4: 방(room) 관리#

처음에는 모든 메시지를 모든 사용자에게 브로드캐스트했다. 채팅방이 하나일 때는 괜찮은데, 여러 방을 지원하려면 서버에서 방별로 클라이언트를 관리해야 한다.

typescript
// 서버
const rooms = new Map<string, Set<WebSocket>>();

wss.on("connection", (ws, request) => {
  const roomId = new URL(request.url!, `http://${request.headers.host}`)
    .pathname.split("/rooms/")[1];

  if (!rooms.has(roomId)) {
    rooms.set(roomId, new Set());
  }
  rooms.get(roomId)!.add(ws);

  ws.on("message", (data) => {
    const message = JSON.parse(data.toString());
    const roomClients = rooms.get(roomId);

    roomClients?.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });

  ws.on("close", () => {
    rooms.get(roomId)?.delete(ws);
    if (rooms.get(roomId)?.size === 0) {
      rooms.delete(roomId);
    }
  });
});

이쯤 되니까 "Socket.IO를 쓸 걸"이라는 생각이 슬슬 들었다. Socket.IO는 방 관리, 재연결, 이벤트 기반 통신 등을 기본으로 제공한다. 직접 구현해보니까 Socket.IO가 왜 존재하는지 알겠다. 학습 목적이 아니었다면 처음부터 Socket.IO를 쓰는 게 생산성 면에서 합리적이다.

배운 것#

직접 만들어보니까 "실시간"이라는 게 단순히 WebSocket 연결 하나로 되는 게 아니라는 걸 알았다. 재연결, 메시지 순서, 방 관리, 오프라인 시 메시지 큐잉, 읽음 처리 등. 채팅 하나에 이렇게 많은 엣지 케이스가 있다.

프로덕션 레벨의 채팅을 만들려면 여기에 더해서 인증, rate limiting, 메시지 저장(DB), 파일 전송, 타이핑 인디케이터 등도 필요하다. 슬랙이나 카카오톡 같은 제품이 왜 복잡한지 새삼 느끼게 된 프로젝트였다.

지금 생각해보면 WebSocket보다 Server-Sent Events(SSE)를 먼저 시도해봐도 좋았을 것 같다. 서버에서 클라이언트로 단방향 스트리밍만 필요한 경우(알림, 실시간 피드 등)에는 SSE가 더 간단하다. WebSocket은 양방향이 꼭 필요한 경우에만.