
세상에 .. 찐막이란 것은 .. 없습니다 ..
user 작업하던것을 pull 받고 났더니 문제가 생겼다
문제는 바로 테스트코드가 돌아가지 않는다는것
"/ws-chat",
"/ws-chat/**"
그래서 단순하게 화이트 코드에 경로를 넣어주고 돌려봤는데 될리가 없다
그래서 결국 다시 시작된 채팅방 리팩토링
우선 웹소켓에서 인증을 하기 위해
토큰 값을 탈취해올 인터셉터를 구현해야한다
웹소켓은 최초의 연결 이후 HTTP 요청이 아니므로
스프링 시큐리티의 필터나 JWT 필터가 동작하지 않기 때문에
연결 시 해당 토큰 값을 탈취해 저장해두어야하기 때문 ! !
바로 구현을 시작해보자
@Configuration
@RequiredArgsConstructor
public class StompHandler implements ChannelInterceptor {
private final JwtProvider jwtProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
//stompCommand 확인할 수 있도록 랩핑
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
StompCommand command = accessor.getCommand();
//command 값이 CONNECT 라면 인증 정보 설정
if (StompCommand.CONNECT.equals(command)) {
String token = accessor.getFirstNativeHeader("Authorization");
if (!StringUtils.hasText(token)) {
throw new CustomException(ErrorCode.NO_TOKEN);
}
String rawToken = jwtProvider.subStringToken(token);
Claims claims = jwtProvider.getClaims(rawToken);
Long userId = Long.valueOf(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class);
List<SimpleGrantedAuthority> authorities = jwtProvider.getRolesFromToken(rawToken).stream()
.map(SimpleGrantedAuthority::new)
.toList();
CustomPrincipal principal = new CustomPrincipal(userId, email, nickname, authorities);
//웹소켓 세션에 사용자 정보 저장
accessor.getSessionAttributes().put("user", principal);
accessor.setUser(principal);
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getPayload(), accessor.getMessageHeaders());
// command 값이 SEND 이거나 SUBSCRIBE 이면 CONNECT 에서 생성한 정보를 가져와 유저 정보 확인
} else if (StompCommand.SEND.equals(command) || StompCommand.SUBSCRIBE.equals(command)) {
Object sessionUser = accessor.getSessionAttributes().get("user");
if (sessionUser instanceof Principal principal) {
accessor.setUser(principal);
}
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getPayload(), accessor.getMessageHeaders());
}
return message;
}
}
주석까지 그냥 가지고 왔다
우선 stompCommand의 값을 확인하기 위해 래핑해준다
이후 래핑된 command의 값이 CONNECT라면 최초 등록된 것이므로
토큰을 탈취해 해당 값을 웹소켓 세션에 저장해준다
accessor.getSessionAttributes().put("user", principal);
accessor.setUser(principal);
위 두 개의 로직은 비슷해보이지만 실제로는 다른 기능을 하는데
accessor.setUser(principal);는 컨트롤러에서
principal 정보를 가져올 수 있게 해주고
accessor.getSessionAttributes().put("user", principal);는
웹소켓 세션에 해당 값을 넣어놓는 용도이다
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getPayload(), accessor.getMessageHeaders());
해당 코드는 메시지 헤더를 수정 가능하게 설정하고
수정된 헤더를 반영한 새로운 메시지를 생성해 반환한다
웹소켓 연결 시 사용자마다 다른 토큰을 검증하고
그에 따라 인증 정보를 헤더에 담기 위함이다
command의 값이 SEND나 SUBSCRIBE 일때는
저장해놓은 값과 들어온 토큰의 값이 일치하는지 확인해준다
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat") //클라이언트가 웹소켓 연결을 위해 마지막에 붙혀야하는 경로
.setAllowedOriginPatterns("*") //CORS 설정(테스트용으로 전체 허용이나 배포 시 변경 필요)
.withSockJS(); //호환성을 높이기 위해 JS 사용(없을경우 http/1.1 이하에서 사용 불가)
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//prefix
registry.enableSimpleBroker("/sub"); //서버가 구독자에게 메세지 보내는 경로
registry.setApplicationDestinationPrefixes("/pub"); //클라이언트가 서버에 메시지 보내는 경로
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
이후 전에 구현해두었던 config에
해당 인터셉터가 웹소켓 실행 시 동작할 수 있도록 코드를 추가해주면 구현은 끝이다
이제 코드가 제대로 동작하는지 확인해보자
토큰 값을 검증하게끔 만들면서 테스트용 프론트 코드도 좀 바꾸었는데
stompClient.connect(
{
Authorization: `Bearer ${token}`
},
function sendMessage() {
const input = document.getElementById("messageInput");
const message = input.value.trim();
if (!message) {
alert("메시지를 입력하세요");
return;
}
const payload = {
chatRoomId: chatRoomId,
message: message
};
stompClient.send("/pub/message", { Authorization: `Bearer ${token}` }, JSON.stringify(payload));
addMessage("나", message, "me");
input.value = "";
}
웹소켓 연결시, 채팅 입력시에 토큰 값을 받아오도록 해주었다
프론트엔드는 잘 모르기 때문에 .. 여전히 채팅방 아이디는
하드코딩 해두었다

우선 채팅방에 들어가 토큰 값을 입력 후 연결을 누르면
웹소켓에 성공적으로 연결된 것을 확인할 수 있다
네이티브 헤더 부분에도 토큰값이 정상적으로 들어가 있는것을 볼 수 있다

이후 메시지를 보내보면
값이 정상적으로 들어오는걸 확인할 수 있다
@Configuration
@RequiredArgsConstructor
public class StompHandler implements ChannelInterceptor {
private final JwtProvider jwtProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// CONNECT, SEND 요청 시 토큰 검증 및 사용자 정보 설정
if (StompCommand.SEND.equals(accessor.getCommand()) || StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (!StringUtils.hasText(token)) {
throw new CustomException(ErrorCode.NO_TOKEN);
}
String rawToken = jwtProvider.subStringToken(token);
Claims claims = jwtProvider.getClaims(rawToken);
Long userId = Long.valueOf(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class);
List<String> rolesFromToken = jwtProvider.getRolesFromToken(rawToken);
List<SimpleGrantedAuthority> authorities = rolesFromToken.stream()
.map(SimpleGrantedAuthority::new)
.toList();
CustomPrincipal principal = new CustomPrincipal(userId, email, nickname, authorities);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal,
null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
accessor.setUser(authentication);
}
return message;
}
}
우선 초기 구현했던 코드이다

해당 코드를 디버깅 해봤을 때 분명 인터셉터까지는 값이 잘 들어오는데
@MessageMapping("/message")
public void sendMessage(
Principal principal,
@Payload MessageSendRequestDto requestDto
) {
CustomPrincipal currentUser = (CustomPrincipal)principal;
ChatMessage message = chatService.createMessage(currentUser.getId(), requestDto);
messagingTemplate.convertAndSend("/sub/room/" + requestDto.getChatRoomId(), message);
}

귀신 들린것마냥 메시지만 보내면 값이 null로 들어왔다
우선 첫 번째 가설을 세운 것이
인터셉터에서 토큰의 값을 제대로 저장하지 못하고 있다 였다
accessor.setUser(principal);
그래서 authentication이 아니라 principal을 저장해줬는데
여전히 원하는대로 동작을 안해줬다
여기서부터 미친 시간과의 싸움을 벌였는데
아무리봐도 문제가 없다고 생각해서 디버깅만 200번 한거같다

그러다 발견해버리고 말았다
SUBSCRIBE .. ??
그렇다 구독자에게 메시지를 보내는 것의 처리는 따로 해주지 않았다
발견했으니 수정하자하고 수정하는데 든 생각
지금 내 코드는 연결을 시도할 때 헤더에 토큰값을 넣어주고 있다
그리고 해당 토큰값을 계속 principal에 저장하고 있다
하지만 최초 연결을 할 때를 제외하곤 계속 토큰을 저장해줄 필요가 없는데 ?-?
그렇게 뜯어 고친 코드가 구현에 있는 코드가 되었다
command의 값을 분리해 최초 연결이라면 토큰 값 저장
아니라면 저장된 토큰값과 비교
이런식으로 동작하게 코드를 수정하고 나니
결과는 성공적이었다
이번 경험을 통해 앞으로 모든 커맨드에 대해 꼼꼼히 예외처리 해주어야한다는 것을 정말 뼈져리게 느꼈다. 또한 웹소켓의 인증 흐름에 대해 배울 수 있어 뜻깊었던 것 같다.
트러블슈팅에 적은 내용이 디버깅하는데 12시간?정도 걸렸다. 개인적인 성향 상 하나의 문제를 이렇게 긴 시간동안 끌고가는걸 못해서 첫날 3시간정도 붙잡고 있다가 챗지피티한테 물어봤었는데 원인을 못찾아줘서 상당히 낙담했던 기억이 난다. 챗지피티가 해결 못한 문제를 해결했다니 . . . 역시 인간은 위대하다 ㅎ 그리고 디버깅은 늘 꼼꼼해야한다. 늘 목표가 챗지피티보다 쓸모있는 사람이 되는거였는데 목표를 이룬거같아 어쨋든 기분은 좋다.