프론트엔드 통신 방식 (2): HTTP 위에서의 실시간 — Polling, Long Polling, SSE

Development

"실시간 기능 하나 넣어주세요"라는 요청을 받으면, 많은 사람이 곧장 WebSocket을 떠올립니다. 그런데 막상 만들어 보면 WebSocket은 생각보다 비쌉니다. 연결을 유지해야 하고, 끊기면 재연결해야 하고, 서버를 여러 대로 늘리면 "이 사용자가 어느 서버에 붙어 있지?"를 풀어야 합니다.

그래서 이번 편에서는 WebSocket을 꺼내기 전에 먼저 검토해야 할 선택지를 다룹니다. 바로 HTTP 위에서 실시간을 구현하는 세 가지 방법입니다.

  • Short Polling (짧은 주기 반복 요청)
  • Long Polling (응답을 미뤄두는 요청)
  • SSE (Server-Sent Events, 서버 단방향 스트림)

이들은 "구식"처럼 보이지만, 많은 실시간 요구사항을 가장 단순하고 안정적으로 해결합니다.

이 글은 프론트엔드 통신 방식 시리즈의 2편입니다.

한눈에 보면

  • Short Polling은 일정 간격으로 그냥 다시 요청하는 것. 가장 단순하지만 낭비가 크고 지연이 간격에 묶입니다.
  • Long Polling은 서버가 변화가 생길 때까지 응답을 미뤄, "거의 실시간"을 HTTP만으로 흉내 냅니다.
  • SSE는 하나의 HTTP 연결로 서버가 이벤트를 계속 흘려보내는 표준 기술입니다. 단방향 실시간의 정답에 가깝습니다.
  • SSE는 브라우저의 EventSource자동 재연결과 마지막 이벤트 ID 추적을 기본 제공해, 직접 만들 때보다 훨씬 견고합니다.
  • 가장 흔한 실무 함정은 프록시/로드밸런서의 버퍼링으로, SSE가 "동작은 하는데 데이터가 안 흐르는" 현상을 만듭니다.
  • 양방향이 필요 없다면, SSE는 WebSocket보다 거의 항상 더 단순하고 운영이 쉽습니다.

출발점: 그냥 반복해서 묻기 (Short Polling)

가장 원초적인 실시간은 "계속 물어보는 것"입니다. 새 데이터가 있는지 일정 간격으로 다시 요청합니다.

// Short Polling: 3초마다 새 알림 확인
function startPolling(onData: (data: unknown) => void) {
  const id = setInterval(async () => {
    const res = await fetch('/api/notifications');
    if (res.ok) onData(await res.json());
  }, 3000);
 
  return () => clearInterval(id); // 정리 함수
}

장점은 분명합니다. 추가 인프라가 전혀 없고, 그냥 REST 요청의 반복입니다. 캐시·CDN·로드밸런서 모두 그대로 동작합니다.

하지만 트레이드오프가 큽니다.

  • 지연이 간격에 묶인다. 3초 간격이면 최악의 경우 3초 늦게 받습니다.
  • 대부분의 요청이 헛수고다. 변화가 없어도 계속 요청하므로 서버와 네트워크를 낭비합니다.
  • 사용자가 많아질수록 비용이 선형으로 증가합니다. 사용자 1만 명 × 3초 간격이면 초당 3,300건이 "혹시 바뀐 거 있나요?"를 묻는 셈입니다.

그래서 Short Polling은 변화 빈도가 낮고, 약간의 지연이 허용되며, 사용자가 적을 때 적합합니다. 예를 들어 관리자 화면의 "처리 상태" 갱신 정도라면 충분합니다.

React에서 폴링을 직접 만들지 않기

실무 프론트엔드에서는 폴링을 손으로 짜기보다, 이미 폴링을 지원하는 데이터 패칭 라이브러리를 쓰는 편이 안전합니다. 예를 들어 React Query는 refetchInterval 하나로 끝나고, 탭이 백그라운드일 때 멈추는 등의 세부 처리까지 해 줍니다.

import { useQuery } from '@tanstack/react-query';
 
function useJobStatus(jobId: string) {
  return useQuery({
    queryKey: ['job', jobId],
    queryFn: () => fetch(`/api/jobs/${jobId}`).then((r) => r.json()),
    refetchInterval: (query) => (query.state.data?.status === 'done' ? false : 2000), // 끝나면 폴링 중단
  });
}

이렇게 "완료되면 폴링을 멈추는" 조건부 폴링은 실무에서 매우 자주 쓰이는 패턴입니다. 배치 작업 진행률, 결제 승인 대기, 파일 변환 상태 같은 화면이 전형적입니다. React Query 자체는 React Query in Production에서 더 다룹니다.

한 단계 진화: Long Polling

Short Polling의 낭비를 줄이는 방법이 Long Polling입니다. 아이디어는 단순합니다. 서버가 곧바로 응답하지 않고, 줄 데이터가 생길 때까지 응답을 붙잡고 있는 것입니다.

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: GET /poll (요청)
  Note over S: 새 데이터가 생길 때까지 대기 (응답 보류)
  S-->>C: 200 + 데이터 (변화 발생 시)
  C->>S: GET /poll (즉시 재요청)
  Note over S: 다시 대기...

클라이언트는 응답을 받으면 즉시 다음 요청을 보냅니다. 그래서 사실상 연결이 거의 끊기지 않은 것처럼 동작하면서도, 사용하는 것은 평범한 HTTP 요청-응답입니다.

async function longPoll(onMessage: (msg: unknown) => void) {
  let active = true;
 
  (async function loop() {
    while (active) {
      try {
        const res = await fetch('/api/long-poll');
        if (res.ok) onMessage(await res.json());
        // 응답을 받자마자 while 루프가 즉시 다음 요청을 보낸다
      } catch {
        await new Promise((r) => setTimeout(r, 1000)); // 에러 시 백오프
      }
    }
  })();
 
  return () => {
    active = false;
  };
}

Long Polling의 가치는 **"진짜 실시간 인프라 없이 거의 실시간을 얻는다"**는 데 있습니다. 그래서 WebSocket이 표준화되기 전 많은 채팅·알림 시스템이 이 방식을 썼고, 지금도 폴백(fallback)으로 널리 쓰입니다.

하지만 한계도 명확합니다.

  • 서버가 응답을 보류하는 동안 연결과 워커를 점유합니다. 동시 사용자가 많으면 보류 중인 요청도 그만큼 쌓입니다.
  • 타임아웃 관리가 필요합니다. 프록시나 브라우저가 너무 오래 걸리는 요청을 끊을 수 있어, 서버는 일정 시간이 지나면 "변화 없음"으로 응답을 마무리하고 클라이언트가 재요청하게 해야 합니다.
  • 매 요청마다 헤더·인증 오버헤드가 다시 발생합니다.

즉 Long Polling은 표준 SSE/WebSocket을 쓸 수 없는 환경의 호환성 카드에 가깝습니다. 새로 설계한다면 단방향은 SSE가 거의 항상 더 낫습니다.

정답에 가까운 단방향 실시간: SSE

Server-Sent Events는 하나의 HTTP 연결을 열어두고 서버가 그 위로 이벤트를 계속 흘려보내는 표준 기술입니다. 브라우저는 EventSource API로 이걸 기본 지원합니다.

SSE의 핵심 매력은 "단순함"입니다. 새 프로토콜이 아니라 그냥 Content-Type: text/event-stream인 긴 HTTP 응답입니다. 그래서 HTTP의 인증·헤더·HTTPS를 그대로 쓰고, 디버깅도 네트워크 탭에서 됩니다.

SSE의 와이어 포맷

서버가 보내는 것은 약속된 텍스트 형식입니다.

event: notification
id: 42
data: {"title":"새 댓글","postId":7}
 
event: notification
id: 43
data: {"title":"좋아요","postId":7}
 
  • data: 줄이 실제 페이로드. 여러 줄로 나눌 수 있습니다.
  • event: 는 이벤트 이름(생략하면 기본 message).
  • id: 는 마지막 이벤트 ID. 재연결 시 이어받기의 핵심입니다.
  • 각 이벤트는 빈 줄로 구분합니다.

클라이언트: EventSource

function subscribeNotifications(onNotify: (data: unknown) => void) {
  const es = new EventSource('/api/sse/notifications');
 
  // 이름 있는 이벤트 구독
  es.addEventListener('notification', (e) => {
    onNotify(JSON.parse(e.data));
  });
 
  es.onerror = () => {
    // EventSource는 끊기면 "자동으로" 재연결을 시도한다.
    // 보통 여기서 별도 재연결 코드를 짤 필요가 없다.
    console.warn('SSE 연결 끊김 — 자동 재연결 대기 중');
  };
 
  return () => es.close();
}

여기서 EventSource의 진짜 가치가 드러납니다. 자동 재연결Last-Event-ID 자동 전송입니다. 연결이 끊기면 브라우저가 알아서 다시 연결하고, 마지막으로 받은 idLast-Event-ID 헤더로 보냅니다. 서버는 이 값을 보고 빠진 이벤트부터 다시 보낼 수 있습니다. 이걸 직접 구현하려면 꽤 많은 코드가 필요한데, 표준이 거저 줍니다.

서버: Next.js Route Handler 예제

App Router의 Route Handler에서 ReadableStream으로 SSE를 구현할 수 있습니다.

// app/api/sse/notifications/route.ts
export const dynamic = 'force-dynamic'; // 정적 최적화 방지
 
export async function GET(req: Request) {
  const lastEventId = req.headers.get('last-event-id');
  const encoder = new TextEncoder();
 
  const stream = new ReadableStream({
    start(controller) {
      let id = lastEventId ? Number(lastEventId) : 0;
 
      const send = (data: unknown) => {
        id += 1;
        controller.enqueue(
          encoder.encode(`event: notification\nid: ${id}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };
 
      // 예시: 5초마다 하트비트(연결 유지) + 실제 이벤트
      const timer = setInterval(() => {
        send({ title: '주기적 업데이트', at: id });
      }, 5000);
 
      // 클라이언트가 떠나면 정리
      req.signal.addEventListener('abort', () => {
        clearInterval(timer);
        controller.close();
      });
    },
  });
 
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
    },
  });
}

몇 가지 짚을 점:

  • Cache-Control: no-transform은 중간 프록시가 응답을 변형/버퍼링하지 못하게 하는 신호입니다(뒤의 함정 절 참고).
  • req.signalabort로 클라이언트 이탈을 감지해 타이머와 리소스를 반드시 정리해야 합니다. 안 그러면 유령 연결이 쌓입니다.
  • 하트비트(주기적 전송)는 유휴 연결이 프록시에 의해 끊기는 것을 막는 실무 패턴입니다. 주석(: ping\n\n)으로 보내면 클라이언트 이벤트로 처리되지 않아 깔끔합니다.

EventSource의 가장 큰 함정: 헤더를 못 붙인다

여기서 실무자가 반드시 알아야 할 제약이 있습니다. 브라우저의 EventSource는 커스텀 헤더를 설정할 수 없습니다.Authorization: Bearer ...를 붙일 수 없습니다. fetch나 axios에 익숙한 사람이 SSE를 처음 붙일 때 거의 항상 막히는 지점입니다.

대응 방법은 세 가지이고, 각각 트레이드오프가 다릅니다.

// 방법 1: 쿠키 인증 (가장 깔끔)
// 같은 출처면 EventSource가 쿠키를 자동 전송한다.
const es = new EventSource('/api/sse/notifications', { withCredentials: true });
  • 방법 1 — 쿠키 기반 인증: 같은 출처(혹은 적절한 CORS 설정)면 withCredentials: true로 쿠키가 자동 전송됩니다. 가장 권장되는 방식이고, 쿠키 기반 인증을 이미 쓰고 있다면 추가 작업이 거의 없습니다.
  • 방법 2 — 쿼리 파라미터로 토큰 전달: new EventSource('/api/sse?token=...'). 동작은 하지만 토큰이 URL에 노출되어 서버 액세스 로그·프록시 로그·브라우저 히스토리에 남습니다. 보안상 권장하지 않습니다.
  • 방법 3 — fetch 기반 SSE 라이브러리: @microsoft/fetch-event-source 같은 라이브러리는 fetch로 스트림을 읽어 헤더를 자유롭게 붙일 수 있습니다. 대신 자동 재연결·Last-Event-ID를 직접 챙겨야 합니다. AI 토큰 스트리밍처럼 Authorization 헤더가 필수인 경우 사실상 표준 선택입니다.
// 방법 3: fetch 기반 — 헤더 자유, 단 재연결은 직접
import { fetchEventSource } from '@microsoft/fetch-event-source';
 
const ctrl = new AbortController();
await fetchEventSource('/api/sse/notifications', {
  headers: { Authorization: `Bearer ${getAccessToken()}` },
  signal: ctrl.signal,
  onmessage: (ev) => handle(JSON.parse(ev.data)),
  onerror: (err) => {
    // throw하면 재시도 중단, 그냥 return하면 라이브러리가 재시도
    if (isFatal(err)) throw err;
  },
});

시니어의 판단: 표준 EventSource + 쿠키 인증으로 끝나면 그게 가장 단순합니다. 토큰을 헤더로 보내야 하는 제약이 생기는 순간(예: 다른 출처의 토큰 인증 API), 방법 3으로 넘어가되 "공짜로 받던 재연결·이벤트 ID를 다시 떠안는다"는 비용을 인지해야 합니다.

Last-Event-ID를 진짜로 활용하기: 서버 측 재전송

앞에서 EventSource가 끊기면 마지막 idLast-Event-ID 헤더로 보낸다고 했습니다. 그런데 서버가 그 값을 실제로 활용하지 않으면 의미가 없습니다. 자동 재연결만으로는 "끊긴 동안 발생한 이벤트"가 그대로 유실됩니다.

견고한 SSE 서버는 보낸 이벤트를 짧게 보관(예: Redis Stream, 또는 메모리 링버퍼)했다가, 재연결 시 그 ID 이후를 먼저 재전송합니다.

// app/api/sse/notifications/route.ts (재전송 포함)
export async function GET(req: Request) {
  const lastId = Number(req.headers.get('last-event-id') ?? 0);
  const encoder = new TextEncoder();
 
  const stream = new ReadableStream({
    async start(controller) {
      const frame = (id: number, data: unknown) =>
        controller.enqueue(encoder.encode(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`));
 
      // 1) 끊긴 동안 놓친 이벤트부터 메운다
      const missed = await getEventsAfter(lastId); // 저장소에서 조회
      for (const e of missed) frame(e.id, e.payload);
 
      // 2) 이후 실시간 이벤트를 이어서 흘린다
      const unsubscribe = subscribe((e) => frame(e.id, e.payload));
      req.signal.addEventListener('abort', () => {
        unsubscribe();
        controller.close();
      });
    },
  });
 
  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform' },
  });
}

이 "재전송 윈도우"가 있어야 SSE가 at-least-once에 가까운 전달을 흉내 낼 수 있습니다. 다만 보관 기간(예: 최근 5분)을 넘겨 끊겼다면 그 간격은 메울 수 없으므로, 클라이언트가 재연결 시 REST로 전체 상태를 한 번 다시 가져오는 폴백을 함께 두는 것이 안전합니다.

Long Polling 서버는 어떻게 생겼나

2절에서 Long Polling 클라이언트만 봤는데, 서버 쪽이 어떻게 응답을 "붙잡는지"를 보면 비용이 분명해집니다.

// app/api/long-poll/route.ts
export async function GET(req: Request) {
  const since = Number(new URL(req.url).searchParams.get('since') ?? 0);
 
  // 곧바로 응답하지 않고, 새 이벤트가 생기거나 타임아웃까지 대기
  const event = await Promise.race([
    waitForNextEvent(since), // 변화가 생기면 resolve
    timeout(25_000, null), // 25초 후 '변화 없음'으로 종료
  ]);
 
  // 타임아웃이면 빈 응답 → 클라이언트가 즉시 재요청(연결 갱신)
  return Response.json(event ?? { type: 'empty', since });
}

핵심은 두 가지입니다. (1) 응답을 보류하는 동안 그 요청이 서버 리소스(이벤트 루프 핸들, 종종 워커)를 점유한다는 점, 그리고 (2) 프록시/브라우저가 끊기 전에 서버가 먼저 타임아웃으로 끝내야 깔끔하게 재요청 사이클이 돈다는 점입니다. 동시 사용자 1만 명이면 보류 중인 요청 1만 개가 떠 있는 셈이라, 이게 Long Polling이 SSE보다 무거운 이유입니다.

느린 소비자(backpressure)라는 숨은 문제

서버가 이벤트를 빠르게 흘려보내는데 클라이언트(또는 그 사이 네트워크)가 느리면, 보내지 못한 데이터가 서버 측 버퍼에 쌓입니다. SSE/스트리밍에서 자주 놓치는 부분입니다.

  • ReadableStreamcontroller.enqueue는 무한정 받아주지 않으며, 소비가 느리면 백프레셔가 걸립니다.
  • 고빈도 이벤트(초당 수백 건)를 그대로 모든 클라이언트에 밀면, 느린 클라이언트 하나가 메모리를 잠식할 수 있습니다.

실무 대응은 서버에서 합치거나(coalescing) 떨어뜨리는(drop) 것입니다. 예를 들어 실시간 대시보드라면 "마지막 값만 의미 있다"는 성질을 이용해, 100ms 동안의 업데이트를 하나로 합쳐서 보냅니다. 모든 틱을 다 보내는 것보다 사용자 경험도 좋고 안전합니다.

// 100ms 윈도우로 최신 값만 합쳐 보내기 (개념)
let latest: unknown = null;
source.on('update', (v) => (latest = v)); // 들어오는 건 덮어쓰기만
setInterval(() => {
  if (latest !== null) {
    frame(nextId(), latest);
    latest = null;
  }
}, 100);

SSE의 가장 흔한 함정: 버퍼링과 연결 한도

SSE는 코드가 맞아도 환경 때문에 "조용히 안 되는" 경우가 많습니다. 시니어가 SSE 장애를 디버깅할 때 가장 먼저 의심하는 두 가지입니다.

1. 프록시/서버의 응답 버퍼링

Nginx 같은 리버스 프록시나 일부 서버리스 환경은 응답을 모아서 한꺼번에 보내려고 합니다. SSE는 "조금씩 흘려보내는" 게 핵심인데, 버퍼링이 끼면 이벤트가 한참 모였다가 터지거나 아예 안 옵니다.

  • Nginx라면 X-Accel-Buffering: no 헤더나 proxy_buffering off;로 꺼야 합니다.
  • 응답 압축(gzip)도 버퍼링을 유발할 수 있어 SSE 경로에서는 끄는 편이 안전합니다.
  • 일부 서버리스/엣지 환경은 스트리밍 응답 지원 여부와 최대 실행 시간 제한을 반드시 확인해야 합니다.

이 함정이 악명 높은 이유는, 로컬에서는 잘 되다가 배포하면 안 되기 때문입니다. 프록시가 끼는 순간 증상이 나타납니다.

2. HTTP/1.1의 동시 연결 한도

브라우저는 같은 도메인에 대해 HTTP/1.1 연결을 6개 정도로 제한합니다. SSE 연결은 그동안 계속 열려 있으므로, 탭을 여러 개 열면 이 한도를 금방 잡아먹어 다른 요청까지 막힐 수 있습니다.

해결책은 HTTP/2 이상을 쓰는 것입니다. HTTP/2는 하나의 연결을 멀티플렉싱하므로 이 6개 제한에서 자유롭습니다. 즉 SSE는 사실상 HTTP/2 환경을 전제로 보는 편이 좋습니다.

SSE vs WebSocket: 단방향이면 SSE

다음 편에서 WebSocket을 깊게 다루겠지만, 선택의 핵심은 여기서 미리 정리할 수 있습니다.

항목 SSE WebSocket
방향성 서버 → 클라이언트 (단방향) 양방향
프로토콜 그냥 HTTP 별도 프로토콜(ws/wss)
자동 재연결 ✅ 내장 ❌ 직접 구현
이벤트 ID 이어받기 ✅ 내장(Last-Event-ID) ❌ 직접 구현
페이로드 텍스트(UTF-8)만 텍스트 + 바이너리
인프라/디버깅 HTTP 그대로, 쉬움 별도 처리 필요
클라이언트 → 서버 전송 일반 REST로 따로 같은 연결로

핵심 결론: 서버가 보여주기만 하면 되는 실시간이라면 SSE가 거의 항상 더 낫습니다. 알림, 실시간 대시보드, 진행률, 라이브 피드, AI 토큰 스트리밍(이게 SSE의 대표 사용처입니다) 같은 화면이 전형적입니다.

클라이언트가 가끔 서버에 보내야 한다면, SSE(받기) + 일반 REST(보내기) 조합이 WebSocket보다 단순한 경우가 많습니다. 양쪽이 대등하게, 자주, 낮은 지연으로 대화해야 할 때 비로소 WebSocket이 정당화됩니다.

미리 알아두면 좋은 함정들

여기서부터는 직접 운영하며 겪은 사례가 아니라, 명세와 자료에서 공통적으로 지적되는 함정들입니다. 셋째 함정은 숫자로 직접 따져보겠습니다.

함정 1: "로컬에선 되는데 배포하면 안 온다"

SSE를 붙이면 로컬에서는 잘 동작하다가 배포 환경에서 이벤트가 안 오는 경우가 자주 언급됩니다. 원인은 대개 앞서 말한 리버스 프록시 버퍼링입니다. Nginx 같은 계층이 응답을 모았다가 한꺼번에 보내려 하면, 조금씩 흘려보내야 할 이벤트가 한참 모였다가 터지거나 아예 도착하지 않습니다.

대응은 SSE 경로에 한해 proxy_buffering off;X-Accel-Buffering: no 헤더를 두고 gzip을 끄는 것입니다. 핵심은, SSE 문제의 1순위 용의자는 코드가 아니라 경로 위의 중간 계층이라는 점입니다. 배포 환경의 프록시 설정을 함께 확인하는 습관이 필요합니다.

함정 2: 서버리스에서 잘리는 스트림

SSE를 서버리스 함수에 올리면 일정 시간(수십 초~몇 분)마다 연결이 강제 종료될 수 있습니다. 많은 서버리스/엣지 런타임이 최대 실행 시간 제한을 두기 때문에 장기 연결과는 상성이 나쁩니다.

이 경우 선택지는 (1) 스트리밍을 지원하고 실행 시간이 긴 런타임으로 옮기거나, (2) 함수가 끊기는 것을 정상 흐름으로 보고 클라이언트가 Last-Event-ID로 자연스럽게 재연결하도록 설계하는 것입니다. 후자라면 앞의 "서버 측 재전송"이 사실상 필수가 됩니다. 핵심은, 실행 환경의 수명 제한을 먼저 확인하지 않으면 장기 연결 기술이 의도대로 동작하지 않는다는 점입니다.

함정 3: 폴링 간격을 줄이면 비용이 곱해진다

숫자로 직접 따져봅시다. 동시 사용자 U명이 간격 I초로 폴링하면 서버가 받는 요청은 초당 U / I건입니다. 사용자 10,000명 기준으로:

  • 30초 간격 → 초당 약 333건
  • 3초 간격 → 초당 약 3,333건

"좀 더 실시간처럼 보이게" 간격을 1/10로 줄이면 요청량은 정확히 10배가 됩니다. 그런데 그 요청 대부분은 "변화 없음"을 확인하는 헛요청입니다. 동시 사용자가 늘어나는 상황과 겹치면 부담이 빠르게 커집니다.

그래서 (1) 변화가 잦은 화면만 SSE로 전환하고, (2) 나머지는 조건부 폴링 + 백오프(탭이 백그라운드면 멈춤, 변화 없으면 간격 증가)로 두는 편이 좋습니다. 핵심은, 폴링 간격은 공짜 손잡이가 아니라는 점입니다. 간격을 줄이는 것은 비용을 곱하는 일이고, 임계점을 넘으면 SSE가 오히려 더 쌉니다.

판단 기준 요약

신호 더 나은 선택
변화가 드물고 약간 지연 OK 조건부 폴링
폴링 간격을 자꾸 줄이고 싶어짐 SSE로 전환 신호
서버가 보여주기만 함 + 토큰 헤더 불필요 EventSource + 쿠키
서버가 보여주기만 함 + 토큰 헤더 필수 fetch 기반 SSE
끊김 구간 유실이 치명적 SSE + 서버 측 재전송 + REST 폴백
양쪽이 자주·즉시 대화 WebSocket(3편)

자주 하는 오해

1. 폴링은 무조건 나쁘다

아닙니다. 변화가 드물고 약간의 지연이 허용되면, 조건부 폴링은 가장 단순하고 디버깅하기 쉬운 정답입니다. 인프라를 늘리지 않는다는 건 큰 장점입니다.

2. SSE는 구식이고 WebSocket이 상위 호환이다

아닙니다. 둘은 방향성이 다른 도구입니다. SSE는 단방향에 특화되어 더 단순하고, 자동 재연결·이벤트 ID 같은 표준 기능을 무료로 줍니다. AI 스트리밍 같은 최신 사용처에서 오히려 SSE가 표준처럼 쓰입니다.

3. SSE 코드만 맞으면 동작한다

아닙니다. 프록시 버퍼링과 HTTP/1.1 연결 한도 때문에 환경이 좌우합니다. "로컬에선 됐는데 배포하면 안 된다"의 단골 원인입니다.

4. Long Polling은 이제 쓸모없다

아닙니다. SSE/WebSocket을 쓸 수 없는 제한된 환경에서의 폴백으로 여전히 가치가 있습니다. 다만 새 설계의 1순위는 아닙니다.

정리하면

WebSocket을 꺼내기 전에, HTTP 위에서 실시간을 구현하는 세 가지를 먼저 검토할 가치가 있습니다.

짧게 정리하면:

  • Short Polling: 가장 단순, 변화가 드물 때. 조건부 폴링으로 낭비를 줄이세요.
  • Long Polling: HTTP만으로 거의 실시간. 지금은 주로 폴백 용도.
  • SSE: 단방향 실시간의 표준 정답. 자동 재연결·이벤트 ID 이어받기가 무료.
  • 함정은 코드가 아니라 프록시 버퍼링과 연결 한도 같은 환경에 있습니다.

서버가 보여주기만 하면 되는 실시간이라면 여기서 끝나는 경우가 많습니다. 하지만 양쪽이 대등하게 대화해야 한다면 다음 편이 필요합니다. 3편에서는 WebSocket을 핸드셰이크부터 수평 확장까지 깊게 파고듭니다.

같이 보면 좋은 글