WebSocket vs SSE — 실시간 웹 통신 선택 가이드

프론트엔드

WebSocketSSE실시간Next.jsNode.js

이 글은 누구를 위한 것인가

  • 채팅, 알림, 실시간 대시보드를 구현하면서 WebSocket과 SSE 중 무엇을 쓸지 결정해야 하는 팀
  • WebSocket을 쓰고 있지만 로드밸런서·프록시 문제로 고생하는 엔지니어
  • LLM 스트리밍 응답을 구현하면서 적절한 프로토콜을 찾는 개발자

두 기술의 핵심 차이

WebSocketServer-Sent Events (SSE)
통신 방향양방향 (full-duplex)단방향 (서버 → 클라이언트)
프로토콜ws:// / wss://HTTP/HTTPS
연결 유지전용 커넥션일반 HTTP 커넥션
자동 재연결직접 구현브라우저 기본 지원
프록시 통과설정 필요HTTP라 자연스러움
로드밸런서Sticky Session 필요문제 없음
HTTP/2 지원별도멀티플렉싱으로 자연 지원

1. SSE를 선택해야 하는 경우

클라이언트가 받기만 하는 경우 SSE가 WebSocket보다 간단하고 인프라 친화적이다.

적합한 사용 사례:

  • 실시간 알림 (주문 상태, 배송 알림)
  • 실시간 피드 업데이트 (뉴스, SNS 타임라인)
  • 대시보드 데이터 스트리밍 (모니터링, 분석)
  • LLM 스트리밍 응답 (ChatGPT 스타일 타이핑 효과)
  • 실시간 로그 스트리밍

SSE 서버 구현 (Next.js App Router)

// app/api/stream/route.ts
export async function GET(request: Request) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // SSE 형식: "data: {내용}\n\n"
      const send = (data: object, eventType?: string) => {
        if (eventType) {
          controller.enqueue(encoder.encode(`event: ${eventType}\n`));
        }
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
      };

      // 이벤트 발생 시 전송
      const unsubscribe = eventBus.subscribe('order_update', (event) => {
        send({ orderId: event.orderId, status: event.status }, 'order_update');
      });

      // 30초마다 keepalive 전송 (프록시 타임아웃 방지)
      const keepalive = setInterval(() => {
        controller.enqueue(encoder.encode(': keepalive\n\n'));
      }, 30000);

      // 연결 종료 시 정리
      request.signal.addEventListener('abort', () => {
        unsubscribe();
        clearInterval(keepalive);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no',  // Nginx 버퍼링 비활성화
    },
  });
}

SSE 클라이언트 구현

// hooks/useSSE.ts
function useSSE<T>(url: string, eventType: string) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const eventSource = new EventSource(url, {
      withCredentials: true,  // 쿠키 포함
    });

    eventSource.addEventListener(eventType, (event) => {
      setData(JSON.parse(event.data));
    });

    eventSource.onerror = (err) => {
      setError(new Error('SSE connection error'));
      // EventSource는 자동으로 재연결 시도
    };

    return () => eventSource.close();
  }, [url, eventType]);

  return { data, error };
}

// 사용
const { data: orderUpdate } = useSSE<OrderUpdate>(
  '/api/stream',
  'order_update'
);

LLM 스트리밍 응답

// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk';

export async function POST(request: Request) {
  const { message } = await request.json();
  const client = new Anthropic();

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const response = await client.messages.stream({
        model: 'claude-sonnet-4-6',
        max_tokens: 1024,
        messages: [{ role: 'user', content: message }],
      });

      for await (const chunk of response) {
        if (chunk.type === 'content_block_delta') {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`)
          );
        }
      }

      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  });
}

2. WebSocket을 선택해야 하는 경우

클라이언트에서 서버로도 실시간 메시지를 보내야 할 때 WebSocket이 필요하다.

적합한 사용 사례:

  • 실시간 채팅
  • 멀티플레이어 게임
  • 실시간 협업 편집 (Google Docs 형태)
  • 실시간 경매/입찰
  • 화상 통화 (WebRTC 시그널링)

WebSocket 서버 (Node.js + ws)

import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// 연결된 클라이언트 관리
const rooms = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws, request) => {
  const roomId = new URL(request.url!, `http://localhost`).searchParams.get('room');

  if (!roomId) {
    ws.close(1008, 'Room ID required');
    return;
  }

  // 룸에 추가
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);

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

    // 같은 룸의 다른 클라이언트에게 브로드캐스트
    rooms.get(roomId)?.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({
          ...message,
          timestamp: Date.now(),
        }));
      }
    });
  });

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

  // Ping-Pong으로 연결 유지
  const pingInterval = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) ws.ping();
  }, 30000);

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

WebSocket 클라이언트

// hooks/useWebSocket.ts
function useWebSocket(url: string) {
  const ws = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
  const reconnectTimeout = useRef<NodeJS.Timeout>();

  const connect = useCallback(() => {
    ws.current = new WebSocket(url);

    ws.current.onopen = () => setStatus('open');

    ws.current.onclose = () => {
      setStatus('closed');
      // 지수 백오프 재연결
      reconnectTimeout.current = setTimeout(connect, 3000);
    };

    ws.current.onerror = () => ws.current?.close();
  }, [url]);

  useEffect(() => {
    connect();
    return () => {
      clearTimeout(reconnectTimeout.current);
      ws.current?.close();
    };
  }, [connect]);

  const send = useCallback((data: object) => {
    if (ws.current?.readyState === WebSocket.OPEN) {
      ws.current.send(JSON.stringify(data));
    }
  }, []);

  return { send, status };
}

3. 스케일링: 멀티 서버 환경

여러 서버 인스턴스에서 WebSocket/SSE 연결이 분산될 때 클라이언트 A의 이벤트를 클라이언트 B에게 전달하려면 인스턴스 간 통신이 필요하다.

Redis Pub/Sub

import { createClient } from 'redis';

const publisher = createClient();
const subscriber = createClient();

await publisher.connect();
await subscriber.connect();

// 이벤트 발생 시 Redis에 퍼블리시
async function broadcastToRoom(roomId: string, message: object) {
  await publisher.publish(`room:${roomId}`, JSON.stringify(message));
}

// 각 인스턴스가 구독
await subscriber.subscribe('room:*', (message, channel) => {
  const roomId = channel.replace('room:', '');
  const localClients = rooms.get(roomId);

  localClients?.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
});

맺으며

대부분의 "실시간" 기능은 사실 단방향이다: 서버가 업데이트를 클라이언트에게 푸시한다. 이 경우 SSE가 WebSocket보다 인프라 친화적이고 구현이 단순하다.

WebSocket이 진짜 필요한 때는 양방향 실시간 통신이 요구될 때다. 채팅이 아니라 알림이라면 SSE를, 사용자 입력이 실시간으로 다른 사용자에게 전달되어야 한다면 WebSocket을 선택한다.