기존에 만들던 SSE 관련 프로젝트에서 잠깐 언급되었던 장애 내용 중 하나에 대해 확인하다가 알게 된 사실을 기록한다.
[BE] SSE 재연결과 관련된 시간
이것저것 테스트하던 중, 가장 기본적인... 재연결 로직이 정상적으로 동작하지 않는 것을 확인했다. SSE 연결은 클라이언트에서 요청을 보내고 (빨간색 화살표), 서버는 해당 연결을 OS에 반환하
gooduck.net
시간문제와는 조금 다른 내용이지만, 조금 더 깊이 스프링의 내부 동작을 이해할 수 있는 계기가 되었던 것 같다.
jakarta.servlet (Jakarta Servlet API documentation)
The jakarta.servlet package contains a number of classes and interfaces that describe and define the contracts between a servlet class and the runtime environment provided for an instance of such a class by a conforming servlet container. For versions prio
jakarta.ee
SseEmitter에 대한 스펙문서는...
SseEmitter (Spring Framework 6.2.8 API)
Invoked after the response is updated with the status code and headers, if the ResponseBodyEmitter is wrapped in a ResponseEntity, but before the response is committed, i.e.
docs.spring.io
동시에 FE에서 SSE에 대한 이해도 넓힐 수 있는 계기가 되었다.
HTML Standard
This section is non-normative. To enable servers to push data to web pages over HTTP or using dedicated server-push protocols, this specification introduces the EventSource interface. Using this API consists of creating an EventSource object and registerin
html.spec.whatwg.org
1. SseEmitter란 ResponseBodyEmitter를 SSE를 보내기 위해 확장시킨 클래스이다.
- ResponseBodyEmitter : 컨트롤러 메서드는 응답에 하나 이상을 적는 비동기적인 요청 과정을 반환할 수 있다.
** DefferedResult : 하나의 응답을 비동기적으로 보내는 클래스
2. SseEmitter는 Servlet 3.0 이후 도입된 비동기 요청 관리 인터페이스인 AsyncContext를 따라 설계되었다.
- 컨트롤러 메서드가 SseEmitter를 반환하는 시점에, 내부적으로 request.startAsync()가 호출된다.
위의 내용과 같이 AsyncContext는 타임아웃 시 객체의 상태를 관리할 수 있는 onTimeout을 실행시킨다.
객체에 대한 관리를 위해, 명세에 따라 에러 처리를 시도하는 내부 요청(DispatcherType.ASYNC)을 보내게 된다.
이때, errorpage에 대한 내용과 함께 에러가 발생하는 원인이 된 것이다.
3. SpringSecurity의 AuthorizationFilter 통과
Filters :: Spring Framework
Browsers can submit form data only through HTTP GET or HTTP POST but non-browser clients can also use HTTP PUT, PATCH, and DELETE. The Servlet API requires ServletRequest.getParameter*() methods to support form field access only for HTTP POST. The spring-w
docs.spring.io
즉, Filter는 내부적으로 Spring이 생성한 ASYNC나 ERROR에 해당하는 DispatcherType에 대해서도 검증을 수행한다.
문제는 SpringSecurity의 가장 끝단에 위치한 AuthorizationFilter의 통과 여부이다.
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/subscribe").permitAll() // 'REQUEST' 타입일 때만 주로 고려됨
.anyRequest().authenticated() // <-- 모든 나머지 요청은 인증을 요구함!
)
나는 Stateless한 인증방식을 사용하는데 이를 JWTFilter에서 수행한다.
하지만 스프링이 생성한 ASNYC 요청은 token이 헤더에 없으므로 filter는 통과하되 unauthenticated 한 상태이다.
즉, 위의 규칙을 적용하면 모든 ASYNC 요청은 인증을 획득하지 못하므로 차단되는 문제가 발생하게 된다.
이로 인해, 403 Forbidden 오류가 발생해버린다.
4. 해결방법
.authorizeHttpRequests(authorize -> authorize
// 1. 가장 먼저 이 규칙을 추가
.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
// 2. 그 다음 기존 규칙들
.requestMatchers("/login", "/subscribe").permitAll()
.anyRequest().authenticated()
)
ASYNC type으로 수행되는 요청에 대해서는 모두 허용해야 한다.
위의 보안 검사를 해제해도 되는 이유
1) ASYNC 타입의 요청은 사용자가 생성하는 것이 아니라 Tomcat과 Spring이 내부적으로 발생시키는 신호일뿐이다.
2) 그 외의 security 인증 필터는 모두 통과한다.
3) 이 작업의 특성은 '인증되지 않은 내부 정리 작업'으로 올바르게 의도된 작업이다.
만약 찜찜하다면?
SseEmitter 객체를 생성할 때, SecurityContext가 인증에 사용할 authentication 객체를 함께 보관한다.
onComplete이 실행되는 스레드에서 해당 객체를 SecurityContext에 포함할 방법을 고려해야 한다.
- 객체가 사라질 때, 사용자와 관련하여 작업이 필요한 경우가 아니라면 고려하지 않아도 될 것 같다.
이 장애를 공부하며 JVM 내부에서 SSE의 생명주기에 대해 고민하는 계기도 되었다.
1. 컨트롤러의 메서드 실행(요청 스레드)
- 스레드의 스택 영역에 지역변수와 참조 정보가 생성된다. jvm 힙 영역에 SSE 객체가 생성된다.
- ConcurrentHashMap에 SSE 객체에 대한 참조 정보가 저장된다.
2. 컨트롤러 메서드 종료
- 요청 스레드의 스택에서 작업공간이 사라지게 된다.
3. 객체의 생존
- ConcurrentHashMap에 SSE 객체 참조가 존재하기 때문에 소멸 시나리오가 없다면 GC의 대상이 되지 않는다.
4. 객체의 소멸
- ConcurrentHashMap에서 참조 정보를 삭제한다. -> GC의 대상이 되어 소멸하게 된다.
- Timeout 시간이 도래한다.
- complete() 함수를 실행시킨다.
5. onCompletion 콜백 실행
- ConcurrentHashMap에서 참조 정보를 삭제한다.
관련된 공식 문서들을 살펴보며 흥미로운 주제들을 많이 살펴볼 수 있었다.
추가로 개발자는 역시 혼자 개발할 수 없고, 발전지향적인 팀에서 발전할 수 있다는 생각이 들었다.
이런 내용들은 팀에서 한 번 공유되면 잊지 않고 사용할 수 있는 내용들일 텐데...... 혼자서 알아보려니 시간이 정말 오래 걸린다.
'공부' 카테고리의 다른 글
[매크로] 게임 매크로 만들어보기 (0) | 2025.06.20 |
---|---|
[이미지변환] HEIC To JPG (1) | 2025.06.20 |
[CI/CD] Gitea + Act_Runner (1) | 2025.06.13 |
[GCP] Nginx SSL 자동갱신 (1) | 2025.06.13 |
[REST] API 요청, 응답 (2) | 2025.06.12 |