본문 바로가기
Backend2026년 4월 19일7분 읽기

Server-Sent Events(SSE) 완벽 정리 — WebSocket 없이 실시간 기능 구현하는 법

YS
김영삼
조회 326

핵심 요약

SSE는 서버 → 클라이언트 단방향 HTTP 스트리밍이다. text/event-stream MIME 타입의 응답을 끊지 않고 이벤트를 push한다. 2026년 현재 ChatGPT·Claude·GitHub 알림 등 주요 서비스 상당수가 SSE를 쓴다.

  • 프로토콜: 표준 HTTP(S), 추가 설치 불필요
  • 브라우저 API: EventSource 내장
  • 자동 재연결: 표준 스펙 포함
  • 단방향: 클라이언트 → 서버는 일반 AJAX로 처리

SSE vs WebSocket — 언제 무엇을 쓰나

항목SSEWebSocket
방향서버 → 클라이언트양방향
프로토콜HTTPUpgrade → ws
프록시·CDN 호환최고설정 필요
자동 재연결기본 포함직접 구현
브라우저 호환99%+99%+
초당 이벤트수백 ~ 수천수만+

LLM 스트리밍 응답·실시간 알림·대시보드 업데이트·빌드 로그 스트리밍은 SSE가 최적. 채팅·게임·실시간 협업 에디터는 WebSocket.

클라이언트 (브라우저)

const es = new EventSource('/api/stream');
es.onmessage = (e) => console.log('default:', e.data);
es.addEventListener('progress', (e) => {
  const data = JSON.parse(e.data);
  updateProgress(data.percent);
});
es.addEventListener('done', () => es.close());
es.onerror = () => console.log('자동 재연결 중...');

서버 — Node/Express

app.get('/api/stream', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',  // Nginx buffering 비활성
  });
  res.flushHeaders();

  // keepalive ping 15초
  const ping = setInterval(() => res.write(':ping\n\n'), 15000);

  const send = (event, data) => {
    if (event) res.write(`event: ${event}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  send('start', { ts: Date.now() });

  // 이벤트 소스
  const unsubscribe = eventBus.subscribe((evt) => send(evt.type, evt.payload));

  req.on('close', () => { clearInterval(ping); unsubscribe(); });
});

서버 — Go

func stream(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, _ := w.(http.Flusher)
    ctx := r.Context()
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case t := <-ticker.C:
            fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
            flusher.Flush()
        }
    }
}

프록시 설정 (매우 중요)

Nginx

location /api/stream {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;          # 핵심
    proxy_cache off;
    proxy_read_timeout 24h;
    chunked_transfer_encoding on;
}

Cloudflare

기본적으로 SSE 지원. 단 "Caching Level: Bypass"proxy_buffering 끄기를 오리진에서 확실히 해야 한다. 클플은 응답을 캐싱하지 않지만 일부 프리페치 레이어가 스트리밍을 방해할 수 있다.

자동 재연결과 Last-Event-ID

클라이언트가 연결이 끊기면 자동으로 재연결한다. 서버가 id: 필드를 보냈다면 재연결 요청에 Last-Event-ID 헤더가 포함된다. 서버는 그 지점부터 재개.

res.write(`id: ${msgId}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);

한계·주의

  • HTTP/1.1에서는 브라우저당 도메인 연결 6개 제한에 걸릴 수 있음 → HTTP/2·3에서 해결
  • 텍스트 기반 → 바이너리 필요하면 base64 오버헤드
  • 클라이언트 → 서버는 별도 REST/GraphQL 호출 필요
  • 서버 메모리: 연결당 소량이지만 10만 연결 시 수 GB 가능 — Node는 cluster/worker 튜닝 필요

실전 유스케이스

  • LLM 응답 토큰 단위 스트리밍
  • CI 빌드 로그 실시간 전송
  • 주식·암호화폐 실시간 시세
  • Notion·Linear 같은 도구의 문서 동기화 알림
  • Slack·Discord 등의 알림 배지 업데이트

자주 묻는 질문

HTTPS에서만 동작하나?

HTTP에서도 가능. 다만 HTTP/2 이상에서 연결 수 제한이 해결돼 운영상 HTTPS(HTTP/2+)가 사실상 표준.

React Native·모바일 지원은?

네이티브 EventSource가 없으면 fetch ReadableStream으로 구현하는 라이브러리를 쓰면 된다. iOS/Android 모두 동작.

keepalive ping은 꼭 필요한가?

프록시·방화벽의 idle timeout(30~60초)을 막기 위해 15초 간격 comment(:ping)를 보내는 것이 권장이다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록