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를 검증하고, 핸드셰이크 중SecurityContextHolder
에Authentication
객체를 설정 → 여기선 잘 작동.- 하지만 메시지 처리 스레드에서는
SecurityContextHolder
가 비어 있음 (InboundChannel Auth from SecurityContext: null
). - 원인: WebSocket 핸드셰이크와 메시지 처리가 서로 다른 스레드에서 실행,
SecurityContextHolder
의ThreadLocal
저장소가 자동으로 전달되지 않음.
- 클론 문제:
-
- 스레딩 문제를 해결한 후에도,
@AuthenticationPrincipal
이 제공한CustomUserDetails
객체는 핸들러에서 다른 인스턴스 (@1b4f09c4
)로 나타남 (채널에서는@40ee6a09
). - 이 클론 객체는
username = null
,userId = null
로 상태 손실, 직전에 로그는Restored Username: user1
을 보여줬음에도 불구하고. - Spring이
CustomUserDetails
를 재구성하며 데이터를 잃어버린 것.
- 스레딩 문제를 해결한 후에도,
CustomUserDetails
의심:-
CustomUserDetailsService
가 username을 정상 설정했지만,CustomUserDetails
의getUsername()
메서드나 전달 과정에서 값이 누락된 가능성.
- STOMP 사용자 문제:
-
- 초기 설정에서
Authentication
을 STOMP 메시지의 user 속성과 일관되게 연결하지 않아,@AuthenticationPrincipal
이 제대로 작동하지 않음.
- 초기 설정에서
문제가 발생한 부분
- 스레드 간 연결:
-
WebSocketConfig
에서ChannelInterceptor
를configureClientInboundChannel
에 추가해 핸드셰이크의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; } }); }
- 이로써
SecurityContextHolder
의Authentication
을 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
설정.
- 메시지 파이프라인:
-
ChannelInterceptor
가Authentication
을 가져와 STOMP 메시지에 연결.
- 핸들러:
-
Principal
로 문제없이 "user1" 반환.
최종 설정
Principal
의 단순성:-
- Spring의
CustomUserDetails
재생성을 피하며 원본 객체의 username 유지.
- Spring의
- 스레드 연결:
-
- 인터셉터가
Authentication
을 STOMP 컨텍스트로 전달, 스레드 간 이동 해결.
- 인터셉터가
- 클론 우회:
-
@AuthenticationPrincipal
리졸버의 username 손실 문제를 회피.
작동 이유
- 스레드의 복잡성:
-
- WebSocket은 핸드셰이크와 메시징을 다른 스레드에서 처리 → 인증 데이터 전달에 주의 필요.
@AuthenticationPrincipal
의 한계:-
- 커스텀 객체에서 완벽히 설정되지 않으면 문제를 일으킬 수 있음.
Principal
의 유용성:-
- 간단히 username을 얻는 가벼운 방법.
- 로그의 중요성:
-
- 핸드셰이크, 채널, 핸들러 단계별 로그로 스레딩 및 클론 문제 파악.
배운 점
CustomUserDetails
조사:-
getUsername()
과 직렬화 설정 점검 → 나중에@AuthenticationPrincipal
재사용 가능성 탐구.
- 견고성 강화:
-
- 인터셉터가
SecurityContextHolder
에 의존 → 속성에서 복구하는 폴백 추가 고려.
- 인터셉터가
- 일관성 확인:
-
JwtFilter
와JwtHandshakeInterceptor
간 호환성 재검토.
앞으로의 계획
- 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