Spring WebSocket에서 @AuthenticationPrincipal이 null인 것 해결하기


Spring WebSocket과 STOMP 메시징을 사용해 JWT 인증 기반 개인 채팅 기능을 구현하던 중, @MessageMapping("/private-message") 핸들러에서 @AuthenticationPrincipal CustomUserDetails userDetails로 인증된 유저의 username을 가져와 발신자 이름으로 설정하려 했으나, userDetails.getUsername()이 계속 null로 반환되는 문제를 겪었다. 데이터베이스에는 username "user1"이 분명히 존재했지만 Spring Security에 담기지 않았다. 간단히 끝내려던 작업이 Spring Security, WebSocket 스레딩, 그리고 예상치 못한 문제들로 이어졌다.


문제가 발생한 부분

문제 시작:
  • CustomUserDetailsService가 데이터베이스에서 username = user1을 정상적으로 로드했는데도, 핸들러에서 userDetails.getUsername()이 null로 반환.
  • 핸드셰이크, 메시지 채널, 핸들러 곳곳에 로그를 추가하며 원인을 파악 시작.
스레딩 혼란:
  • JwtHandshakeInterceptor가 Authorization 쿠키의 JWT를 검증하고, 핸드셰이크 중 SecurityContextHolderAuthentication 객체를 설정 → 여기선 잘 작동.
  • 하지만 메시지 처리 스레드에서는 SecurityContextHolder가 비어 있음 (InboundChannel Auth from SecurityContext: null).
  • 원인: WebSocket 핸드셰이크와 메시지 처리가 서로 다른 스레드에서 실행, SecurityContextHolderThreadLocal 저장소가 자동으로 전달되지 않음.
클론 문제:
  • 스레딩 문제를 해결한 후에도, @AuthenticationPrincipal이 제공한 CustomUserDetails 객체는 핸들러에서 다른 인스턴스 (@1b4f09c4)로 나타남 (채널에서는 @40ee6a09).
  • 이 클론 객체는 username = null, userId = null로 상태 손실, 직전에 로그는 Restored Username: user1을 보여줬음에도 불구하고.
  • Spring이 CustomUserDetails를 재구성하며 데이터를 잃어버린 것.
CustomUserDetails 의심:
  • CustomUserDetailsService가 username을 정상 설정했지만, CustomUserDetailsgetUsername() 메서드나 전달 과정에서 값이 누락된 가능성.
STOMP 사용자 문제:
  • 초기 설정에서 Authentication을 STOMP 메시지의 user 속성과 일관되게 연결하지 않아, @AuthenticationPrincipal이 제대로 작동하지 않음.

해결 방법

스레드 간 연결:
  • WebSocketConfig에서 ChannelInterceptorconfigureClientInboundChannel에 추가해 핸드셰이크의 Authentication을 메시지 스레드로 전달:
  •                 @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
          registration.interceptors(new ChannelInterceptor() {
                @Override
                public Message preSend(Message message, MessageChannel channel) {
                      StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
                      Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                      if (auth != null && accessor.getCommand() != null) {
                          accessor.setUser(auth); // STOMP에 SecurityContext 연결
                      }
                      return message;
                }
          });
    }
                  
  • 이로써 SecurityContextHolderAuthentication을 STOMP 사용자에 연결, 핸들러에서 인증 정보 사용 가능.
Principal로 전환:
  • @AuthenticationPrincipal CustomUserDetails가 null 값 클론을 반환해, 핸들러에서 Principal로 변경:
  •                 @MessageMapping("/private-message")
    public void sendPrivateMessage(@Payload MessageDTO messageDTO, Principal principal) throws Exception {
          messageDTO.setSenderName(principal.getName());
          messageDTO.setEnrolledAt(Timestamp.valueOf(LocalDateTime.now()));
          redisService.addPendingMessage(messageDTO);
          messagingTemplate.convertAndSend("/topic/private-chat/" + messageDTO.getChatRoomId(), messageDTO);
    }
                  
  • Principal.getName()Authentication의 원본 CustomUserDetails에서 getUsername()을 호출, "user1" 정상 반환.

최종 설정

핸드셰이크:
  • JwtHandshakeInterceptor가 JWT 검증, CustomUserDetails 로드, SecurityContextHolder와 세션 속성에 Authentication 설정.
메시지 파이프라인:
  • ChannelInterceptorAuthentication을 가져와 STOMP 메시지에 연결.
핸들러:
  • Principal로 문제없이 "user1" 반환.

작동 이유

Principal의 단순성:
  • Spring의 CustomUserDetails 재생성을 피하며 원본 객체의 username 유지.
스레드 연결:
  • 인터셉터가 Authentication을 STOMP 컨텍스트로 전달, 스레드 간 이동 해결.
클론 우회:
  • @AuthenticationPrincipal 리졸버의 username 손실 문제를 회피.

배운 점

스레드의 복잡성:
  • WebSocket은 핸드셰이크와 메시징을 다른 스레드에서 처리 → 인증 데이터 전달에 주의 필요.
@AuthenticationPrincipal의 한계:
  • 커스텀 객체에서 완벽히 설정되지 않으면 문제를 일으킬 수 있음.
Principal의 유용성:
  • 간단히 username을 얻는 가벼운 방법.
로그의 중요성:
  • 핸드셰이크, 채널, 핸들러 단계별 로그로 스레딩 및 클론 문제 파악.

앞으로의 계획

CustomUserDetails 조사:
  • getUsername()과 직렬화 설정 점검 → 나중에 @AuthenticationPrincipal 재사용 가능성 탐구.
견고성 강화:
  • 인터셉터가 SecurityContextHolder에 의존 → 속성에서 복구하는 폴백 추가 고려.
일관성 확인:
  • JwtFilterJwtHandshakeInterceptor 간 호환성 재검토.

최종 코드

WebSocket 설정:
                    @Configuration
    @EnableWebSocketMessageBroker
    @RequiredArgsConstructor
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
            private final JwtUtil jwtUtil;
            private final CustomUserDetailsService userDetailsService;
    
            @Override
            public void configureMessageBroker(MessageBrokerRegistry config) {
                  config.enableSimpleBroker("/topic", "/queue");
                  config.setApplicationDestinationPrefixes("/app");
                  config.setUserDestinationPrefix("/user");
            }
            @Override
            public void registerStompEndpoints(StompEndpointRegistry registry) {
                  registry.addEndpoint("/websocket")
                  .setAllowedOrigins("*")
                  .addInterceptors(new JwtHandshakeInterceptor(jwtUtil, userDetailsService));
            }
            @Override
            public void configureClientInboundChannel(ChannelRegistration registration) {
                  registration.interceptors(new ChannelInterceptor() {
                      @Override
                      public Message preSend(Message message, MessageChannel channel) {
                          StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
                          Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                          if (auth != null && accessor.getCommand() != null) {
                               accessor.setUser(auth);
                          }
                      return message;
                  }
            });
            }
    }
                  
핸들러:
                    @MessageMapping("/private-message")
    public void sendPrivateMessage(@Payload MessageDTO messageDTO, Principal principal) throws Exception {
              messageDTO.setSenderName(principal.getName()); // "user1" 정상 작동!
              messageDTO.setEnrolledAt(Timestamp.valueOf(LocalDateTime.now()));
              redisService.addPendingMessage(messageDTO);
              messagingTemplate.convertAndSend("/topic/private-chat/" + messageDTO.getChatRoomId(), messageDTO);
    }
                    
                  


Posted on 2025.03.04