Spring Security + SSE 사용 중, Access Denied 예외가 발생하는 이슈 해결 2

후투티·2025년 6월 23일

한동안 Access Denied가 나오지 않고 잘 굴러가는 줄 알았다.
그런데 또 Access Denied가 나오는 로그를 발견,,ㅠ

[   scheduling-1] c.d.d.notification.NotificationService   : Ping 전송 실패 : userId = 13, error = ServletOutputStream failed to flush: java.io.IOException: Broken pipe
[nio-8080-exec-9] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] threw exception
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
 
[nio-8080-exec-9] o.a.c.c.C.[Tomcat].[localhost]           : Exception Processing [ErrorPage[errorCode=0, location=/error]]
jakarta.servlet.ServletException: Unable to handle the Spring Security Exception because the response is already committed.
 Caused by: org.springframework.security.authorization.AuthorizationDeniedException: Access Denied

지난번의 필터로 해결될 문제가 아니었나 싶어 심란한 마음에 앞의 상황 맥락과 로그들을 살펴봤다.

이 로그가 나오는 것의 공통점은 사용자가 로그아웃을 하거나 탈퇴를 했을 때였다.

1. Broken pipe (IOException):

  • 사용자가 로그아웃/회원퇴함에 따라 연결은 끊겼는데 서버는 그걸 인식하지 못하고 데이터를 계속 보내려다가 예외가 터진 것이다.

2. AuthorizationDeniedException: Access Denied:

  • 나는 로그아웃을 하면 토큰들을 삭제하거나 블랙리스트로 설정했기 때문에 더이상 인증이 되지 않도록 구현했다. 그런데 계속 30초마다 ping으로 SSE에 데이터를 보내려고 하다보니 인증 실패 예외를 터트린 것이다.

그러면 로그아웃, 회원탈퇴를 했을 때 연결이 제대로 끊어지도록 뭔가 조치를 취해야겠지!

해결한 방법

NotificationService 내에


  private void removeEmitter(Long userId, SseEmitter emitter, String reason) {
    emitters.compute(userId, (key, currentEmitter) -> {
      if (currentEmitter == emitter) {
        try {
          emitter.complete();
        } catch (Exception e) {
          log.warn("Emitter 종료 중 예외 발생: userId = {}, error = {}", userId, e.getMessage());
        }
        log.debug("Emitter 제거 완료: userId = {}, 이유 = {}", userId, reason);
        return null;
      }
      return currentEmitter;
    });
  }

이렇게 제거 메서드를 만들어두고, 이 메서드를 사용하여 연결을 끊는 메서드를 만들었다.

public void disconnectEmitter(Long userId) {
    SseEmitter emitter = emitters.remove(userId);
    if (emitter != null) {
      removeEmitter(userId, emitter, "로그아웃/탈퇴에 의한 SSE 연결 종료");
    }
  }

작성하다보니 뭔가.... 부족한 게 느껴지는데 좀 더 보완해야겠다ㅠ
뭔가 명확하질 않네...
아무튼 흐름은 깨달았고, 제대로 돌아가긴 한다.

그리고 로그아웃을 한 후, 회원탈퇴로 사용자를 완전히 delete 하기 전에 disconnectEmitter()를 넣어주면 되겠다!

    notificationService.disconnectEmitter(user.getId());
    log.info("SSE 연결 종료(로그아웃) : userId = {}", user.getId());


    log.info("로그아웃 성공");
    notificationService.disconnectEmitter(user.getId());
    log.info("SSE 연결 종료(회원탈퇴) : userId = {}", user.getId());
    
    // 사용자 삭제
    userRepository.delete(user);
    log.info("회원탈퇴 완료");

(원래는 UserService 내에 로그인, 로그아웃, 회원탈퇴 등의 인증 관련 로직을 함께 관리하고 있었는데,
NotificationService에서 UserService를 참조하고, 이번 작업으로 인해 UserService가 다시 NotificationService를 참조하게 되어 순환 참조 문제가 발생했다.
이를 해결하기 위해 인증 관련 기능은 AuthService로 따로 분리했다.)

그러면 이제 Access Denied 예외는 더이상 터지지 않게 된다!

profile
모르는 건 모른다고 하는 사람

0개의 댓글