The given id must not be null오류부터 최종 해결까지의 기록
🚨 1. 문제의 시작: Unhandled exception from message handler method, The given id must not be null
채팅 기능 구현 중, 클라이언트에서 메시지를 보내면 STOMP를 통해 실시간으로 통신은 되었지만, 해당 메시지를 DB에 저장하는 과정에서 오류가 발생했다. 처음 마주한 오류 로그는 다음과 같았다.
최초 오류 로그:
[boundChannel-13] .WebSocketAnnotationMethodMessageHandler : Unhandled exception from message handler method
...
org.springframework.dao.InvalidDataAccessApiUsageException: The given id must not be null
...
Caused by: java.lang.IllegalArgumentException: The given id must not be null
... at org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById(SimpleJpaRepository.java:323)
findById(null)이 호출되었다는 명백한 단서였다. 호출 흐름(Controller -> Service -> Repository)을 따라가 보니, 원인은 컨트롤러에서 메시지를 보내는 사용자의 ID, 즉 senderId를 null로 서비스에 넘겨주고 있기 때문이었다.
문제 발생 당시의 Controller 코드:
// ChatMessageController.java
@MessageMapping("/chat/{groupId}/sendMessage")
@SendTo("/topic/group/{groupId}")
public ChatMessageDto sendMessage(@AuthenticationPrincipal CustomUserDetails userDetails, /*...*/) {
// 이 시점에서 userDetails가 null이 되어 NullPointerException 발생을 예상했으나,
// 실제로는 userDetails.getId()가 null을 반환하여 다음 계층으로 전달됨.
Long senderId = userDetails.getId();
chatMessageService.saveMessage(groupId, senderId, request.getContent());
// ...
}
분명 AuthChannelInterceptor에서 JWT 토큰을 검증하고 Principal을 설정했는데, 왜 컨트롤러에서는 @AuthenticationPrincipal이 제대로 동작하지 않는 걸까? 여기서부터 길고 험난한 디버깅 여정이 시작되었다.
🤔 2. 험난한 여정: 잘못된 가정과 수많은 시도들
가설 1: 인터셉터가 실행되지 않는다?
가장 먼저 든 생각은 "인터셉터가 아예 동작 안 하는 거 아닐까?" 였다. 이를 검증하기 위해 AuthChannelInterceptor의 모든 로직을 제거하고, 단순히 로그만 찍는 코드로 변경했다.
테스트용 인터셉터 코드:
// AuthChannelInterceptor.java (단순화 버전)
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
log.info("[!!! INTERCEPTOR CALLED !!!] Command: {}, SessionID: {}", accessor.getCommand(), accessor.getSessionId());
return message;
}
결과:
[!!! INTERCEPTOR CALLED !!!] Command: CONNECT, SessionID: 3io0lrjy
[!!! INTERCEPTOR CALLED !!!] Command: SUBSCRIBE, SessionID: 3io0lrjy
[!!! INTERCEPTOR CALLED !!!] Command: SEND, SessionID: 3io0lrjy
로그는 정상적으로 찍혔다. 인터셉터는 실행되고 있었다. 이 간단한 테스트 덕분에 Spring 설정이나 컴포넌트 스캔의 문제가 아님을 확신하고, 문제의 범위를 인터셉터의 내부 로직과 인증 정보 전파 방식으로 좁힐 수 있었다.
가설 2: Principal 전파가 유실된다?
인터셉터가 실행된다면, CONNECT 시점에 설정한 Principal이 SEND 시점에 유실되는 것이 분명했다. 이를 해결하기 위해 여러 방법을 시도했다.
sessionAuthMap사용:sessionId를 키로Authentication객체를Map에 저장하고,SEND시 다시 꺼내 쓰는 방식. 실패했다. STOMP 세션 ID가 항상 동일하게 유지된다T는 보장이 없었다.- 매번 토큰 전송: 프론트엔드에서
SEND메시지를 보낼 때마다 헤더에 토큰을 실어 보내고, 백엔드는 매번 검증하는 방식. 동작은 했지만, STOMP의 세션 기반 프로토콜의 이점을 활용하지 못하는 불필요한 작업이었다.
이 모든 시도는 핵심을 빗나가고 있었다.
💡 3. 깨달음: 근본 원인은 ThreadLocal과 @AuthenticationPrincipal
수많은 삽질 끝에 문제의 근본 원인을 깨달았다.
SecurityContextHolder는ThreadLocal이다. Spring Security의SecurityContextHolder는 스레드별로 보안 컨텍스트를 저장한다. 하지만 WebSocket 메시지는 HTTP 요청을 처리하는 스레드가 아닌, 별도의 메시지 브로커 스레드 풀에서 비동기적으로 처리된다.@AuthenticationPrincipal은SecurityContextHolder를 참조한다.@AuthenticationPrincipal어노테이션은 현재 실행중인 스레드의SecurityContextHolder에서Authentication객체를 가져와 주입해준다.
결론적으로, 내가 인터셉터(preSend)에서 SecurityContextHolder.getContext().setAuthentication(...)을 호출해봤자, 그 정보는 인터셉터가 실행된 스레드에만 유효할 뿐, 정작 @MessageMapping 컨트롤러 메서드가 실행되는 스레드에는 전달되지 않았던 것이다.
WebSocket 환경에서 신뢰할 수 있는 유일한 인증 정보는 StompHeaderAccessor가 세션 내내 들고 있는 user 필드(즉, Principal 객체) 뿐이었다.
🛠️ 4. 최종 해결책: Principal을 직접 주입받기
@AuthenticationPrincipal이라는 편리한 어노테이션에 대한 미련을 버리고, STOMP가 제공하는 가장 확실한 방법을 사용하기로 했다. 바로 @MessageMapping 메서드에서 java.security.Principal을 직접 파라미터로 주입받는 것이다.
Before (문제 코드)
// ChatMessageController.java
@MessageMapping("/chat/{groupId}/sendMessage")
public ChatMessageDto sendMessage(@AuthenticationPrincipal CustomUserDetails userDetails, ...) {
// userDetails가 null이 되어버림
Long senderId = userDetails.getId();
// ...
}
After (해결 코드)
// ChatMessageController.java
import java.security.Principal; // import 추가
import org.springframework.security.core.Authentication; // import 추가
@MessageMapping("/chat/{groupId}/sendMessage")
public ChatMessageDto sendMessage(Principal principal, ...) { // Principal을 직접 주입
if (principal == null) {
throw new SecurityException("인증된 사용자만 메시지를 보낼 수 있습니다.");
}
// Principal에서 Authentication 객체를 추출하고, 다시 CustomUserDetails를 얻는다.
Authentication authentication = (Authentication) principal;
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long senderId = userDetails.getId();
// senderId는 이제 절대 null이 아니다!
chatMessageService.saveMessage(groupId, senderId, request.getContent());
// ...
}
이와 함께, AuthChannelInterceptor의 역할도 명확해졌다. CONNECT 시점에 accessor.setUser()를 통해 Principal을 설정하고, 이후 SEND나 SUBSCRIBE 시에는 해당 Principal이 존재하는지만 확인해주면 되었다.
최종 AuthChannelInterceptor 로직:
// AuthChannelInterceptor.java
// CONNECT 시
private void handleConnect(StompHeaderAccessor accessor) {
// ... 토큰 검증 후
Authentication authentication = createAuthentication(jwt);
accessor.setUser(authentication); // 세션에 Principal 설정. 이것이 핵심!
}
// SEND, SUBSCRIBE 시
private void handleSend(StompHeaderAccessor accessor) {
// 세션에 설정된 Principal이 있는지 확인
if (accessor.getUser() == null) {
throw new MessagingException("Not authenticated");
}
}
📌 5. 결론
- WebSocket 인증은 HTTP와 다르다.
SecurityContextHolder가ThreadLocal이라는 점, 그리고 메시지 브로커가 별도의 스레드 풀에서 동작한다는 점을 반드시 인지해야 한다. HTTP 환경의 편리한 어노테이션(@AuthenticationPrincipal)을 맹신해서는 안 된다.
'개발' 카테고리의 다른 글
| [Spring]서블릿(Servlet)과 Spring MVC (0) | 2025.11.17 |
|---|---|
| [Python] 파이썬으로 TTS 구현하기: gTTS (Google Text-to-Speech) (0) | 2025.11.13 |
| [Celery] 파이썬 비동기 태스크 큐 Celery란 무엇인가 (0) | 2025.11.11 |
