이 화면은 GMG 이 프로젝트 로그 모니터링 화면인데 SSE를 도입한 이후 저런 로그가 자주 뜨기 시작했다. 처음에는 이 로그를 봤을 때 GPT한테 물어봤는데 크게 문제 없는 거라고 하길래 그냥 아 그렇구나 하고 넘어갔었다. 그러다 문득 SSE를 도입을 하긴 했는데 내가 SSE를 잘 알고 도입한게 아니구나 + 저 로그가 왜 생기는지 궁금했다 ServletOutputStream failed to flush: java.io.IOException: Broken pipe, exception=AsyncRequestNotUsableException
1. SSE는 HTTP위에서 어떻게 동작하는가?
SSE는 HTTP위에서 어떻게 동작하는가? & HTTP인데 연결이 유지되는가?
- SSE는 종료될때까지 하나의 HTTP Connection을 유지하면 HTTP 응답을 끝나지 않은 상태로 유지시킴으로써 단일 커넥션을 통해서 Body를 스트리밍하는 방식이다.
- 서버는 Response header를 한번만 전송한 후 이후 body를 통해 스트리밍 데이터를 지속적으로 전달한다
SSE는 “HTTP 요청-응답 모델을 깨는 기술”이 아니라 HTTP 응답을 끝내지 않고 계속 보내는 방식의 스트리밍 응답이다. (HTTP streaming)
- 기존 HTTP의 경우는 HTTP (body)의 데이터를 다 보내면 응답이 끝나는 것이었음
- 반면 SSE는 응답을 끝내지 않음
기존 HTTP client -> request server -> response header server -> resposen body connection close(or idle)
SSE clinet -> reqeust server -> response header server -> event data server -> event data server -> event data …
브라우저는 connection open 상태로 계속 데이터를 받는다
1 | |
1. Content-Type
- text/event-stream : 브라우저에게 이 응답은 HTTP body가 아니라 event stream이다
- 그래서 브라우저는 EventSource API로 읽는다
2. body format
1 | |
빈 줄이 이벤트 구분자
- HTTP 인데 연결이 왜 유지될까?
- SSE가 연결을 유지하는 특별한 기술이 있는게 아님 HTTP 자체가 원래 연결을 유지할 수 있음
- TCP connection
- 여기서 TCP 잠깐 복습
- 커넥션 후에 데이터를 보내야함
- TCP 3-way handshake
- A –>connection request —> B
- A <—ack + connection request—-B
- A —-ack—–>B
- 이제 data 보낼 수 있음
-
HTTP는 TCP 위에서 동작함 -> request했을 때 response가 끝나지 않으면 TCP connection도 계속 유지됨 => 이 원리
- body를 어떻게 계속 보내냐?
- chunked transfer encoding
- 일반 HTTP는 Content-Length:100 이렇게 길이를 미리 알려줌
- 하지만 SSE는 얼마나 보낼지 모름
- 그래서 서버가 Transfer-Encoding: chunked 이렇게 보냄
- chunk –> chunk –> chunk 이렇게 계속 body를 보낼 수 있음(streaming)
1 | |
Polling vs SSE vs WebSocket
- Polling
- 클라이언트가 일정 주기로 계속 요청하는것
- 그냥 계속 HTTP 요청 새로 생성함 -> connection이 새로 생김
- 있나요? -> 없어요, 있나요? -> 네
- SSE
- 한번 연결하고 계속 서버가 보내자
- connection을 하나 계속 유지
- 서버가 client에게만 데이터를 보내는 단방향
- event-stream 포맷
- 장점 : polling 보다 네트워크 효율적, HTTP 기반이라 proxy 친화적, 구현 간단
- WebSocket
- HTTP를 업그레이드 해서 새로운 프로토콜을 만듬 이후부터는 websocket connetion으로 양방향 통신함
- TCP connection 유지 + 양방향 + HTTP 아님
EventSource
- 브라우저가 제공하는 SSE 전용 클라이언트 구현체
EventSource는 text/event-stream HTTP 연결을 열고, 스트림을 파싱해서 JavaScript 이벤트로 변환하고, 연결이 끊어지면 자동으로 재연결한다.
- 브라우저가 SSE로 인식하게 해줌 그래서 event-stream 헤더를 보고 body를 sse 방식으로 파싱해야한다라고 판단함
`text
data: hello
data: world
event: notification data: update
-> EventSource 내부에서 line 기반 파서가 동작 buffer에 데이터 수신 ↓ newline 기준 파싱 ↓ event field / data field 해석 ↓ 빈 줄(event delimiter) 만나면 이벤트 생성
1 | |
- HTTP client + SSE parser + reconnection manager 역할을 하는 브라우저 컴포넌트임
2. 네트워크 레벨에서 SSE
- SSE는 기본적인 HTTP와 다르게 서버가 앞으로 얼마나 많은 이벤트를 보낼지 미리 모른다.
- 기존 HTTP는 Content-Length 헤더를 통해 아 이정도 길이 응답 받으면 끝나는구나 알 수 있음
- 그래서 SSE는 Content-Length를 미리 정할 수 없으니 Transfer-Encoding: chunked를 사용함
- 이 헤더가 붙으면 body를 한 번에 다 보내지 않고 쪼개서 chunk 단위로 순차 전송할 수 있음
- 결국 SSE는 HTTP 요청 하나에 대해 서버가 응답 완료를 미루고 있는 상태
- body 일부 보냄 (아직 안끝남 ㅋ) 또 body 보냄 (아직도 안끝남 ㅋ)
- 이런 일을 Spring에서 SseEmitter가 하는 일
- 응답 헤더를 열어두고
- 나중에 send()가 호출할 때마다 응답 스트림에 data:….\n\n계속 써 넣는다
- request에 대한 응답이 없으면 TCP connection을 끊을 수 없음
- 결국 브라우저도 연결을 열어두고 서버도 열어두고 중간 프록시도 그 연결을 계속 관리해야함
여기서 문제가 생김 TCP연력을 오래 유지하면 왜 문제가 생길까?
- 예를 들어 사용자 5,000명이 각자 SSE 연결 하나씩 잡고 있으면?
- 서버는 소켓 5000개 유지
- 프록시도 5000개 유지
- 로드밸런서도 상태 5000개 유지 -> 즉 지속 연결이 시스템 자원을 먹는다 -> 시스템들은 너무 오래 유지되면 timeout 이슈가 생긴다 그냥 끊어버림
프록시(CDN, Nginx, API gateway)도 그래서 일정 시간이 지나면 응답 body에 변화가 없으면 연결을 끊어버릴 수 잇음
- 그래서 SSE에서 주기적으로 heartbeat를 보내기를 권장함
로드밸런서 도 비슷함
- 클라이언트 요청을 서버로 분배를 하는데 오래 열린 연결을 무작정 좋아하지 않음
- idle timeout
- 일정 시간 동안 트래픽이 없으면 연결을 닫을 수 있음
- SSE 연결에서 이벤트가 뜸하면 클라이언트나 서버가 유지중이라고 생각을 해도 로드밸런서가 중간에 끊음
- 대표적으로 Broken pip, Connection reset by peer, Spring 쪽에서는 AsyncRequestNotUsableException
- long-lived connection이 분산에 불리함
- 짧은 요청은 로드밸런서가 골고루 분산시키기 쉬움 그런데 SSE는 한번 연결되면 그 연결이 오래 감
- idle timeout
클라우드 플레어
- SSE처럼 오래 열려있는 응답은 설정이나 플랜, 동작 모드에서 따라 문제가 생길 수 있음
- 중요한 건 cloudflare가 SSE를 항상 완전히 편하게 다뤄주는건 아님
- 응답 buffering
- idle timeout
- 특정 구간에서 연결 재설정
- 프록시 계층에서 장시간 스트림 유지 제약
- 특히 buffering 걸리면 SSE가 즉시 전달 안되고 뭉쳐서 갈 수 있음(실시간 깨짐)
- 그래서 프록시가 streaming response를 그대로 흘려보내는지
- buffering이 꺼져있는지
- idel timeout이 heartbeat주기보다 긴지
client disconnect
- 가장 흔하고, 정상적인 케이스임
- 클라이언트가 여러 이유로 연결을 끊음 -> 근데 이 끊어졌다는 사실을 즉시 알지 못할 수 있음 -> TCP는 반대편이 사라졌다는 걸 보통 다음 write 시점에야 알게 되는 경우가 많기 때문
즉
- 클라이언트는 이미 떠남
- 서버는 아직 연결 살아 있다고 생각
- 서버가 다음 이벤트를 send()
- flush시도
- 소켓이 이미 닫혀 있음
- broken pip, IOExcepiton, AsyncReqeustNotUsableExcpetion -> 이미 끝난 연결에 쓰려고 했기 때문에 발생
heartbeat가 왜 필요한가
- heartbeat의 역할
- 중간 장비에게 연결이 살아 있음을 보여줌
- 서버가 죽은 연결을 빨리 감지
- 주기적으로 write해보면 끊긴 연결을 빠르게 정리 가능
- 클라이언트 측도 상태를 덜 애매하게 유지 => 실제 데이터가 자주 오지 않는 서비스일수록 heartbeat가 중요함
3. SSE lifecycle
- client connect
- 브라우저에서 SSE 연결 시작하면
- GET + Accept : text/event-stream
- server create emitter
- 응답을 받으면 SseEmitter를 생성하는 순간 lifecycle 시작됨
- HTTP response header가 준비됨
- Content-Type: text/event-stream
- Servlet async 모드 시작 (비동기)
- Spring MVC SSE는 사실 Servlet async processing 기반 request thread ↓ emitter 생성 ↓ response open ↓ thread 반환
- 요청을 처리하던 톰캣 스레드는 반환된다
- 그래서 SSE는 요청 하나가 연결을 유지하지만 서버 thread를 점유하지 않음
- server push events(send)
- 이제 서버는 이벤트를 보낼 수 있음
emmiter.send("hello");- http body에는
data:hello이런식으로 - 빈 줄이 구분자이기 때문에 여러번 보내면 빈줄로 구분되어 스트림이 이어짐
- HTTP response는 아직 끝나지 않았고 TCP connection은 계속 열린 상태임
- connection idle
- 이벤트가 없는 시간에는 connection open data 없음 -> 이렇게 연결 유지
- 문제는 이런 idle 상태가 길어지면 중간 네트워크 장비가 연결을 끊을 수 있음
- heartbeat
- 그래서 연결이 살아 있음을 증명하는 신호를 보냄
- proxy timeout 방지, 서버가 끊긴 연결을 빨리 감지, 브라우저 connection 유지
- client disconnect
- server detext disconnect
- 서버는 다음 write 시도 때 연결이 끊어진 걸 아는 경우가 많음
- emitter cleanup
- 연결이 끝나면 emitter를 정리해야함
emitter.onCompletion(..)emitter.onTimeout(..)emitter.onError(..)- 이걸 안하면 메모리 누수가 생김 왜냐하면 emitter는 보통 map에 저장해두기 때문
- HTTP response header가 준비됨
- 응답을 받으면 SseEmitter를 생성하는 순간 lifecycle 시작됨
결론
SSE 자세히 공부를 해보니까 사실 엄청 복잡한 흐름이 있는게 아니었다. 그냥 HTTP 연결을 계속 유지하는 거였다는거였다. 그래서 이해하기에 그렇게 어렵지 않았다
정리하면 원인은
1 | |
- heartbeat로 emitter.send() 하는데(ServletOutputStream에 데이터 쓰고 flush) 만약 Client연결이 끊어져 있다면 emitter.send()가 실패를 하여(ServletOutputStream에 데이터 쓰고 flush 이 단계 실패) IOException으로 잡아
log.warn("Heartbeat 전송 실패, emitter 제거: {}", e.getMessage());이런 로그가 추가 되는 것이고
- 잡았는데도
AsyncReqeustNotUsableException예러가 나오는 이유는 Tomcat/Servlet 컨테이너는 응답 스트림 write 실패를 비동기 요청의 오류 상태로 인식할 수 있고, 그 결과 async reqeust는 더 이상 정상적으로 사용할 수 없는 상태가 된다.- 스케줄링이 작업이 끝나면 톰캣은 AsyncListener.onError() 호출하도록 함
- 이후 모든 콜백이 종료되면 Spring MVC/Servlet async 내부에서 비동기 요청을 정리하는 과정에서 이미 깨진 async response를 다시 다루게 되면
AsyncReqeustNotUsableException을 발생 시킴