
위 사진을 통해 STOMP 기반 메시지 브로커의 흐름을 살펴보면
그러나 나의 경우
@EventListener
public void handleSessionSubscribeEvent(SessionSubscribeEvent event) {
try {
log.info("구독중..");
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
System.out.println(headerAccessor.getMessageHeaders());
String destination = headerAccessor.getDestination();
if (destination.startsWith("/topic/chat.")) {
String channelId = destination.substring("/topic/chat.".length());
String nickName = headerAccessor.getFirstNativeHeader("nickName");
String id = headerAccessor.getFirstNativeHeader("chatRoomId");
String sessionId = headerAccessor.getSessionId();
if (nickName == null || id == null) throw new IllegalArgumentException("헤더값이 null입니다");
ChatRoom chatRoom = chatRoomRepository.findById(id)
.orElseThrow(()-> new IllegalArgumentException("해댕 채팅방이 존재하지않습니다."));
log.info("채팅 구독");
/* 메인 채팅방에 입장하는 경우 */
if (chatRoom.getChannelId().equals(channelId)) {
String message = nickName + "님이 입장하셨습니다.";
ChatRoomCommonMessageResponseDto chatMessage = new ChatRoomCommonMessageResponseDto("ENTER",message);
String jsonStringEnterMessage = new ObjectMapper().writeValueAsString(chatMessage);
messageSendingOperations.convertAndSend(destination, jsonStringEnterMessage);
}
/* 서브 채팅방에 입장하는 경우 */
else {
chatRoom.getSubChatRooms().stream()
.filter(subChatRoom -> subChatRoom.getSubChannelId().equals(channelId))
.findFirst()
.map(SubChatRoom::getSubChannelId)
.orElseThrow(() -> new IllegalArgumentException("서브 채팅방이 존재하지 않습니다."));
String message = nickName + "님이 입장하셨습니다.";
ChatRoomCommonMessageResponseDto chatMessage = new ChatRoomCommonMessageResponseDto("ENTER",message);
String jsonStringEnterMessage = new ObjectMapper().writeValueAsString(chatMessage);
messageSendingOperations.convertAndSend(destination, jsonStringEnterMessage);
}
}
else {
/* 채팅방 목적지 외 처리 */
}
} catch (IllegalArgumentException e) {
log.error("구독 실패: " + e.getMessage());
messageSendingOperations.convertAndSend("/topic/error", CommonResponseDto.CommonResponseSocketErrorDto.error("구독실패",e.getMessage()));
}
catch (Exception e) {
e.printStackTrace();
}
}
@MessageMapping("/message/{channelId}")
@SendTo("/topic/chat.{channelId}")
public ChatRoomSendMessageResponseDto sendChatMessage(
@DestinationVariable
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
message = "Invalid UUID format") String channelId,
@Payload @Valid ChatRoomSendMessageRequestDto RequestDto, SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("nickName", RequestDto.getSender());
log.info("메시지 받기 ");
ChatRoomSendMessageResponseDto responseDto = chatRoomService.sendChatMessage(channelId,RequestDto);
return responseDto;
}
IllegalArgumentExceptio 이 발생해도,해당 destination으로 메세지를 발행하면 구독을 실패했음에도 해당 구독 채널에서 메시지를 발행 및 읽어올 수 있었다.
이유는 주로 서버 측에서 구독 상태를 제대로 검증하지 않거나 혹은 브로드캐스트 방식으로 메시지를 전송하는 경우가 있기 때문이라고 한다.
1. 서버의 구독 상태 검증 부족
서버가 클라이언트의 구독 상태를 제대로 확인하지 않고, 구독 여부와 상관없이 메시지를 발행하는 경우, 구독에 실패한 클라이언트도 메시지를 수신할 수 있다. 즉, 서버가 구독 상태를 명확히 관리하지 않아서 구독에 실패한 클라이언트도 메시지를 수신하게 되는 것
2. 브로드캐스트 방식의 메시지 전송
서버나 메시지 브로커가 특정 토픽이나 채널로 구독 여부와 상관없이 모든 클라이언트에게 메시지를 보내는 설정을 할 수 있다. 이런 경우 구독이 실패했더라도 해당 토픽에 연결된 클라이언트는 모두 메시지를 수신하게 된다.
이를 해결하기 위해 구독 상태를 관리하여, 해당 채팅방을 구독한 유저들끼리 메시지를 발행하고, 구독 검증이 완료됐을 경우에만 메세지를 발행(전달)하도록 코드를 수정하였다.
package server.cubeTalk.chat.handler;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SubscriptionManager {
private final ConcurrentHashMap<String, Set<String>> subscriptionMap = new ConcurrentHashMap<>();
public void addSubscription(String sessionId, String channelId) {
subscriptionMap.computeIfAbsent(sessionId, k -> new HashSet<>()).add(channelId);
}
public void removeSubscription(String sessionId, String channelId) {
Set<String> subscriptions = subscriptionMap.get(sessionId);
if (subscriptions != null) {
subscriptions.remove(channelId);
}
}
public boolean isSubscribed(String sessionId, String channelId) {
return subscriptionMap.containsKey(sessionId) && subscriptionMap.get(sessionId).contains(channelId);
}
public void removeSession(String sessionId) {
subscriptionMap.remove(sessionId);
}
}
이후 subscribe event listner 로
subscriptionManager.addSubscription(sessionId, channelId);
를 추가하여 구독 요청이 들어오면 구독 상태를 저장하였고,
@MessageMapping("/message/{channelId}")
@SendTo("/topic/chat.{channelId}")
public ChatRoomSendMessageResponseDto sendChatMessage(
@DestinationVariable
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
message = "Invalid UUID format") String channelId,
@Payload @Valid ChatRoomSendMessageRequestDto RequestDto, SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("nickName", RequestDto.getSender());
String sessionId = headerAccessor.getSessionId();
// 구독 상태 검증
if (!subscriptionManager.isSubscribed(sessionId, channelId)) {
throw new IllegalArgumentException("구독되지 않은 채널에 메시지를 발행할 수 없습니다.");
}
log.info("메시지 받기 ");
ChatRoomSendMessageResponseDto responseDto = chatRoomService.sendChatMessage(channelId,RequestDto);
return responseDto;
}
메세지를 브로드캐스트하는 부분에서 구독 상태를 검증하여 구독한 채팅방으로 메시지를 발행할 수 있도록 수정하였다.