[친구하자] WebSocket 메시지 핸들러 인증 문제 트러블슈팅 (@AuthenticationPrincipal와 SimpMessageHeaderAccessor)
친구하자를 개발하면서 통화 중 에러를 처리하던 중 발견한 WebSocket 메시지 핸들러 인증 문제 트러블슈팅입니다.
📖 문제 발견 배경
실시간 음성 매칭 서비스를 개발하던 중, 통화 중 브라우저를 새로고침했을 때 통화가 비정상적으로 종료되는 문제를 발견했다. 이를 해결하기 위해 Grace Period(유예 시간) 기능을 구현하여, 새로고침 후 30초 이내에 재접속하면 기존 통화를 복구할 수 있도록 했다.
Grace Period 기능 구현을 완료하고 서버를 재시작한 뒤, 통화 종료 기능을 테스트하는 순간 예상치 못한 NullPointerException이 발생했다. 🥲
당황스러웠던 점은:
- 서버 재시작 전까지는 정상적으로 작동했던 기능이었던 점
- WebSocket CONNECT 시점에는 인증이 완벽하게 성공했는데, MESSAGE 전송 시점에만 에러가 발생했던 점
- 로그를 보니
CustomUserDetails객체는 존재하는데 내부의user필드만 null이었던 점..
WebSocket 연결 인증 성공 - userId: xxx ✅
CustomUserDetails.user is null?: false ✅
User ID: xxx ✅
... (통화 종료 버튼 클릭)
User in UserDetails is null ❌
NullPointerException: Cannot invoke "User.getId()" because "this.user" is null
코드를 변경한 것도 아닌데 갑자기 작동하지 않는 이 상황이 매우 혼란스러웠다. 이 글은 이 문제의 원인과 해결 과정을 정리한 트러블슈팅 문서이다.
📋 문제 상황
증상
- WebSocket CONNECT 시점: 인증 성공 ✅
- WebSocket MESSAGE 전송 시점:
NullPointerException발생 ❌java.lang.NullPointerException: Cannot invoke "com.ldsilver.chingoohaja.domain.user.User.getId()" because "this.user" is null
에러 발생 코드
@MessageMapping("/endpoint/{targetId}")
public void handleMessage(
@DestinationVariable Long targetId,
@Payload SomeMessage message,
@AuthenticationPrincipal CustomUserDetails userDetails // ❌ 문제 발생
) {
Long userId = userDetails.getUserId(); // NullPointerException 발생!
// ...
}
로그 분석
CONNECT 시점 (성공):
WebSocket 연결 인증 성공 - userId: xxx
Principal is CustomUserDetails?: true
CustomUserDetails.user is null?: false ✅
User ID: xxx
MESSAGE 전송 시점 (실패):
Invoking WebSocketController#handleMessage
User in UserDetails is null ❌
NullPointerException: Cannot invoke "User.getId()" because "this.user" is null
🔍 근본 원인 분석
1. WebSocket 인증 흐름
CONNECT 단계
클라이언트 CONNECT 요청
↓
JwtChannelInterceptor.preSend() 실행
↓
토큰 검증 및 User 조회
↓
CustomUserDetails 생성 (user 필드 포함)
↓
UsernamePasswordAuthenticationToken 생성
↓
WebSocket 세션에 Authentication 저장
이 시점에는 CustomUserDetails.user가 정상적으로 존재
MESSAGE 전송 단계
클라이언트 MESSAGE 전송
↓
Spring이 세션에서 Authentication 조회
↓
@AuthenticationPrincipal로 Principal 주입
↓
??? CustomUserDetails.user가 null ???
2. @AuthenticationPrincipal의 동작 방식
@AuthenticationPrincipal는 내부적으로 다음과 같이 동작한다:
// Spring Security 내부 동작 (의사 코드)
public Object resolveArgument(MethodParameter parameter, ...) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
// 🔥 문제: principal이 직렬화/역직렬화되었을 수 있음
// 🔥 WebSocket 세션 저장소(Redis, 메모리)에서 복원될 때 문제 발생 가능
return principal;
}
3. 문제의 핵심: 직렬화/역직렬화
WebSocket 세션은 메모리나 Redis에 저장될 수 있으며, 이 과정에서:
- CONNECT 시점:
CustomUserDetails객체가 생성되고 세션에 저장User user필드가 JPA 엔티티로 완전히 로드됨
- MESSAGE 전송 시점: 세션에서
Authentication객체 복원- 직렬화/역직렬화 과정에서
User객체가 제대로 복원되지 않음 - 또는 세션 스토리지가 얕은 복사만 수행
- 결과:
CustomUserDetails.user == null
- 직렬화/역직렬화 과정에서
4. 프로젝트 구현 특성
CustomUserDetails 구조
public class CustomUserDetails implements UserDetails {
private final User user; // JPA Entity
public Long getUserId() {
return user.getId(); // user가 null이면 NPE 발생!
}
@Override
public String getUsername() {
return String.valueOf(user.getId()); // userId를 String으로 저장
}
}
User 엔티티
@Entity
public class User {
@Id
private Long id;
// ... 다양한 필드들
// Serializable 구현되지 않음 (문제 요인 중 하나)
}
JwtChannelInterceptor
private Authentication authenticateToken(String token) {
Long userId = jwtTokenProvider.getUserIdFromToken(token);
User user = userRepository.findById(userId).orElseThrow(...);
CustomUserDetails userDetails = new CustomUserDetails(user);
// UsernamePasswordAuthenticationToken 생성
// 이 객체가 WebSocket 세션에 저장됨
return new UsernamePasswordAuthenticationToken(
userDetails, // principal
null,
userDetails.getAuthorities()
);
}
✅ 해결 방법
SimpMessageHeaderAccessor 사용
@MessageMapping("/endpoint/{targetId}")
public void handleMessage(
@DestinationVariable Long targetId,
@Payload SomeMessage message,
SimpMessageHeaderAccessor headerAccessor // ✅ 변경
) {
Long userId = extractUserIdFromPrincipal(headerAccessor);
// ...
}
private Long extractUserIdFromPrincipal(SimpMessageHeaderAccessor headerAccessor) {
Principal principal = headerAccessor.getUser();
if (principal instanceof Authentication) {
Authentication auth = (Authentication) principal;
Object principalObj = auth.getPrincipal();
if (principalObj instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) principalObj;
// user가 null이어도 username(=userId)에서 추출 가능
if (userDetails.getUser() == null) {
return Long.parseLong(userDetails.getUsername());
}
return userDetails.getUserId();
}
}
return null;
}
🔬 @AuthenticationPrincipal vs SimpMessageHeaderAccessor
@AuthenticationPrincipal 방식
장점
- ✅ 코드가 간결함
- ✅ Spring Security의 표준 방식
- ✅ HTTP 요청에서 잘 작동
단점
- ❌ WebSocket 세션 관리에서 불안정
- ❌ 직렬화/역직렬화 이슈에 취약
- ❌ Principal 객체의 내부 필드가 null일 수 있음
- ❌ 디버깅이 어려움 (Spring이 자동으로 주입)
동작 방식
// Spring이 내부적으로 수행
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails principal = (CustomUserDetails) auth.getPrincipal();
// 🔥 이 principal이 직렬화/역직렬화를 거쳤을 수 있음
// 🔥 user 필드가 null일 가능성
SimpMessageHeaderAccessor 방식
장점
- ✅ WebSocket 메시지의 실제 Principal에 직접 접근
- ✅ 직렬화 이슈 회피 (Principal 객체를 직접 다룸)
- ✅ null 체크 및 fallback 로직 구현 가능
- ✅ 디버깅 용이 (명시적인 코드)
단점
- ❌ 코드가 다소 길어짐
- ❌ 헬퍼 메서드 필요
동작 방식
// 직접 메시지 헤더에서 Principal 추출
Principal principal = headerAccessor.getUser();
// 이 principal은 JwtChannelInterceptor에서
// accessor.setUser(authentication)으로 설정한 바로 그 객체
// 중간에 직렬화/역직렬화를 거치지 않음 (같은 요청 컨텍스트 내)
🎯 핵심 차이점
인증 정보 조회 경로
@AuthenticationPrincipal:
WebSocket 세션 스토리지 (메모리/Redis)
↓
직렬화된 Authentication 객체
↓
역직렬화 (문제 발생 가능)
↓
SecurityContext
↓
@AuthenticationPrincipal 주입
SimpMessageHeaderAccessor:
현재 메시지의 StompHeaderAccessor
↓
직접 Principal 조회
↓
명시적 타입 체크 및 변환
↓
안전한 userId 추출
세션 관리 관점
| 측면 | @AuthenticationPrincipal | SimpMessageHeaderAccessor |
|---|---|---|
| 직렬화 의존성 | 높음 | 낮음 |
| 세션 저장소 영향 | 영향 받음 | 영향 적음 |
| JPA 엔티티 문제 | 발생 가능 | 회피 가능 |
| Lazy Loading | 문제 가능 | 문제 없음 |
🧪 테스트 결과
Before (문제 발생)
WebSocket CONNECT: ✅ 성공
WebSocket MESSAGE: ❌ NullPointerException
After (해결)
WebSocket CONNECT: ✅ 성공
WebSocket MESSAGE: ✅ 성공
userId 추출: ✅ 성공
메시지 처리: ✅ 성공
💡 추가 개선 사항 (선택)
1. User 엔티티를 Serializable로 만들기
@Entity
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
2. CustomUserDetails를 Serializable로 만들기
public class CustomUserDetails implements UserDetails, Serializable {
private static final long serialVersionUID = 1L;
private final User user;
// ...
}
3. WebSocket 세션 관리 개선
- Redis를 사용한다면 직렬화 설정 최적화
- 세션 타임아웃 설정 검토
- 재연결 시 인증 갱신 로직 구현
📚 참고 자료
- Spring WebSocket Documentation: https://docs.spring.io/spring-framework/reference/web/websocket.html
- Spring Security WebSocket Support: https://docs.spring.io/spring-security/reference/servlet/integrations/websocket.html
- STOMP Protocol Specification: https://stomp.github.io/
🏁 결론
WebSocket 환경에서는 HTTP 요청과 달리 세션 관리 및 직렬화 이슈가 발생할 수 있다.
특히 @AuthenticationPrincipal은 편리하지만 WebSocket 세션 저장소를 거치면서 Principal 객체의 내부 상태가 손실될 위험이 있다.
SimpMessageHeaderAccessor를 사용하면:
- ✅ 직접 Principal에 접근하여 안정성 확보
- ✅ null 체크 및 fallback 로직 구현 가능
- ✅ 명시적 코드로 디버깅 용이