트러블슈팅
SSE와 Spring Security 충돌 해결 (Context 비동기 전파 문제)
chillmyh
2025. 6. 6. 15:58
1. 배경
1.1 시스템 개요
- 프로젝트: SSOM 백엔드 - 실시간 알림 시스템
- 기술 스택: Spring Boot 3.x, Spring Security 6.x, SSE (Server-Sent Events)
- 기능: 실시간 알림 전송을 위한 SSE 기반 push 서비스
1.2 문제 발생 상황
사용자가 알림 구독(/api/alert/subscribe) 엔드포인트에 접근할 때 다음과 같은 예외가 발생
AuthorizationDeniedException: Access Denied
at org.apache.catalina.core.AsyncContextImpl$AsyncRunnable.run(AsyncContextImpl.java:601)
Unable to handle the Spring Security Exception because the response is already committed
1.3 비즈니스 영향
- 실시간 알림 시스템 불안정으로 사용자 경험 저하
- 빈번한 예외 발생으로 시스템 로그 오염
- 메모리 누수로 인한 서버 리소스 낭비
2. 원인 분석
2.1 기술적 근본 원인
2.1.1 Spring Security Context 전파 실패
Spring Security 공식 문서에 따르면, MODE_INHERITABLETHREADLOCAL 설정은 직접 생성한 자식 스레드에만 Security Context를 전파한다.
// SecurityConfig.java
static {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
그러나 SSE의 비동기 콜백(onTimeout, onError, onCompletion)은 Servlet 컨테이너가 관리하는 스레드에서 실행되어 Security Context가 전파되지 않는다.
2.1.2 AsyncContext에서의 Authorization 체크
Spring Framework 공식 문서의 SSE 예제를 보면,
@GetMapping(path="/events", produces=MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handle() {
SseEmitter emitter = new SseEmitter();
// In some other thread - 여기서 문제 발생
emitter.send("Hello once");
return emitter;
}
"In some other thread" 주석이 핵심이다. 이 스레드는 Servlet 컨테이너가 관리하므로 Spring Security Context가 없는 상태에서 추가적인 Authorization 검증이 수행된다.
2.1.3 응답 커밋 후 예외 처리
SSE 응답이 이미 클라이언트로 전송된 후에 Spring Security가 예외를 처리하려고 시도하여 "Response already committed" 오류가 발생한다.
2.2 구조적 한계
- DelegatingSecurityContextExecutor는 개발자가 제어하는 스레드에만 적용 가능
- Servlet 컨테이너 내부 스레드는 Spring이 제어할 수 없는 영역
- SSE의 비동기 특성과 Spring Security의 동기적 인증 모델 간의 불일치
3. 문제 접근 방법
3.1 분석 도구 활용
- Context7: Spring Security 및 Spring Framework 공식 문서 검증
- 정적 코드 분석: 예외 발생 지점과 호출 스택 추적
- 로그 분석: 예외 발생 패턴과 빈도 분석
3.2 접근 전략
- 원인 파악: 공식 문서를 통한 프레임워크 동작 원리 이해
- 구조적 해결: Security Context 의존성 제거
- 계층적 방어: 여러 단계의 예외 처리 구현
- 모니터링 강화: 실시간 상태 추적 시스템 구축
4. 문제 확인
4.1 재현 조건
# SSE 구독 요청
curl -N -H "Accept: text/event-stream" \
-H "Authorization: Bearer <JWT_TOKEN>" \
http://localhost:8080/api/alert/subscribe
4.2 로그 분석 결과
[알림 SSE 구독] SSE 연결 완료 : emitterId = CHN0001
2024-06-06 09:15:23.456 ERROR --- [AsyncContext-worker-1]
AuthorizationDeniedException: Access Denied
Unable to handle the Spring Security Exception because the response is already committed
4.3 메모리 누수 확인
- 24시간 운영 후 1,247개의 비활성 SSE 연결이 메모리에 누적
- 평균 메모리 사용량: 2.8GB (정상 대비 140% 증가)
5. 해결 방법
5.1 아키텍처 설계 원칙
- Security Context 독립성: SSE 콜백에서 보안 컨텍스트 사용 금지
- 응답 상태 기반 처리: 커밋 상태 확인 후 예외 처리
- 자동 리소스 관리: 주기적 연결 정리 및 모니터링
5.2 핵심 해결책
5.2.1 SSE 전용 Exception Handler 구현
@RestControllerAdvice
public class SseExceptionHandler {
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<Void> handleSseAuthorizationDenied(
AuthorizationDeniedException ex,
HttpServletRequest request,
HttpServletResponse response) {
String requestUri = request.getRequestURI();
boolean isSseRequest = requestUri.contains("/subscribe") ||
"text/event-stream".equals(request.getHeader("Accept"));
if (isSseRequest) {
if (response.isCommitted()) {
log.warn("SSE 응답 커밋 후 AuthorizationDeniedException 발생: {}", requestUri);
return null; // 이미 커밋된 응답에는 추가 처리 불가
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
throw ex; // 일반 요청은 다른 핸들러에서 처리
}
}
5.2.2 Spring Context 독립적 SSE 콜백
private void setupEmitterCallbacks(SseEmitter emitter, String emitterId) {
// Security Context 없이 안전하게 처리
emitter.onCompletion(() -> {
log.info("[Emitter 완료] emitterId = {}", emitterId);
emitters.remove(emitterId);
});
emitter.onTimeout(() -> {
log.info("[Emitter 타임아웃] emitterId = {}", emitterId);
emitters.remove(emitterId);
// 명시적 complete 호출 안함 (이미 타임아웃됨)
});
emitter.onError((throwable) -> {
log.error("[Emitter 오류] emitterId = {}, error = {}", emitterId, throwable.getMessage());
emitters.remove(emitterId);
// 에러 발생 시에도 명시적 complete 불필요
});
}
5.2.3 Thread-Safe SSE 전송 구현
public void sendSseAlertToUser(String emitterId, AlertResponseDto responseDto) {
SseEmitter emitter = emitters.get(emitterId);
if (emitter == null) return;
try {
synchronized (emitter) {
emitter.send(SseEmitter.event()
.name("SSE_ALERT")
.id(createTimeIncludeId(emitterId))
.data(responseDto)
.reconnectTime(3000L));
}
} catch (IOException e) {
handleSseFailure(emitterId, responseDto); // FCM 대체 전송
}
}
private void handleSseFailure(String emitterId, AlertResponseDto responseDto) {
emitters.remove(emitterId);
try {
sendFcmNotification(emitterId, responseDto);
} catch (Exception fcmException) {
log.error("FCM 대체 전송 실패: {}", fcmException.getMessage());
}
}
5.2.4 자동 연결 관리
@Component
public class SseConnectionMonitor {
@Scheduled(fixedRate = 300000) // 5분마다
public void cleanupInactiveConnections() {
int beforeCount = alertService.getActiveEmitterCount();
alertService.cleanupDisconnectedEmitters();
int afterCount = alertService.getActiveEmitterCount();
if (beforeCount != afterCount) {
log.info("비활성 연결 {}개 제거, 활성: {}개",
beforeCount - afterCount, afterCount);
}
}
@Scheduled(fixedRate = 3600000) // 1시간마다
public void logConnectionStatistics() {
int activeConnections = alertService.getActiveEmitterCount();
Runtime runtime = Runtime.getRuntime();
long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
log.info("SSE 연결 통계 - 활성: {}개, 메모리: {}MB", activeConnections, usedMemory);
}
}
5.3 설정 최적화
5.3.1 비동기 처리 향상
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(securityContextAsyncTaskExecutor());
configurer.setDefaultTimeout(120000); // SSE 장시간 연결 고려
configurer.registerCallableInterceptors(new SseCallableProcessingInterceptor());
}
5.3.2 SSE 응답 헤더 최적화
private void setupSseHeaders(HttpServletResponse response) {
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Connection", "keep-alive");
response.setHeader("Content-Type", "text/event-stream; charset=UTF-8");
response.setHeader("X-Accel-Buffering", "no"); // Nginx 프록시 버퍼링 방지
}
6. 결과
6.1 예외 발생률 개선
메트릭 | 개선 전 | 개선 후 | 개선율 |
AuthorizationDeniedException | 매일 발생 | 0회 | 100% 해결 |
"Response committed" 오류 | 매일 발생 | 0회 | 100% 해결 |
SSE 연결 성공률 | 80-85% | 99.9% | 15-20%p 향상 |
6.2 메모리 사용량 최적화
개선 전 (24시간 운영):
- 누적 SSE 객체: 1,247개
- 예상 메모리 사용량: 높음 (자동 정리 없음)
개선 후 (24시간 운영):
- 평균 활성 연결: 300-400개
- 자동 정리: 5분마다 실행
- 메모리 효율성:
SSE Emitter 객체당 메모리: ~2.5KB
24시간 누적 연결 (정리 없음): 1,247개 × 2.5KB = 3.1MB (base)
+ 관련 객체 오버헤드: ~2.8GB 추정
자동 정리 후: 평균 342개 × 2.5KB = 0.85MB (base)
+ 관련 객체 최적화: ~1.2GB 추정
개선율: (2.8GB - 1.2GB) / 2.8GB = 57%
6.3 응답 시간 개선
요청 타입 | 개선 효과 |
SSE 구독 초기화 | 예외 처리 오버헤드 제거로 응답 시간 단축 |
알림 전송 | 직접 전송 경로로 지연 시간 감소 |
연결 복구 | 자동 정리로 빠른 재연결 |