
프론트엔드에서 SSE(Server-Sent Events)를 이용해 알림을 구독하려 할 때, 다음과 같은 에러가 발생했다.
EventSource's response has a Content-Type specifying an unsupported type: text/event-stream, text/event-stream. Aborting the connection.
또한 연결이 되자마자 readyState는 2(closed)로 바뀌며, onerror 콜백이 실행되었다.
HTTP 응답 코드는 200이었기 때문에, 일단 백엔드나 프록시에서 문제가 발생했다고 생각하지는 않았다.
처음에는 백엔드 쪽을 의심했다.
SseEmitter는 연결 직후 .event("connect").data("connected")를 정상적으로 보내고 있었고,.comment("heartbeat") 형식으로 주기적으로 전송하고 있었다.proxy_buffering off, chunked_transfer_encoding on, X-Accel-Buffering no 등 기본 설정은 잘 반영되어 있었다.게다가 curl로 테스트하면 정상적으로 연결이 유지되었고, heartbeat도 잘 수신되었다.

그래서 프론트 문제일 가능성을 의심했지만, 브라우저 콘솔이 알려준 핵심 단서는 바로 이 부분이었다:
Content-Type: text/event-stream, text/event-stream
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로 설정을 반영한 뒤 문제는 해결되었다.
한 가지 이상한 점은 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-Type | text/event-stream, text/event-stream (중복) | Spring이 설정한 헤더만 유지 |
| heartbeat | .comment("heartbeat") | .name("heartbeat").data("ping") |
| 연결 상태 | 연결 직후 종료됨 (readyState: 2) | 안정적으로 유지됨 (readyState: 1) |
이번 문제는 언뜻 보기엔 별 문제가 없어 보였지만,
브라우저가 SSE를 다루는 방식은 매우 엄격하며 사소한 설정 차이도 치명적인 결과를 초래할 수 있다는 점을 배웠다.
특히 다음 두 가지는 기억해두어야 할 교훈이다:
event + data)으로 명시적으로 보내야 한다.처음엔 명확한 로그도 없이 애매하게 끊기고, curl에서는 잘 되던 탓에
문제를 좁히는 데 시간이 오래 걸렸지만,
오히려 그 과정을 통해 SSE의 구조와 동작 원리를 더 깊이 이해할 수 있었다.