1. 문제 상황

1.1 발생한 문제

SSE(Server-Sent Events)를 통한 로그 스트리밍 연결 시 다음과 같은 오류가 발생했습니다.

HTTP/1.1 401 Unauthorized

문제 증상

  • 일반 REST API 요청은 정상 작동 (JWT 인증 성공)
  • SSE 연결만 401 Unauthorized 발생
  • EventSource를 통한 실시간 로그 스트리밍 불가

1.2 환경 정보

기술 스택

  • Spring Boot 3.x
  • Spring Security
  • JWT 인증
  • SSE (Server-Sent Events)

인증 구조

클라이언트 → Authorization: Bearer <JWT> → JwtAuthenticationFilter → SecurityContext

2. 원인 분석

2.1 EventSource API의 제약사항

EventSource란?

EventSource는 브라우저에서 서버로부터 단방향 실시간 스트리밍을 받기 위한 API입니다.

주요 특징

  • 서버 → 클라이언트 방향의 단방향 통신
  • HTTP 프로토콜 기반
  • 자동 재연결 기능

제약

// ❌ EventSource는 커스텀 헤더를 설정할 수 없음
const eventSource = new EventSource('/api/logs/stream', {
  headers: {
    'Authorization': 'Bearer ' + token  // 불가능!
  }
});

2.2 JWT 인증의 일반적인 흐름

정상적인 REST API 요청

1. 클라이언트가 Authorization: Bearer <JWT> 헤더 포함하여 요청

2. JwtAuthenticationFilter가 헤더에서 토큰 추출

3. 토큰 유효성 검증

4. SecurityContext에 인증 객체 저장

5. 요청 처리

SSE 요청의 문제

1. EventSource로 연결 시도 (Authorization 헤더 없음)

2. JwtAuthenticationFilter가 토큰을 찾지 못함

3. 인증 실패

4. HTTP 401 Unauthorized 반환

2.3 기존 JwtAuthenticationFilter 코드

private String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    
    return null;  // ❌ SSE 요청 시 항상 null 반환
}

문제점

  • Authorization 헤더만 확인
  • EventSource는 헤더를 보낼 수 없으므로 항상 null 반환
  • 결과적으로 인증 실패

3. 해결 방법

3.1 핵심 아이디어

쿼리 파라미터를 통한 토큰 전달

EventSource의 헤더 설정 제약을 우회하기 위해 JWT 토큰을 URL의 쿼리 파라미터로 전달합니다.

// ✅ 쿼리 파라미터로 토큰 전달 가능
const token = localStorage.getItem('accessToken');
const eventSource = new EventSource(`/api/logs/stream?token=${token}`);

표준 방식

  • WebSocket, SSE 등 커스텀 헤더를 지원하지 않는 통신에서 표준적으로 사용되는 방법
  • OAuth 2.0 등에서도 유사한 방식 사용

3.2 JwtAuthenticationFilter 수정

수정된 코드

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final String TOKEN_QUERY_PARAM = "token";  // 추가
    
    private final JwtTokenProvider jwtTokenProvider;
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        
        // 토큰 추출 (헤더 또는 쿼리 파라미터)
        String token = resolveToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
 
    /**
     * HTTP 헤더 또는 쿼리 파라미터에서 JWT 토큰 추출
     */
    private String resolveToken(HttpServletRequest request) {
        // 1️⃣ Authorization 헤더에서 토큰 추출 (일반 API 요청)
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(BEARER_PREFIX.length());
        }
        
        // 2️⃣ 쿼리 파라미터에서 토큰 추출 (SSE 요청)
		String requestUri = request.getRequestURI(); 
		if (requestUri.startsWith("/api/logs/stream")) { 
			String tokenParam = request.getParameter(TOKEN_QUERY_PARAM);
			if (StringUtils.hasText(tokenParam)) { 
				return tokenParam; 
			} 
		}
		
        return null;
    }
}

3.3 작동 원리

처리 순서

1. Authorization 헤더 확인

   있으면 → Bearer 토큰 추출
   없으면 ↓
   
2. token 쿼리 파라미터 확인

   있으면 → 토큰 값 추출
   없으면 ↓
   
3. null 반환 (인증 실패)

예시

일반 API 요청:
GET /api/logs
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
→ 헤더에서 토큰 추출 ✅
 
SSE 요청:
GET /api/logs/stream?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
→ 쿼리 파라미터에서 토큰 추출 ✅

3.4 프론트엔드 코드 수정

React 예시

// 기존 코드 (실패)
const eventSource = new EventSource('/api/logs/stream');
 
// 수정된 코드 (성공)
const token = localStorage.getItem('accessToken');
const eventSource = new EventSource(`/api/logs/stream?token=${token}`);
 
eventSource.onmessage = (event) => {
  const log = JSON.parse(event.data);
  console.log('Received log:', log);
};
 
eventSource.onerror = (error) => {
  console.error('SSE connection error:', error);
  eventSource.close();
};

6. 결론

6.1 핵심 요약

항목내용
문제EventSource는 Authorization 헤더를 보낼 수 없어 JWT 인증 실패
원인EventSource API의 제약으로 커스텀 헤더 설정 불가
해결쿼리 파라미터로 JWT 전달 + 서버 필터 수정
결과SSE 스트리밍에서도 정상 인증 처리, 401 오류 해결