문제 상황
실시간 채팅 기능을 구현하기 위해 Spring WebSocket(STOMP)을 도입했다. ChatController에서 메세지를 받아 전파하는 로직을 짰고, 기존 REST API에서 하던 것처럼 @AuthenticationPrincipal을 사용해 유저 정보를 가져오려고 했다:
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
// (adds /pub) client -> server
// 엔드포인트: SEND /pub/contents/{contentId}/chat
@MessageMapping("/contents/{contentId}/chat")
public void sendChat(@DestinationVariable UUID contentId,
@Valid @Payload ContentChatSendRequest contentChatSendRequest,
@AuthenticationPrincipal CustomUserDetails principal
) {
UserDto userDto = principal.getUser();
... // 기존 코드
}
하지만 웹소켓 연결 후 채팅을 보내자마자 NullPointerException이 터졌다. 로그를 확인해보니 principal 객체는 들어오는데, 그 안의 값이 비어있거나 CustomUserDetails 자체가 제대로 주입되지 않고 있었다.
java.lang.NullPointerException: Cannot invoke "com.codeit.mopl.domain.user.dto.response.UserDto.id()" because "userDto" is null at com.codeit.mopl.domain.watchingsession.controller.ChatController.sendChat(ChatController.java:39) ~[main/:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
알기로는 서블릿 세션과 웹소켓 세션이 아에 달라서 유저 정보가 없던 것이다.
원인 분석
그러면 HttpSession에 담긴 정보들을 웹소켓 통신에서 가져올 수 있는 방법을 생각해봤다. 그러다가 우연히 HttpSessionHandshakeInterceptor를 알게 됐다:
- HttpSessionHandshakeInterceptor = An interceptor to copy information from the HTTP session to the "handshake attributes" map to made available via `WebSocketSession.getAttributes()`
처음엔 "핸드셰이크 과정에서 인증 정보가 안 넘어갔나?" 싶어서 HttpSessionHandshakeInterceptor를 적용해봤다. 보통 세션 기반 인증에서는 이 인터셉터가 HTTP 세션(HttpSession)에 있는 정보를 웹소켓 세션으로 복사해주는 역할을 하기 때문이다. 하지만 결과적으로 작동이 안됐는데, 그 이유를 생각해보니까 프로젝트는 JWT 방식 토큰 기반이였고, JWT는 stateless여서 HttpSession을 생성하지 않아 복사해올 세션 자체가 없었던 이였다.
그래서 한참 spring docs를 읽어보다가,
- WebSockets/STOMP/Authentication : HttpServletRequest의 getUserPrincipal()을 사용하면 authenticated user을 접근할 수 있다고 쓰여져 있다. 그래서 보통의 web app은 이미 주어진 것만 사용하면 된다..라고 쓰여져 있지만..
- WebSockets/STOMP/Token Authentication : 여기서는 또 쿠키 기반 인증이 아닐 경우 (토큰 기반) "Therefore, applications that wish to avoid the use of cookies may not have any good alternatives for authentication at the HTTP protocol level" 라고 쓰여져있었다!
웹소켓에서 바로 그냥 @AuthenticationPrincipal을 사용하려면 어플리케이션이 쿠키기반 인증이어야 했다.
즉, 어플리케이션이 토큰 기반이면 Spring Security가 자동으로 해주지 않으니 직접 구현하라는 뜻이였다. (ㅜㅜ)
문서에는 이 방식을 권유한다:
- Use the STOMP client to pass authentication headers at connect time.
- Process the authentication headers with a ChannelInterceptor.
그리고 밑에 예시 코드가 있었고, 결국에는 그 예시 코드대로 ChannelInterceptor을 따로 구현했다.
해결: ChannelInterceptor 구현
@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class AuthChannelInterceptor implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
log.info("[WebSocket] STOMP CONNECT 요청: sessionId = {}", accessor.getSessionId());
String jwt = accessor.getFirstNativeHeader("Authorization");
if (StringUtils.hasText(jwt) && jwt.startsWith("Bearer ")) {
jwt = jwt.substring(7);
}
try {
// 토큰 유효성 검사
if (StringUtils.hasText(jwt)) {
String userEmail = jwtTokenProvider.getEmail(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null, // 비번은 필요 없음
userDetails.getAuthorities()
);
// STOMP 세션에 인증 정보 설정
accessor.setUser(authenticationToken);
log.info("[WebSocket] STOMP 인증 성공. userEmail = {}, sessionId = {}", userEmail, accessor.getSessionId()); }
} catch (AuthenticationException | JwtException e) {
log.warn("[WebSocket] STOMP CONNECT: Authorization 헤더에 JWT 토큰이 없습니다.");
// 커스텀 exception
throw new AuthenticationException("JWT 토큰이 필요합니다.", e) {};
} catch (Exception e) {
log.error("[WebSocket] STOMP 인증 처리 중 알 수 없는 예외 발생", e);
throw new RuntimeException("인증 처리 중 오류가 발생했습니다.", e);
}
}
if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
log.info("[WebSocket] STOMP DISCONNECT. sessionId = {}", accessor.getSessionId());
}
return message;
}
}
- 단계
- StompHeaderAccessor를 사용해 네이티브 헤더의 JWT를 꺼낸다.
- 토큰을 검증하고 UsernamePasswordAuthenticationToken을 생성한다.
- accessor.setUser(authenticationToken)을 통해 웹소켓 세션에 인증 객체(UsernamePasswordAuthenticationToken)를 넣는다.
- 자료: https://stackoverflow.com/questions/45405332/websocket-authentication-and-authorization-in-spring - 이 엄청 오래된 stack overflow 포스팅이 있었는데 만약 못찾았다면 큰일날뻔했다.
- 여기서 preSend()는 무조건 UsernamePasswordAuthenticationToken 을 리턴해야 한다고 적혀있다.
- 또 중요한건 @Order(Ordered.HIGHEST_PRECEDENCE + 99) 을 사용해야한다는
마지막으로, 컨트롤러를 수정해야 했다.
ChatController를 UsernamePasswordAuthenticationToken을 사용하는 걸로 바꿨다:
@Slf4j
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
// (adds /pub) client -> server
// 엔드포인트: SEND /pub/contents/{contentId}/chat
@MessageMapping("/contents/{contentId}/chat")
public void sendChat(@DestinationVariable UUID contentId,
@Valid @Payload ContentChatSendRequest contentChatSendRequest,
UsernamePasswordAuthenticationToken token
) {
if (token == null || !(token.getPrincipal() instanceof CustomUserDetails userDetails)) {
throw new UserNotAuthenticatedException(
WatchingSessionErrorCode.USER_NOT_AUTHENTICATED,
Map.of("contentId", contentId));
}
log.info("[실시간 채팅] Chat Controller - 유저 정보 받음. UserDto = {}", userDetails.getUser());
UserDto userDto = userDetails.getUser();
..// 기존 코드
}
}
- 원래 AuthenticationPrincipal과 CustomUserDetail을 사용해서 유저 정보를 받아오는 코드였지만, ChannelInterceptor에서는 UsernamePasswordAuthenticationToken을 accessor에 넣어주기 때문에 컨트롤러에서도 이렇게 받아줘야 했다.
- 이렇게 하지 않고 CustomUserDetail로 받으면 다시 NPE가 발생한다.
정리 및 배운 점
- JWT + 웹소켓 조합은 Spring Security가 자동으로 챙겨주지 않아서 코드를 직접 구현해야 한다. (쿠키 기반과 다르게)
- HttpSessionHandshakeInterceptor는 HTTP 세션 기반이라 JWT 환경에서는 사용하지 못한다.
- ChannelInterceptor를 통해 STOMP CONNECT 시점에 헤더를 가로채서 수동으로 인증 객체(Authentication)를 주입해야 한다.
- 컨트롤러에서는 주입한 Authentication 객체(여기선 UsernamePasswordAuthenticationToken)를 받아 처리하는 것이 가장 안전하다.
- 단어/개념 복습
- Authentication 객체
- Principal, Credentials, Authorities, Authenticated 등 정보를 모두 포함하는 인터페이스
- UsernamePasswordAuthenticationToken
- Authentication 인터페이스를 구현한 구체적인 클래스 (구현체). 구현체이므로 Principal, Credentials 등 Authentication의 여러 정보가 포함되어 있음.
- UserDetails (인터페이스) & CustomUserDetails (구현체)
- UserDetails - 스프링이 내부적으로 유저 정보를 사용하기 위해 정의된 인터페이스
- CustomUserDetails - 인터페이스를 직접 구현한 개발자 정의 클래스, 개발자가 만든 user 엔티티/dto를 필드로 가짐
- UsernamePasswordAuthenticationToken안에 principal이란 이름으로 CustomUserDetails가 들어감
- Authentication 객체
Authentication (인터페이스)
└─ 구현체: UsernamePasswordAuthenticationToken
│
├─ Principal (필드) ──참조──> CustomUserDetails (UserDetails 구현체)
│ └─ User / UserDto (실제 DB 데이터)
│
├─ Credentials (필드) ──> null (이미 인증돼서 보통 비움)
│
└─ Authorities (필드) ──> GrantedAuthority 리스트 (ROLE_USER 등)'Codeit > 프로젝트' 카테고리의 다른 글
| Elastisearch 토이 프로젝트 만들어보기 (프로젝트를 위한 준비..) (0) | 2025.11.27 |
|---|---|
| [모두의 플리] 고도화 - redis를 이용한 분산환경 고려 (pub/sub) (0) | 2025.11.26 |
| [모두의 플리] 트러블슈팅 - @AuthenticationPrincipal을 가진 컨트롤러 테스트 (0) | 2025.11.19 |
| [Monew] 트러블슈팅 - 뉴스 기사 백업 (0) | 2025.09.16 |
| [Monew] 트러블슈팅 - CICD (0) | 2025.09.16 |