SSE 유의사항 — 프록시 버퍼링·재연결 폭주·인증 제약

SSE는 로컬에서 짜면 잘 된다. EventSource 열고 emitter.send() 하면 바로 온다. 문제는 그 사이에 Nginx, 로드밸런서, 방화벽이 끼는 순간 생긴다. “로컬에선 되는데 운영에선 이벤트가 안 와요” — 거의 다 인프라 레이어 함정이다.

원리와 스케일링은 형제 글에서 깊게 다뤘으니, 여기서는 실제로 밟는 함정만 ‘왜 생기나 → 증상 → 어떻게 피하나’ 순으로 모았다.

→ 한 연결의 동작 원리: SSE 동작 원리 — HTTP, TCP/IP 레이어까지 → 대규모 아키텍처: SSE 연결 1억건 — 현실적인 아키텍처 설계


프록시 뒤에서 이벤트가 안 온다 — 버퍼링

왜 생기나: Nginx는 기본적으로 업스트림 응답을 메모리 버퍼에 쌓았다가 한 번에 클라이언트로 보낸다(proxy_buffering on). 일반 API에선 좋은 최적화지만, SSE는 응답이 끝나지 않으니 버퍼가 찰 때까지 이벤트를 붙잡는다.

증상: emitter.send()를 해도 클라이언트에 안 온다. 그러다 한참 뒤 수십 개가 한꺼번에 도착한다.

어떻게 피하나: 버퍼링을 끈다. 두 가지 방법이 있다.

location /events {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_buffering off;          # 핵심
    proxy_set_header Connection '';
}

Nginx 설정을 못 건드리면 응답 헤더로 이 응답에 한해 끌 수 있다.

response.setHeader("X-Accel-Buffering", "no");  // Nginx 버퍼링 비활성화
response.setHeader("Cache-Control", "no-cache");

X-Accel-Buffering: no는 Nginx가 인식하는 응답 헤더다. SSE 관련 장애의 절대다수가 이 함정이다.


60초마다 연결이 끊긴다 — idle/read 타임아웃

왜 생기나: 프록시·로드밸런서는 idle 타임아웃을 둔다. Nginx proxy_read_timeout 기본값은 60초 — 업스트림이 60초 동안 아무것도 안 보내면 죽은 연결로 보고 끊는다. 이벤트가 드문드문 오는 SSE에선 정상 연결도 끊긴다.

증상: 이벤트가 없는 동안 일정 주기(60초 등)로 연결이 끊겼다 재연결된다.

어떻게 피하나: 두 축으로 막는다. ① 타임아웃을 늘리고, ② 주기적 heartbeat로 트래픽을 흘려 idle 상태를 만들지 않는다.

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
@Scheduled(fixedDelay = 30_000)
public void heartbeat() {
    registry.forEach((id, emitter) -> {
        try {
            // SSE comment 라인(`:`) — 브라우저는 무시, 중간 장비엔 "살아있음" 신호
            emitter.send(SseEmitter.event().comment("ping"));
        } catch (IOException e) {
            emitter.completeWithError(e);
            registry.remove(id);
        }
    });
}

heartbeat 주기는 가장 짧은 타임아웃보다 짧게 잡는다. 기업 방화벽(5분), AWS NAT Gateway(기본 350초), 로드밸런서 idle timeout 중 최솟값 기준. 30초가 안전한 기본값이다.

heartbeat 주기 < min(프록시·LB·방화벽 idle timeout) × 0.7

heartbeat가 좀비 연결 감지기 역할도 한다는 점, 운영 디테일은 → SSE 동작 원리에서 다뤘다.


서버 재시작하면 동시 재연결로 서버가 죽는다 — 재연결 폭주(thundering herd)

왜 생기나: 서버를 재배포하면 모든 SSE 연결이 동시에 끊긴다. 브라우저 EventSource는 기본 재연결 간격(보통 3초) 후 재연결하는데, 모든 클라이언트가 같은 타이밍에 동시에 들어온다. 연결 10만이면 3초 뒤 10만 요청 + 인증 + DB 조회가 한꺼번에.

증상: 재배포 직후 트래픽 스파이크. 인증/DB가 먼저 터지고, 재연결이 실패하면 또 재시도해서 악순환.

어떻게 피하나: 재연결 시점을 시간 축으로 흩뿌린다(지터). 서버가 retry 필드로 재연결 간격 힌트를 줄 수 있다.

emitter.send(SseEmitter.event()
        .comment("connected")
        .reconnectTime(3000 + (long)(Math.random() * 7000)));  // 3~10초 랜덤

클라이언트도 지수 백오프 + 지터를 둔다.

let delay = 1000;
const maxDelay = 30000;
function connect() {
    const es = new EventSource('/events');
    es.onopen  = () => { delay = 1000; };          // 성공 시 초기화
    es.onerror = () => {
        es.close();
        setTimeout(connect, delay + Math.random() * 1000);  // 지터
        delay = Math.min(delay * 2, maxDelay);     // 지수 증가
    };
}

인프라 레벨에선 Rolling 배포로 전체 서버를 동시에 내리지 않는 게 근본 대책이다.


재연결 동안 발생한 메시지가 사라진다 — Last-Event-ID 미구현

왜 생기나: 연결이 끊겼다 다시 붙는 사이에도 서버에선 이벤트가 계속 발생한다. 그 공백 동안의 이벤트는 클라이언트가 받지 못한다. SSE는 이를 위해 id 필드와 Last-Event-ID 헤더를 제공하는데, id를 안 넣으면 동작하지 않는다.

증상: 모바일에서 WiFi↔LTE 전환, 화면 잠금, 터널 진입처럼 재연결이 잦은 상황에서 알림이 군데군데 빈다.

어떻게 피하나: 모든 이벤트에 id를 붙인다. 브라우저는 재연결 시 마지막 수신 ID를 Last-Event-ID 헤더로 보내고, 서버는 그 이후 이벤트를 재전송한다.

public SseEmitter subscribe(
        @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
    SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
    if (lastEventId != null) {
        replayAfter(Long.parseLong(lastEventId), emitter);  // 공백 구간 재전송
    }
    return emitter;
}

id는 단조 증가여야 하고, 여러 인스턴스면 Redis INCR 같은 원자적 카운터로 발급한다. 재전송 저장소 구현(Redis Sorted Set)은 → SSE 동작 원리에 정리했다.


EventSource에 Authorization 헤더를 못 붙인다 — 인증 제약

왜 생기나: 브라우저 네이티브 EventSource 생성자는 URL과 withCredentials 정도만 받는다. 커스텀 헤더(Authorization 등)를 붙일 수 없다. Bearer 토큰을 헤더로 넘기는 일반적인 방식이 막힌다.

증상: 다른 API는 Authorization: Bearer ...로 잘 되는데 SSE 엔드포인트만 401이 난다.

어떻게 피하나: 세 가지 선택지가 있다.

  • 쿠키: 토큰을 쿠키에 담고 new EventSource(url, { withCredentials: true }). CORS면 서버도 Access-Control-Allow-Credentials: true 필요. 가장 흔한 방식.
  • 쿼리 파라미터: new EventSource('/events?token=...'). 단, 토큰이 액세스 로그·프록시 로그·브라우저 히스토리에 남는다. 권장하지 않음.
  • fetch 기반 polyfill: @microsoft/fetch-event-source 같은 라이브러리는 fetch로 SSE를 구현해 헤더를 자유롭게 붙일 수 있다. 헤더 방식을 고수해야 하면 이걸 쓴다.

쿠키 방식이 로그 노출이 없어 보통 가장 안전하다.


탭 몇 개 열면 페이지가 멈춘다 — HTTP/1.1 도메인당 6연결 제한

왜 생기나: 브라우저는 HTTP/1.1에서 같은 도메인에 최대 6개 TCP 커넥션만 허용한다(Chrome·Firefox). SSE 연결은 끊기지 않고 계속 그 슬롯을 점유한다. 탭을 6개 열면 슬롯이 다 차서, 그 도메인의 다른 요청(이미지, API)이 대기한다.

증상: 탭을 여러 개 띄운 사용자에게서 “이미지가 안 떠요”, “API가 멈춰요”. 탭 하나 닫으면 풀린다.

어떻게 피하나: HTTP/2로 올린다. HTTP/2는 한 TCP 커넥션 위에서 여러 스트림을 다중화하므로 도메인당 6연결 제한이 사라진다.

server:
  http2:
    enabled: true   # TLS 필요

Nginx가 TLS를 종료하고 백엔드와 HTTP/1.1로 통신하는 구성이면, 클라이언트–Nginx 구간만 HTTP/2여도 이 제한은 풀린다. 다중화 구조의 상세는 → SSE 동작 원리에 있다.


끊긴 연결이 메모리에 쌓인다 — 멀티 인스턴스의 좀비 연결

왜 생기나: 클라이언트가 갑자기 죽으면(배터리 방전, 네트워크 전환, NAT 세션 만료) TCP FIN이 안 온다. 서버는 연결이 살아있다고 믿는다. emitter.send()를 시도해 IOException이 나야 비로소 죽은 걸 안다. 인스턴스가 여러 대면 각자 자기 좀비를 쌓는다.

증상: 활성 연결 수가 실제 사용자보다 계속 많고 우상향. 힙이 차고 Full GC가 잦아지다 결국 OOM.

어떻게 피하나: ① heartbeat의 IOException을 잡아 레지스트리에서 즉시 제거하고(위 타임아웃 항목 코드), ② OS TCP keepalive를 줄여 커널이 죽은 소켓을 더 빨리 정리하게 한다.

# /etc/sysctl.conf — idle 60초 후 probe, 10초 간격 3회 실패 시 종료(최대 ~90초)
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 3

대규모에서의 좀비 정리 비용과 강제 재연결 전략은 → SSE 연결 1억건에서 다뤘다.


그 밖에 — 로드밸런서 idle timeout, 압축/청크 인터랙션

  • 로드밸런서 idle timeout: AWS ALB 기본 idle timeout은 60초, NAT Gateway는 350초. 프록시뿐 아니라 경로상의 모든 장비가 각자 타임아웃을 갖는다. heartbeat 주기는 그중 최솟값 기준으로 잡아야 한다. 한 군데만 짧아도 거기서 끊긴다.
  • 응답 압축(gzip)과의 충돌: 프록시·필터에서 text/event-stream을 gzip으로 압축하면, 압축 버퍼가 일정량 쌓일 때까지 출력을 미뤄 버퍼링과 똑같은 지연이 생긴다. SSE 응답은 압축 대상에서 제외한다(Nginx gzip_types에서 빼거나 해당 location에서 gzip off).
  • 청크 인코딩 전제: SSE는 Content-Length 없이 Transfer-Encoding: chunked로 응답을 열어둔다. 중간 장비가 응답 전체를 모아 Content-Length를 다시 매기려 하면(일부 버퍼링 프록시) 스트리밍이 깨진다. 위의 버퍼링 끄기 설정이 이 문제도 함께 막는다.

SSE 함정은 거의 다 **“중간에 낀 장비가 끝나지 않는 HTTP 응답을 어떻게 다루느냐”**로 수렴한다. 버퍼링을 끄고(proxy_buffering off / X-Accel-Buffering: no), idle timeout보다 짧은 heartbeat를 흘리고, 재연결에 지터와 Last-Event-ID를 붙이고, 인증은 헤더가 안 되니 쿠키로 — 이 네 가지가 운영 SSE 장애의 대부분을 막는다.