[Hoops PJ] 트러블 슈팅 & TIL (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명이 넘어가는 대형 그룹채팅을 만들 경우 각각의 메세지는 저장하지만 해당 메세지의 전송은 접속한 사람에게만 보내도록 확장하기에도 용이하다고 생각했다.
댓글남기기