SSE 개론

Nyong·2025년 10월 15일

1. SSE란?

SSE (Server-Sent Events)는 서버가 클라이언트에게 일방적으로 데이터를 지속해서 보내는(Push) 표준 기술이다.
HTTP 프로토콜 위에서 동작하므로, WebSocket처럼 별도의 프로토콜 없이 기존 웹 인프라를 그대로 활용할 수 있다.

  • 핵심 특징

    • 단방향 통신 (Server → Client): 서버에서 브라우저로만 데이터를 전송한다. 실시간 채팅보다는 알림, 대시보드 업데이트, 주식 시세 등에 적합하다.
    • 자동 재연결: 클라이언트의 EventSource API는 네트워크 연결이 끊어졌을 때 자동으로 재연결을 시도하는 기능이 내장되어 있다.
    • HTTP 기반: ws:// 같은 별도 프로토콜이 아닌, 익숙한 HTTP/HTTPS 위에서 동작하여 구현과 디버깅이 비교적 쉽다.
    • 텍스트 기반: text/event-stream이라는 단순한 텍스트 형식으로 데이터를 주고받는다.
  • SSE vs WebSocket 비교

구분SSEWebSocket
통신 방향단방향 (서버→클라이언트)양방향
주요 용도알림, 데이터 스트리밍채팅, 온라인 게임
재연결API 자체 지원 (자동)직접 구현 필요
프로토콜HTTP/HTTPSws://, wss://

2. SSE를 활용하기 위한 방법들

프론트엔드에서 SSE를 사용하는 방법은 크게 세 가지이며, 인증 헤더를 보낼 수 있는 EventSourcePolyfill를 권장한다.

방법핵심 특징장점단점
EventSource (네이티브)브라우저 기본 API간단함, 설치 불필요커스텀 헤더 불가
EventSourcePolyfill (권장)기능 확장 라이브러리커스텀 헤더 가능, 호환성외부 의존성 추가
fetch + ReadableStream저수준(Low-level) API최고의 유연성매우 복잡, 재연결 직접 구현
  • EventSource: Authorization 헤더를 보낼 수 없어 인증이 필요한 실무 API에는 사용하기 어렵다.
  • EventSourcePolyfill: headers 옵션을 통해 인증 토큰을 쉽게 전송할 수 있다.
  • fetch + ReadableStream: 스트림을 직접 제어해야 하는 매우 특수한 상황(예: 스트림 변환)이 아니면 지양.

3. 기본적인 사용법

EventSourcePolyfill을 사용한 기본적인 클라이언트/서버 연동 예시이다.

  • 서버 (Node.js/Express 예시)
    백엔드에서는 Content-Type: text/event-stream 헤더와 함께 정해진 형식(data:, id:, event:)으로 데이터를 보내야 한다.

    const express = require('express');
    const cors = require('cors');
    
    const app = express();
    const PORT = 3001;
    
    app.use(cors());
    
    // SSE 엔드포인트 설정
    app.get('/sse', (req, res) => {
      // 1. 응답 헤더 설정
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');
      res.flushHeaders(); // 즉시 헤더 전송
    
      // 인증 헤더 확인 (Polyfill을 사용하는 이유!)
      const token = req.headers.authorization;
      console.log(`클라이언트 연결 완료. (인증 토큰: ${token})`);
    
      // 2. 1초마다 데이터 전송
      const intervalId = setInterval(() => {
          const data = { time: new Date().toLocaleTimeString() };
          // SSE 데이터 형식: "data: JSON문자열\n\n"
          res.write(`data: ${JSON.stringify(data)}\n\n`);
      }, 1000);
    
      // 3. 클라이언트 연결 종료 시 처리
      req.on('close', () => {
          clearInterval(intervalId);
          res.end();
          console.log('클라이언트 연결 종료.');
      });
    });
    
    app.listen(PORT, () => {
      console.log(`SSE 서버가 http://localhost:${PORT} 에서 실행 중입니다.`);
    });
  • 클라이언트 (React, EventSourcePolyfill 사용)
    클라이언트는 EventSourcePolyfill 객체를 생성하여 서버에 연결하고, addEventListener로 특정 이벤트를 수신한다.

       // 1. EventSourcePolyfill 객체 생성
    const eventSource = new EventSourcePolyfill('http://localhost:3001/sse', {
       headers: {
           Authorization: `Bearer ${accessToken}`
       },
       heartbeatTimeout: 86400000 // 타임아웃 시간 길게 설정 (옵션)
    });
    
    // 2. 'message' 이벤트 리스너 등록 (기본 이벤트)
    eventSource.onmessage = (event) => {
       // event.data는 서버가 보낸 JSON 문자열
       const data = JSON.parse(event.data);
       timeDisplay.textContent = data.time;
       console.log('메시지 수신:', data);
    };
    
    // 3. 'open' 이벤트 리스너 (연결 성공 시)
    eventSource.onopen = () => {
       console.log('SSE 연결 성공!');
    };
    
    // 4. 'error' 이벤트 리스너 (에러 발생 시)
    eventSource.onerror = (error) => {
       console.error('EventSource 에러 발생:', error);
    
       // event-stream이 아니기 때문에 error인 경우
       const isNotStream = error.status === 200;
    
       // 에러 발생 시 연결이 자동으로 닫히므로, 필요 시 여기서 재연결 로직 구현 가능
       eventSource.close();
    };

    EventSourcePolyfill 코드를 까보면 아래와 같이 event-stream이 아닌 경우 status 200 && error로 확인 가능함.

    // EventSourcePolyfill eventsource.js
    
    var contentTypeRegExp = /^text\/event\-stream(;.*)?$/i;
    
    var onStart = function (status, statusText, contentType, headers) {
        if (currentState === CONNECTING) {
            if (status === 200 && contentType != undefined && contentTypeRegExp.test(contentType)) {
                currentState = OPEN;
                wasActivity = Date.now();
                retry = initialRetry;
                es.readyState = OPEN;
                var event = new ConnectionEvent('open', {
                    status: status,
                    statusText: statusText,
                    headers: headers
                });
                es.dispatchEvent(event);
                fire(es, es.onopen, event);
            } else {
                var message = '';
                if (status !== 200) {
                    if (statusText) {
                        statusText = statusText.replace(/\s+/g, ' ');
                    }
                    message = "EventSource's response has a status " + status + ' ' + statusText + ' that is not 200. Aborting the connection.';
                } else {
                    message =
                        "EventSource's response has a Content-Type specifying an unsupported type: " +
                        (contentType == undefined ? '-' : contentType.replace(/\s+/g, ' ')) +
                        '. Aborting the connection.';
                }
                close();
                var event = new ConnectionEvent('error', {
                    status: status,
                    statusText: statusText,
                    headers: headers
                });
                es.dispatchEvent(event);
                fire(es, es.onerror, event);
                console.error(message);
            }
        }
    };

4. 기타

  • SPA에서의 생명주기 관리
    React, Vue 등 SPA 환경에서는 컴포넌트가 화면에서 사라질 때(unmount) sse.close()를 호출하여 반드시 연결을 끊어야 한다. 그렇지 않으면 불필요한 연결이 계속 남아 메모리 누수 및 성능 저하의 원인이 된다.

    // React useEffect 훅을 이용한 생명주기 관리
    useEffect(() => {
      const sse = new EventSourcePolyfill(...);
      // ... 이벤트 리스너 등록 ...
    
      // cleanup 함수: 컴포넌트가 unmount될 때 실행됨
      return () => {
        sse.close();
      };
    }, []); // 의존성 배열이 비어있어 마운트 시 한 번만 실행
  • Nginx 등 리버스 프록시 설정
    Nginx는 기본적으로 백엔드의 응답을 버퍼링했다가 한번에 클라이언트로 보내는데, 이는 SSE의 스트리밍 동작을 방해한다. SSE 엔드포인트에 대해서는 반드시 프록시 버퍼링을 꺼야 한다.

    # /etc/nginx/conf.d/default.conf
    location /events {
        proxy_pass http://your-backend-server;
        proxy_set_header Connection '';
        proxy_http_version 1.1;
        
        # (핵심) 프록시 버퍼링 비활성화
        proxy_buffering off;
        
        # 캐시 관련 헤더 설정
        proxy_cache off;
    }
profile
개인 기록용 블로그

0개의 댓글