7 분 소요

웹소켓 버그 수정

  • 기존에 채팅방을 만들 때는 웹소켓으로 연결해서 사용하는거라 게임 생성 한 개당 채팅방이 하나만 있으면 된다고 생각했다.
  • 그래서 채팅방 하나에서 여러 명이 그룹채팅을 하도록 구현함


  • 문제는 사용자가 인터넷 창을 껐다가(웹소켓 연결 끊김) 다시 접속했을 경우
  • 이전 메세지를 불러오는 과정에서 다른 사용자들도 해당 내역이 같이 보이는 문제가 있었다. -> 메세지 중복 표시


  • 생각해보면 현재 서비스 특성상 실시간 스트리밍 채팅 보다는 메신저 역할에 더 가까웠다.
  • 그렇다고 기존 HTTP 형식으로 만들 경우 1:1이면 상관 없지만 그룹 채팅이다보니 불필요한 리소스가 너무 들어간다고 생각했다.
  • 웹소켓은 유지하면서 중복 메세지는 피하고 새로운 채팅 참여자는 이전 메세지를 볼 수 없게 만들고 싶었다.
  • 이런 부분을 해결하기 위해 인터넷에 찾아보니 웹소켓으로 그룹채팅을 만들 때 세션 id로 나눠서 한다는거임
    • 여기서 세션 값은 임의로 1,2,3 으로 표현했지만 UUID를 통해 랜덤 값으로 고유식별을 하려고 했다.
    • 새로운 유저가 들어올 때 SessionId를 업데이트를 해서 관리


  • 세션 자체를 기존 엔티티에서 별도로 분리하고 여기에 맞게 서비스도 분리해서 관리하려고 했는데 생각해 보니까 굳이 세션으로 관리하는 게 아니라 그냥 채팅방마다 고유 값으로 식별 가능하게만 만들어주면 된다고 생각했다.
  • 또한 현재 프로젝트 서비스 특성상 한 채팅방에 15명 이상 들어갈 수 없는 점을 고려했을 때 채팅 방과 게임의 관계를 M:1 로 만드는 게 더 낫다고 생각해서 아래와 같이 수정을 진행함

  • 각 채팅방이 다르더라도 Game 하나를 바라보고 있기 때문에 같은 GameEntity에 연결 되어있는 각각의 채팅 방에 임의의 User가 입력한 메세지를 각각 채팅룸에 모두 보내주면된다.
  • 또한 이러한 각각의 고유 채팅방은 Accept 된 유저가 입장을 시도할 때 새로 만들어주고 기존에 만들어진 방이 있다면 기존 방을 그대로 사용하면 되기 때문에 불필요한 Session관리를 굳이 redis 에서 관리하는 캐시나 db, 메모리등을 사용해서 할 필요가 없다.

    웹소켓 버그 수정 과정

  • MessageEntity, ChatRoomEntity 구조변경 -> 불필요한 세션관리 제거
  • ChatRoomEntity 와 GameEntity의 1:1 관계에서 M:1 로 변경
    • 기존에 게임을 생성시 자동으로 채팅방이 만들어지는 로직 제거
  • 해시코드 추가

새로운 유저/ 기존 유저 가 채팅방을 입장할 시

이전에 만들었던 html로 간단히 구현한 프론트 코드에서 최대한 변경이 없도록 만들었다.

로그인 과정에서 추가로 필요한 부분은 유저의 닉네임을 localStorage에 추가로 담는 것 말고는 변경된 사항이 없다.
이를 통해 세션 값을 따로 관리하지 않고도 유저의 nickName 만으로도 개별 유저를 식별하는 게 가능하다.

  • (회원 가입시 닉네임 중복 가입 불가)

프론트 html 로그인 코드


let resJson = await res.json();
console.log("res : ", resJson);

if (res.status === 200) {
    localStorage.setItem('access', resJson.refreshToken);
    localStorage.setItem('nickName', resJson.nickName);
    location.replace('index.html');
}
  • 로그인 성공시 response 값에 있는 nickName 값 localStorage에 추가

프론트 html 채팅방 입장 코드

 var stompClient = null;
    var gameId = null;
    const mainUrl = 'http://localhost:8080';
    var accessToken = localStorage.getItem('access');
    var nickName = localStorage.getItem('nickName');

    function connect(event) {
        gameId = $("#gameId").val().trim();
        if (gameId) {
            $("#username-page").addClass("d-none");
            $("#chat-page").removeClass("d-none");

            var socket = new SockJS(mainUrl + "/ws");
            stompClient = Stomp.over(socket);

            var headers = {
                'Authorization': 'Bearer ' + accessToken,
                'gameId': gameId,
                'nickName': nickName
            };
            stompClient.connect(headers, function(frame) {
                console.log('Connected:', frame);
                onConnected();
            }, onError);
        }
        event.preventDefault();
    }
    function onConnected() {
        var headers = {
            'Authorization': 'Bearer ' + accessToken
        };
        stompClient.subscribe("/topic/" + gameId + "/" + nickName, onMessageReceived, headers);
        stompClient.send("/app/loadMessages/" + gameId, headers, {});
        stompClient.send("/app/addUser/" + gameId, headers, JSON.stringify({ sender: nickName, type: "JOIN" }));
        $(".connecting").addClass("d-none");
    }

소켓 연결시 subscribe에 + / nickName 을 추가해줘 각 유저마다 고유 채팅방의 경로를 설정해주고 header에 nickName을 넣어주는 게 추가되었다.

또한 html로 간단히 구현하다보니 유저의 nickName을 따로 API로 호출해서 사용하기 보다는 처음 로그인 할때 저장하고 저장된 내용을 불러와서 활용하는 방식으로 구현했다.

백엔드 채팅방 입장 Controller

@MessageMapping("/addUser/{gameId}")  
public void addUser(  
    @Payload ChatMessage chatMessage,  
    @DestinationVariable String gameId,  
    StompHeaderAccessor headerAccessor  
) {  
  String token = headerAccessor.getFirstNativeHeader("Authorization");  
  chatService.addUser(chatMessage, gameId, token);  
}
  • 변경사항 X

백엔드 채팅방 입장 Service

public void addUser(ChatMessage chatMessage, String gameId,  
    String token) {  
  Long gameIdNumber = Long.parseLong(gameId);  
  UserEntity user = jwtTokenExtract.getUserFromToken(token);  
  GameEntity game = gameRepository.findById(gameIdNumber)  
      .orElseThrow(() -> new CustomException(ErrorCode.GAME_NOT_FOUND));  
  
  boolean userChatRoom = chatRoomRepository.existsByGameEntity_IdAndUserEntity_Id(  
      game.getId(), user.getId());  
  
  if (!userChatRoom) {   
    ChatRoomEntity chatRoom = new ChatRoomEntity();  
    chatRoom.saveGameInfo(game);  
    chatRoom.saveUserInfo(user);  
    chatRoomRepository.save(chatRoom);  
  }  
  
  List<ChatRoomEntity> chatRoomEntityList = chatRoomRepository.findByGameEntity_Id(  
      gameIdNumber);  
  
  for (ChatRoomEntity chatRoomEntity : chatRoomEntityList) {   
    String nickName = chatRoomEntity.getUserEntity().getNickName();  
    messagingTemplate.convertAndSend("/topic/" + gameId + "/" + nickName,  
        chatMessage);  
  }  
}
  • 해당 게임과 관련된 채팅방이 이전에 개설된 적이 없다면 새로운 채팅방을 만든다.
  • 이전에 입장한 적이 있을 경우 본인의 채팅방이 이미 개설되어 있다는 뜻이므로 다음 로직으로 넘어간다.
  • 해당 게임과 관련된 채팅방의 정보를 list로 추출한 뒤 유저가 새로 접속 했음을 각 채팅방에 전체에 뿌려준다.
  • 이 때 각각의 개별 유저들은 gameId/{유저닉네임} 방향으로 구독하고 있으므로 거기에 맞게 정보를 개별로 전달해 준다.

메시지 보내기

변경 전 로직

변경 후 로직

프론트 html 메세지 보내기

function sendMessage(event) {
	var messageContent = $("#message").val().trim();
	if (messageContent && stompClient) {
		var chatMessage = {
			sender: nickName,
			content: messageContent,
			type: "CHAT"
		};
		var headers = {
			'Authorization': 'Bearer ' + accessToken
		};
		stompClient.send("/app/sendMessage/" + gameId, headers, JSON.stringify(chatMessage));
		$("#message").val("");
	}
	event.preventDefault();
}

프론트 쪽 코드는 이전과 달라진 점이 없다.
기존과 같이 프론트에서 메세지를 보내는 경로는 gameId 로만 처리했다.

백엔드에서 이전과 달라진 점은 이전처럼 전달받은 메세지를 gameId 경로로 보내는게 아니라 gameid/{유저닉네임} 의 경로로 각자의 유저가 구독하는 주소로 다시 전송 하는 점이다.

백엔드 메세지 보내기 Controller

@MessageMapping("/sendMessage/{gameId}")  
public void sendMessage(  
    @Payload ChatMessage chatMessage,  
    @DestinationVariable String gameId,  
    StompHeaderAccessor headerAccessor  
) {  
  String token = headerAccessor.getFirstNativeHeader("Authorization");  
  chatService.sendMessage(chatMessage, gameId, token);  
}
  • 변경사항 X

백엔드 메세지 보내기 Service

public void sendMessage(ChatMessage chatMessage, String gameId,  
    String token) {  
  Long gameIdNumber = Long.parseLong(gameId);  
  UserEntity user = jwtTokenExtract.getUserFromToken(token);   
  List<ChatRoomEntity> chatRoomEntityList = chatRoomRepository.findByGameEntity_Id(  
      gameIdNumber);  
  
  for (ChatRoomEntity chatRoomEntity : chatRoomEntityList) {  
    String nickName = chatRoomEntity.getUserEntity().getNickName();  
  
    MessageDto message = MessageDto.builder()  
        .content(chatMessage.getContent())  
        .build();  
  
    messageRepository.save(message.toEntity(user, chatRoomEntity));  
    messagingTemplate.convertAndSend("/topic/" + gameId + "/" + nickName,  
        chatMessage);  
  }   
}

변경된 점을 보면 현재 관련된 game을 참조하는 모든 채팅방 (최대 15개) 의 정보를 리스트로 추출한 뒤 현재 메시지를 보내는 사람 이름으로 현재 참여하고 있는 채팅방의 주인이 갖고 있는 nickName 경로로 메세지를 일괄적으로 전달한다.

이전 메세지 불러오기

변경 전 로직

변경 후 로직

프론트 html 이전 메세지 불러오기

function onConnected() {
	var headers = {
		'Authorization': 'Bearer ' + accessToken
	};
	// 생략
	
	stompClient.send("/app/loadMessages/" + gameId, headers, {});
	
	$(".connecting").addClass("d-none");
}

function onMessageReceived(payload) {
	var message = JSON.parse(payload.body);
	if (Array.isArray(message)) {
		message.forEach(function(msg) {
			displayMessage(msg);
		});
	} else {
		displayMessage(message);
	}
}

function displayMessage(message) {
	if (message.type === "JOIN") {
	// 생략
	} else {
		$("#message-area").append(`<tr><td class="fs-5"><b>${message.sender} :</b> ${message.content}</td></tr>`);
	}
	var messageArea = document.getElementById("message-area");
	messageArea.scrollTop = messageArea.scrollHeight;
}

백엔드 이전 메세지 불러오기 Controller

@MessageMapping("/loadMessages/{gameId}")  
public void loadMessages(@DestinationVariable String gameId,  
    StompHeaderAccessor headerAccessor) {  
  String token = headerAccessor.getFirstNativeHeader("Authorization");  
  chatService.loadMessagesAndSend(gameId, token);  
}
  • 변경사항 X

백엔드 이전 메세지 불러오기 Service

public void loadMessagesAndSend(String gameId, String token) {  
  Long gameIdNumber = Long.parseLong(gameId);  
  UserEntity user = jwtTokenExtract.getUserFromToken(token);  
user.getNickName());  
  
  ChatRoomEntity chatRoom = chatRoomRepository.findByGameEntity_IdAndUserEntity_Id(  
          gameIdNumber, user.getId())  
      .orElseThrow(  
          () -> new CustomException(ErrorCode.NOT_EXIST_CHATROOM));  
  
  List<MessageEntity> messages = messageRepository.findByChatRoomEntity(  
      chatRoom);  
  
  List<MessageConvertDto> messageDto = messages.stream()  
      .map(this::convertToChatMessage)  
      .collect(Collectors.toList());  
  
  messagingTemplate.convertAndSend(  
      "/topic/" + gameId + "/" + user.getNickName(), messageDto);
}

웹소켓에 새로 연결할 때 기존에 채팅 내역이 있던 사람들은 addUser 에서 새로운 방을 생성하지 않고 기존 채팅방과 연결되어있던 메세지를 불러오게 된다.
이 때 재접속한 유저의 고유 subscribe 경로로만 이전 내역을 불러와서 보낸다면 이전에 다른 유저에게도 보이던 중복 메세지가 보이던 현상을 막을 수 있다.

웹소켓 연결 유무 출력

현재 로직상 큰 문제는 없지만 사용자가 웹소켓에 연결되었을 때만 알릴 뿐 웹소켓이 끊겼을 경우에 대한 알림이 따로 없었다.

유저의 접속을 알리는 기능을 구현하기 위해 다음과 같이 환경설정을 만들어줬다.

프론트 html 코드

function displayMessage(message) {
	if (message.type === "JOIN") {
		$("#message-area").append(`<tr><td class="text-secondary fs-6">[${message.sender}]님이 새로 참여했습니다! </td></tr>`);
	} else if (message.type === "LEAVE") {
		$("#message-area").append(`<tr><td class="text-secondary fs-6">[${message.sender}]님이 채팅방에서 로그아웃 하셨습니다!</td></tr>`);
	} else {
		$("#message-area").append(`<tr><td class="fs-5"><b>${message.sender} :</b> ${message.content}</td></tr>`);
	}
	var messageArea = document.getElementById("message-area");
	messageArea.scrollTop = messageArea.scrollHeight;
}

백엔드 코드

@Component  
@Slf4j  
@RequiredArgsConstructor  
public class WebSocketConnectionEventListener {  
  
  private final SimpMessageSendingOperations messageTemplate;  
  private final ChatRoomRepository chatRoomRepository;  
  
  @EventListener  
  public void handleWebSocketConnectListener(SessionConnectEvent event) {  
    StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(  
        event.getMessage());  
    String sessionId = headerAccessor.getSessionId();  
    String nickName = headerAccessor.getFirstNativeHeader("nickName");  
    String gameId = headerAccessor.getFirstNativeHeader("gameId");  

    if (nickName != null && gameId != null) {  
      if (headerAccessor.getSessionAttributes() == null) {  
        headerAccessor.setSessionAttributes(new ConcurrentHashMap<>());  
      }  
      headerAccessor.getSessionAttributes().put("nickName", nickName);  
      headerAccessor.getSessionAttributes().put("gameId", gameId);  
    } else {  
      log.error("NickName or GameId is null");  
    }  
  }  
  
  @EventListener  
  public void handleWebSocketDisconnectListener(  
      SessionDisconnectEvent event) {  
    StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(  
        event.getMessage());  
    Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();  
    if (sessionAttributes != null) {  
      String username = (String) sessionAttributes.get("nickName");  
      String gameId = (String) sessionAttributes.get("gameId");  
      Long gameIdNumber = Long.parseLong(gameId);  
      if (username != null) {  
        List<ChatRoomEntity> chatRoomEntityList = chatRoomRepository.findByGameEntity_Id(  
            gameIdNumber);  
        for (ChatRoomEntity chatRoomEntity : chatRoomEntityList) {  
          String nickName = chatRoomEntity.getUserEntity().getNickName();  
          ChatMessage chatMessage = ChatMessage.builder()  
              .type(MessageType.LEAVE)  
              .sender(username)  
              .build();  
          messageTemplate.convertAndSend(  
              "/topic/" + gameId + "/" + nickName,  
              chatMessage);  
        }  
      }  
    } else {  
      log.error("Session attributes are null");  
    }  
  }  
}

이벤트 리스너를 통해 Session을 설정해서 웹소켓과 끊김과 연결을 감지해주도록 설정했다.
유저가 웹소켓과의 연결이 끊겼을 시 메세지 타입을MessageType.LEAVE 로 설정해서 프론트로 전달한다면 프론트 단에서 LEAVE 메세지를 감지하고 해당 유저가 채팅방에서 로그아웃을 했다고 알릴 수 있다.

채팅방 입장과 퇴장의 정보를 db에 저장하지 않은 이유는

  • 유저의 입장과 재입장을 반복하는 내용은 db에 불필요한 내용이라고 판단
  • 따라서 db에 저장 없시 프론트 창에만 따로 표시

위와 같이 구현할 경우 프론트에서도 어떤 유저가 현재 채팅방에 접속하고 있는지 화면에 출력할 수 있는 기능을 만들 수 있다.

또한 추후에 채팅방에 100명이 넘어가는 대형 그룹채팅을 만들 경우 각각의 메세지는 저장하지만 해당 메세지의 전송은 접속한 사람에게만 보내도록 확장하기에도 용이하다고 생각했다.

댓글남기기