1114 단어
6 분
SSE 구현 중 마주친 3가지 오류와 해결 과정
실시간 알림 기능을 위해 SSE를 도입하던 중 예상치 못한 오류를 마주하였습니다.
이것들은 코드 문제가 아닌 Spring Security와 비동기 통신의 특성을 이해해야 해결할 수 있는 문제였습니다.
Access Denied
우선 SSE 기능을 구현하고 테스트를 진행하는데 /api/subscribe를 호출하자말자 심각한 오류가 발생했습니다.
org.springframework.security.authorization.AuthorizationDeniedException: Access Deniedjakarta.servlet.ServletException: Unable to handle the Spring Security Exception because the response is already committed.로그로 일단 권한 부족이랑 Spring Security 부분에서 문제가 생겼다는 사실은 바로 알았습니다.
그렇지만, 왜 터졌는지 찾아야합니다.
원인 분석
처음에는 로그인 된 사용자니까 정상적으로 되야하는 게 정상아닌가? 하고 생각했습니다.
웹 페이지 새로고침을 반복하니, 처음에는 연결에 문제 없다가 다음 재연결 시 터지는 걸 확인했습니다.
어째서?
- 클라이언트가 처음 접근(
/api/subscribe) 시 토큰 검사를 진행합니다 - 이 당시에는 토큰 검사가 정상 수행되서 인증된 사용자로 통과합니다.
- 이후 비동기로 연결된 상태 활동이 이뤄지는 데 이 부근에서 오류가 발생합니다.
- 분명 인증 실패 이므로 이 부근에서 토큰 검사를 진행하는 것 같습니다
비동기 문제?
-
이 과정에서 발생할 만한 부분을 생각한다면 아마도 비동기 영역일 거 같습니다. 정확히 해당 구간과 로그만 뜨는걸 봐선 딱히 떠오르는 게 없었습니다.
-
그렇다면 비동기 통신은 토큰 검사를 하지 않도록 해야합니다
-
사전에 이미 토큰 검사를 했으니 이후부터는 통과시켜줘야 이 문제가 해결될 거 같았습니다
http .authorizeHttpRequests(auth -> auth .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() .requestMatchers(해결
- SecurityConfig에서 DispatcherType.ASYNC 요청은 보안을 생략하도록 설정했습니다
- Spring 문서 참조
타임아웃(Timeout) 500 에러
상단 Access Denied 오류를 해결하니 다음 문제가 발생했습니다
org.springframework.web.context.request.async.AsyncRequestTimeoutException...Resolved [org.springframework.web.context.request.async.AsyncRequestTimeoutException]원인 분석
- 이번에는 TimeoutException이 뜨는 것을 보니 연결 끊어짐 상태에 대한 문제인 거 같습니다.
- 해당 문제는 SSE 연결은 영구적이지 않지만, 서버 에러 500을 반환하고 있었습니다.
- 이는 스트림이 열려있는 상태에서 에러 응답을 시도하니 문제가 발생한 거 같았습니다.
@ExceptionHandler(AsyncRequestTimeoutException.class) public void handleAsyncRequestTimeoutException(AsyncRequestTimeoutException e) { log.debug("SSE 연결 시간 만료"); }해결
- 타임아웃은 에러가 아닌 만료이므로, 핸들러에 로그만 남기고 아무 응답을 하지 않도록 처리했습니다
Connection Abort
이제는 사용자가 창을 닫을 때마다 서버 로그에 에러가 발생했습니다.
org.springframework.web.context.request.async.AsyncRequestNotUsableExceptionCaused by: java.io.IOException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다원인 분석
- 이번에는 명확하게 메시지가 뜨는 것을 보아, 클라이언트 이탈이 발생한 거 같습니다.
- SSE은 단방향이지만, 어쨌든 WebSocket 처럼 연결되어있을테니까요
@Overridepublic void onMessage(Message message, byte[] pattern) { // ... SseEmitter emitter = sseEmitterRepository.get(userId);
if (emitter != null) { try { emitter.send(SseEmitter.event() .name("notification") .data(notificationContent)); } catch (IOException e) { sseEmitterRepository.deleteById(userId); } }}바로 문제의 원인으로 갔지만 처음에는 이해할 수 없었습니다.
- 예외 처리를 했었으니까요
로그를 조금 더 자세히 볼 필요가 있습니다
AsyncRequestNotUsableException는 IOException으로 처리가 되는 것인가?
저는 이 에러를 모르니 ctrl + 클릭으로 봤더니 RuntimeException 이였습니다
해당 문제는 IOException으로 잡을 수 없는 문제였습니다..
해결
@Componentpublic class NotificationRedisSubscriber implements MessageListener { // ... @Override public void onMessage(Message message, byte[] pattern) { SseEmitter emitter = sseEmitterRepository.get(userId);
if (emitter != null) { try { emitter.send(SseEmitter.event() .name("notification") .data(notificationContent)); } catch (Exception e) { sseEmitterRepository.deleteById(userId); } } }}- 예외 범위를 Exception으로 확장했습니다.
- 해당 상황에선 어떻게해도 기존 연결로 메시지를 전송할 수 없을테니까요
- 기존 연결은 버리고 새 연결을 생성하는 편이 맞는 거 같습니다
SSE 구현 중 마주친 3가지 오류와 해결 과정
https://devlog.jpstudy.org/posts/2025/spring/trouble/1/