SSE 최종 에러 해결.. - ResponseBodyEmitter$DataWithMediaType , 그리고 Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR
sse 에러 해결 (AsyncRequestNotUsableException)
ERROR 2798 --- [nio-5000-exec-5] c.j.j.c.e.h.GlobalExceptionHandler : RuntimeException :ResponseBodyEmitter has already completed with error: org.springframework.web.context.request.async.AsyncRequestNotUsableException: Response not usable after response e
hyunco.tistory.com
이전 sse 에러 해결 글에서 크리티컬한 에러는 해결했지만, 또 다른 에러가 발생했다..!
서버 로그에 뜬 에러를 먼저 보여주자면 아래와 같다.
Dec 23 19:05:01 ip-172-31-0-18 springapp[2801]: 2024-12-23T19:05:01.842+09:00 ERROR 2801 --- [io-5000-exec-25] c.j.j.c.e.h.GlobalExceptionHandler : RuntimeException
: Failed to send [org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter$DataWithMediaType@2eeb358d, org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter$DataWithMediaType@4610af44, org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter$DataWithMediaType@7b395c63]
게시글에 좋아요를 누르거나, 취소, 혹은 댓글을 생성하려고 할 때 전부 위와 같은 에러가 뜨면서 실패했다.
처음에는 트랜잭션을 먼저 분리하고 에러 해결보다 정상적인 커뮤니티 활동부터 가능하게 할까 했지만,
새벽 안에만 끝내 보고 안되면 아침에 트랜잭션 분리 후에 고치기로 했다.
아무튼, 서칭을 해 본 결과 여러 가지 원인을 예측할 수 있었지만 우선 아래의 개선 방법을 시도해 봤다.
ResponseBodyEmitter$DataWithMediaType 관련 오류가 발생하므로, 데이터 전송 로직에 문제가 있을 가능성이 있다고 판단했다.
원인 : emitter.send() 호출 시 데이터 포맷
기존에 sendToClient 메서드에서 data를 전송할 때 Object 타입으로 받아들이고 있었다.
SSE 클라이언트는 일반적으로 JSON 포맷을 선호하며, 전송 데이터가 올바르게 직렬화되지 않으면 오류가 발생할 수 있다고 한다...
기존 코드
private void sendToClient(SseEmitter emitter, String emitterId, Object data) {
try {
emitter.send(SseEmitter.event()
.id(emitterId)
.name("sse")
.data(data)
);
} catch (IOException exception) {
emitterRepository.deleteById(emitterId);
emitter.completeWithError(exception);
}
}
수정 방안 : Object로 받던 data를 JSON으로 변환하여 전송했다.
private void sendToClient(SseEmitter emitter, String emitterId, Object data) {
try {
String jsonData = new ObjectMapper().writeValueAsString(data); // JSON 변환
emitter.send(SseEmitter.event()
.id(emitterId)
.name("sse")
.data(jsonData)
);
} catch (IOException exception) {
emitterRepository.deleteById(emitterId);
emitter.completeWithError(exception);
}
}
이렇게 첫 번째 에러는 해결했다.
하지만 기존부터 뜨던 에러가 하나 있었는데..
Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR
이 에러가 랜덤한 시간마다 클라이언트 페이지 console에 떠있는 걸 발견했었다.
문제는 서버 로그에는 에러가 뜨지를 않아서 더 원인을 찾기 힘들었다.
3주간의 sse 에러 해결의 마지막 서칭이 됐다.
처음에는 Nginx 설정 문제인가 해서 아주 여러 번 바꿨지만 소용없었다.
현재 잘 작동하는 설정 파일을 혹시 모르니 올려놓겠다.,
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 1800;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
그 후로 다들 클라이언트의 CORS 문제를 언급하던데,
나는 이미 app 코드 내에서 corsConfig로 도메인을 열어뒀었다.
이 외에도 클라이언트 브라우저/네트워크 환경 문제, 데이터 형식 문제, 연결 중단 시 복구 코드, HTTP/2 및 클라이언트 호환성 문제
등등 안 해본 게 없었다.
그러다 마지막으로 시도해 봤던 게 해결이 됐다.
클라이언트-서버 연결 해제
- 원인: SSE 연결 유지 중 네트워크 문제나 클라이언트 환경에서 연결이 중단.
- 확인 및 해결 : Ping 또는 Keep-Alive:
- 서버에서 주기적으로 더미 이벤트를 보내 연결을 유지:
-
private void sendPing(SseEmitter emitter, String emitterId) {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event()
.id(emitterId)
.name("ping")
.data("ping"));
} catch (IOException e) {
emitter.completeWithError(e);
scheduler.shutdown();
}
}, 0, 30, TimeUnit.SECONDS); // 30초 간격으로 ping 전송
}
위 메소드를 맨 처음 subscribe를 설정할 때 실행되도록 구현했다.
설명
- sendPing 메소드:
- ScheduledExecutorService를 사용해 일정 간격(예: 30초)으로 ping 이벤트를 전송합니다.
- 클라이언트와 서버의 연결이 끊어졌을 경우 IOException을 발생시켜 emitter.completeWithError로 연결을 종료합니다.
- subscribe 메소드와 통합:
- SseEmitter를 생성한 직후 sendPing을 호출하여 ping 이벤트가 주기적으로 전송되도록 설정합니다.
다른 분들은 Nginx 환경 설정이나 헤더 설정으로 keep-alive를 충분히 구현하시던데..
나는 아무리 설정을 바꿔봐도 해결이 안 됐었다.
그러나 위와 같이 일정 시간마다 더미 데이터를 전송함으로써 에러를 해결할 수 있었다..
에러 해결!
*) 나중에 알게 된 사실인데, 현재 우리는 ALB를 사용하고 있었는데 기본적으로 ALB는 timeout이 60초라서 끊기는 거라고 한다..
nginx가 끊는 게 아니라 ALB가 끊는 거라서 nginx에서 keep alive를 해도 안되는 거였다?!