본문 바로가기

개발 공부

웹소켓(WebSocket)

현대 웹 애플리케이션에서는 실시간으로 데이터가 오가는 경험이 점점 더 중요해지고 있습니다.

예를 들어, 채팅 앱에서 상대방이 메시지를 보내자마자 바로 화면에 뜨거나, 주식 시세가 실시간으로 업데이트되는 상황을 생각해보세요.

이러한 실시간 데이터 통신을 가능하게 해주는 핵심 기술 중 하나가 바로 웹소켓(WebSocket) 입니다.

기존의 HTTP 통신 방식은 클라이언트가 요청(Request)을 보내야 서버가 응답(Response)을 주는 구조입니다.

이 구조에서는 서버가 클라이언트에게 먼저 무언가를 보낼 수 없고, 클라이언트가 정기적으로 서버에 "새로운 게 있어?" 하고 물어보는 방식(폴링, Long Polling 등)을 사용해야 했습니다.

이는 서버 자원 낭비가 크고 실시간성과도 거리가 멉니다.

웹소켓은 이런 한계를 해결하기 위해 등장했습니다.

한 번의 연결만으로 클라이언트와 서버 간에 양방향 통신을 실시간으로 할 수 있게 해주는 프로토콜입니다.

 

작동원리

  1. 초기 연결: 클라이언트는 HTTP 프로토콜을 사용해 웹소켓으로 업그레이드 요청을 보냅니다. (HTTP Handshake)
  2. 연결 성립: 서버가 이를 수락하면, 연결은 WebSocket 프로토콜로 전환되고 연결이 유지됩니다.
  3. 양방향 통신: 연결이 유지된 상태에서 클라이언트와 서버는 자유롭게 데이터를 주고받을 수 있습니다.
  4. 연결 종료: 클라이언트 또는 서버가 명시적으로 연결을 닫을 때까지 연결은 살아있습니다.
 

활용 예시

웹소켓은 다양한 실시간 서비스에 활용됩니다.

  • 채팅 애플리케이션: 카카오톡, WhatsApp과 같은 실시간 채팅 앱
  • 실시간 알림 시스템: 새로운 댓글, 좋아요 알림 등
  • 온라인 게임: 사용자 간 실시간 상호작용
  • 주식/코인 시세 표시: 초 단위 가격 변동을 실시간 반영
  • 콜라보레이션 툴: Google Docs와 같은 실시간 공동 작업 도구
 

웹소켓의 장단점

장점

  • 실시간성: 데이터를 기다리지 않고 즉시 받을 수 있어 반응성이 뛰어남
  • 효율성: HTTP 대비 오버헤드가 적고, 연결을 재사용할 수 있어 리소스 낭비가 적음
  • 서버 푸시 가능: 서버에서도 클라이언트로 직접 메시지를 보낼 수 있음

단점

  • 리소스 사용량 증가: 연결을 지속적으로 유지하기 때문에, 동시 접속자가 많을 경우 서버 리소스를 많이 사용함
  • 보안 고려: ws:// 대신 wss:// 사용 필요, 인증 및 권한 처리가 별도로 필요함
  • 환경 제약: 일부 방화벽이나 프록시에서는 웹소켓이 차단되기도 함
 

예시

  • 재연결 로직 구현: 일시적 연결 끊김에 대비해 자동 재연결 로직 작성
  • 연결 상태 관리: 상태 관리 라이브러리(Redux, Zustand 등)와 연동하면 유지 관리가 쉬움
  • 메시지 큐 처리: 수신 메시지를 큐에 넣어 UI 렌더링과 분리하여 성능 개선
  • 백엔드 연동: NestJS, Express, Fastify 등에서 웹소켓 서버 구현 가능. socket.io, ws 등의 라이브러리 사용
 

웹소켓 예제 (주식 시세 실시간 표시)

  • 사용자는 드롭다운/체크박스 등으로 구독할 종목을 선택함
  • 선택한 종목이 웹소켓을 통해 서버로 전송됨
  • 서버는 선택된 종목에 대한 시세 데이터를 주기적으로 보내줌
  • 클라이언트는 해당 데이터를 수신해서 실시간으로 렌더링함

백엔드 (WebSocket 서버 코드)

// server.ts
import WebSocket, { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080, path: '/stocks' });

type Client = {
  socket: WebSocket;
  subscribedSymbols: string[];
};

function generatePrice(): number {
  return +(100 + Math.random() * 50).toFixed(2);
}

function broadcastTicker(client: Client) {
  client.subscribedSymbols.forEach((symbol) => {
    const ticker = {
      symbol,
      price: generatePrice(),
      timestamp: Date.now(),
    };
    client.socket.send(JSON.stringify(ticker));
  });
}

wss.on('connection', (ws) => {
  const client: Client = {
    socket: ws,
    subscribedSymbols: [],
  };

  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message.toString());
      if (data.action === 'subscribe' && Array.isArray(data.symbols)) {
        client.subscribedSymbols = data.symbols;
        console.log('✅ 구독 종목:', data.symbols);
      }
    } catch (err) {
      console.error('❌ 파싱 오류:', err);
    }
  });

  const interval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      broadcastTicker(client);
    }
  }, 1000);

  ws.on('close', () => clearInterval(interval));
  ws.on('error', () => clearInterval(interval));
});

 

 

프론트엔드 (React + TypeScript)

// StockClient.tsx
import React, { useEffect, useRef, useState } from 'react';

type Ticker = {
  symbol: string;
  price: number;
  timestamp: number;
};

const availableSymbols = ['AAPL', 'TSLA', 'GOOGL', 'AMZN', 'MSFT'];

const StockClient: React.FC = () => {
  const [selectedSymbols, setSelectedSymbols] = useState<string[]>([]);
  const [tickers, setTickers] = useState<Record<string, Ticker>>({});
  const socketRef = useRef<WebSocket | null>(null);

  const toggleSymbol = (symbol: string) => {
    setSelectedSymbols((prev) =>
      prev.includes(symbol)
        ? prev.filter((s) => s !== symbol)
        : [...prev, symbol]
    );
  };

  useEffect(() => {
    socketRef.current = new WebSocket('ws://localhost:8080/stocks');

    socketRef.current.onopen = () => {
      console.log('📡 연결됨');
    };

    socketRef.current.onmessage = (event) => {
      const data: Ticker = JSON.parse(event.data);
      setTickers((prev) => ({ ...prev, [data.symbol]: data }));
    };

    return () => {
      socketRef.current?.close();
    };
  }, []);

  useEffect(() => {
    if (
      socketRef.current &&
      socketRef.current.readyState === WebSocket.OPEN
    ) {
      socketRef.current.send(
        JSON.stringify({ action: 'subscribe', symbols: selectedSymbols })
      );
    }
  }, [selectedSymbols]);

  return (
    <div>
      <h2>📈 실시간 주식 시세</h2>
      <div>
        {availableSymbols.map((symbol) => (
          <label key={symbol} style={{ marginRight: '1rem' }}>
            <input
              type="checkbox"
              checked={selectedSymbols.includes(symbol)}
              onChange={() => toggleSymbol(symbol)}
            />
            {symbol}
          </label>
        ))}
      </div>

      <table>
        <thead>
          <tr>
            <th>종목</th>
            <th>가격</th>
            <th>시간</th>
          </tr>
        </thead>
        <tbody>
          {selectedSymbols.map((symbol) =>
            tickers[symbol] ? (
              <tr key={symbol}>
                <td>{symbol}</td>
                <td>{tickers[symbol].price.toFixed(2)}</td>
                <td>{new Date(tickers[symbol].timestamp).toLocaleTimeString()}</td>
              </tr>
            ) : null
          )}
        </tbody>
      </table>
    </div>
  );
};

export default StockClient;

'개발 공부' 카테고리의 다른 글

콜백 지옥 (Callback Hell)  (0) 2025.04.29
정규화(Normalization)  (0) 2025.04.27
REST API  (0) 2025.03.27
Zod 검증  (0) 2025.03.12
Request Waterfall 방식과 Parallel 방식  (0) 2025.02.22