SSE 연결 실패 트러블슈팅

coldrice99·2025년 5월 18일
0
post-thumbnail

문제 상황

프론트엔드에서 SSE(Server-Sent Events)를 이용해 알림을 구독하려 할 때, 다음과 같은 에러가 발생했다.

EventSource's response has a Content-Type specifying an unsupported type: text/event-stream, text/event-stream. Aborting the connection.

또한 연결이 되자마자 readyState2(closed)로 바뀌며, onerror 콜백이 실행되었다.
HTTP 응답 코드는 200이었기 때문에, 일단 백엔드나 프록시에서 문제가 발생했다고 생각하지는 않았다.


1. "설정은 다 맞췄는데 왜 안 되지?"

처음에는 백엔드 쪽을 의심했다.

  • SseEmitter는 연결 직후 .event("connect").data("connected")를 정상적으로 보내고 있었고,
  • heartbeat도 .comment("heartbeat") 형식으로 주기적으로 전송하고 있었다.
  • Nginx도 proxy_buffering off, chunked_transfer_encoding on, X-Accel-Buffering no 등 기본 설정은 잘 반영되어 있었다.

게다가 curl로 테스트하면 정상적으로 연결이 유지되었고, heartbeat도 잘 수신되었다.

그래서 프론트 문제일 가능성을 의심했지만, 브라우저 콘솔이 알려준 핵심 단서는 바로 이 부분이었다:

Content-Type: text/event-stream, text/event-stream

2. Content-Type 헤더 중복 문제

Content-Type 헤더가 중복되어 내려가고 있었던 것이다.
이는 Nginx에서 수동으로 add_header Content-Type text/event-stream;를 설정한 것이 원인이었다.

하지만 Spring에서도 이미 text/event-stream을 자동으로 내려주고 있었기 때문에,
브라우저는 이를 비정상적인 응답으로 판단하고 SSE 연결을 바로 종료시켰던 것이다.

해결 방법:
/etc/nginx/sites-available/default

# 아래 줄은 제거
add_header Content-Type text/event-stream;

이후 nginx -s reload로 설정을 반영한 뒤 문제는 해결되었다.


3. heartbeat가 있는데도 연결이 끊기는 이유

한 가지 이상한 점은 heartbeat도 주기적으로 보내고 있었는데,
브라우저에서는 연결이 유지되지 않았다는 점이다.

그 이유는 heartbeat를 .comment("heartbeat")로 보내고 있었기 때문이다.
이 방식은 EventSource 내부에서 keep-alive 용도로 사용되긴 하지만,
클라이언트에서 직접 수신하는 이벤트로는 인식되지 않는다.

하지만 프론트에서는 다음과 같이 addEventListener("heartbeat")로 수신을 시도하고 있었다.

eventSource.addEventListener("heartbeat", (e) => {
  console.log("💓 heartbeat 수신:", e.data);
});

따라서 heartbeat도 아래와 같이 수정해주었다.

emitter.send(
  SseEmitter.event()
    .name("heartbeat")
    .data("ping")
);

결과 요약

항목수정 전수정 후
Content-Typetext/event-stream, text/event-stream (중복)Spring이 설정한 헤더만 유지
heartbeat.comment("heartbeat").name("heartbeat").data("ping")
연결 상태연결 직후 종료됨 (readyState: 2)안정적으로 유지됨 (readyState: 1)

회고

이번 문제는 언뜻 보기엔 별 문제가 없어 보였지만,
브라우저가 SSE를 다루는 방식은 매우 엄격하며 사소한 설정 차이도 치명적인 결과를 초래할 수 있다는 점을 배웠다.

특히 다음 두 가지는 기억해두어야 할 교훈이다:

  • 헤더 중복은 예상보다 더 쉽게 발생하고, 브라우저가 연결을 아예 차단할 수 있다.
  • heartbeat는 브라우저가 인식 가능한 방식(event + data)으로 명시적으로 보내야 한다.

처음엔 명확한 로그도 없이 애매하게 끊기고, curl에서는 잘 되던 탓에
문제를 좁히는 데 시간이 오래 걸렸지만,
오히려 그 과정을 통해 SSE의 구조와 동작 원리를 더 깊이 이해할 수 있었다.

profile
서두르지 않으나 쉬지 않고

0개의 댓글