프론트엔드에서 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의 구조와 동작 원리를 더 깊이 이해할 수 있었다.