[친구하자] WebSocket 메시지 핸들러 인증 문제 트러블슈팅 (@AuthenticationPrincipal와 SimpMessageHeaderAccessor)

친구하자를 개발하면서 통화 중 에러를 처리하던 중 발견한 WebSocket 메시지 핸들러 인증 문제 트러블슈팅입니다.


📖 문제 발견 배경

실시간 음성 매칭 서비스를 개발하던 중, 통화 중 브라우저를 새로고침했을 때 통화가 비정상적으로 종료되는 문제를 발견했다. 이를 해결하기 위해 Grace Period(유예 시간) 기능을 구현하여, 새로고침 후 30초 이내에 재접속하면 기존 통화를 복구할 수 있도록 했다.

Grace Period 기능 구현을 완료하고 서버를 재시작한 뒤, 통화 종료 기능을 테스트하는 순간 예상치 못한 NullPointerException이 발생했다. 🥲

당황스러웠던 점은:

  1. 서버 재시작 전까지는 정상적으로 작동했던 기능이었던 점
  2. WebSocket CONNECT 시점에는 인증이 완벽하게 성공했는데, MESSAGE 전송 시점에만 에러가 발생했던 점
  3. 로그를 보니 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에 저장될 수 있으며, 이 과정에서:

  1. CONNECT 시점: CustomUserDetails 객체가 생성되고 세션에 저장
    • User user 필드가 JPA 엔티티로 완전히 로드됨
  2. 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 추출

세션 관리 관점

측면@AuthenticationPrincipalSimpMessageHeaderAccessor
직렬화 의존성높음낮음
세션 저장소 영향영향 받음영향 적음
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 로직 구현 가능
  • ✅ 명시적 코드로 디버깅 용이