안녕하세요!
이번 채팅 시스템을 구축하면서 채팅방 리스트 최신화를 어떤 방식으로 진행시켰는지 설명해보겠습니다!
그 전에 이해를 돕고자 서비스(서비스명 : 캐치룸) 아키텍처에 대해 간략히 설명드리겠습니다.
자세한 코드는 깃허브에서 확인 가능합니다!
https://github.com/HyemIin/Catchroom_Chat
0. 서비스(캐치룸) 프리뷰
1) 채팅서버와 메인서버 분리
먼저 채팅서버와 메인서버가 구분되어 있는 것을 확인하실 수 있습니다.
채팅서버는 말 그대로 채팅 관련 요청을 전담하는 서버이고, 메인 서버는 채팅 외 캐치룸에서 적용되는 모든 API를 처리하는 서버입니다.
서로 구분한 이유는 채팅 트래픽이 일반 API 호출 대비 많을 것이라 예상했고, 만약 모놀리식으로 서버를 구축한 상태에서 채팅 트래픽이 증가한다면 서비스 전체에 악영향을 줄 것이라 판단했기에 구분했습니다.
특히 이번 프로젝트는 인프라 비용을 고려하여 AWS 프리티어 범위 내에서만 동작할 수 있도록 약속했기 때문에 더욱 더 서버 분리가 필요하다고 생각했습니다.
2) 채팅서버와 메인서버 통신
서버를 분리했지만, 채팅서버와 메인서버 간 통신은 필요했습니다.
메인서버 DB인 MySQL에 사용자 정보(User)가 저장됨에 따라 채팅방 테이블 또한 MySQL에 저장하도록 설계해야 했기 때문입니다.
채팅 서버에 별도 RDB를 구축할 수 있었지만, 그렇게 된다면 동일한 유저 정보를 관리하는 DB가 2개로 늘어나 데이터 무결성을 지키는데 난이도가 급격히 올라간다고 판단했습니다. 채팅방 테이블 또한 채팅 대상자의 정보(userId, 채팅방 삭제여부 등)가 함께 저장되기 때문에 유저 정보가 저장된 MySQL에 함께 저장해야 했습니다.
그렇기에 서버 간 통신은 필수였고, 저희가 선택한 방법은 Feign Client를 활용하는 것이었습니다.
왜 Feign Client 였는지?
이번 프로젝트를 시작하기에 앞서, 채팅 도메인이 처음인 저와 팀원(1명)은 MSA 스터디를 함께 진행한 이력이 있었습니다.
2주간 스터디를 진행하며 서버 분리 환경에서 각 서버 간 통신하는 방법으로 처음 접한 것이 Feign Client 였기에 실제 프로젝트에서 활용하기까지 Learning Curve가 가장 낮은 방법이었습니다.
또한 Rest Template의 경우 Spring5.0 이후 레거시 라이브러리로 간주되는 상황이었습니다. 그러므로 Rest Template으로 구현한다하더라도 추후 반드시 리팩토링해야하는 어려움이 있었기에 선택하지 않았습니다.
Web Client는 Web Flux 라이브러리의 일부로, 향후 비동기적으로 동작하는 채팅 시스템으로 고도화 하기 위해 꼭 필요한 방법이라 판단했습니다. 다만 저와 팀원 모두 리액티브 프로그래밍을 접한 적이 없을 뿐더러, 비동기 채팅 시스템은 기한 내 목표가 아닌 향후 고도화 목표였기 때문에 당장 시간을 투자해 Web Client를 도입할 이유가 없다고 판단했습니다.
3) 각 서버별로 Redis를 따로 쓰는 이유
각 Redis의 목적이 다르기에 별도 구축하였습니다.
채팅 서버의 경우 채팅 전송 시 Redis Pub/Sub 이라는 메세지 큐 기능을 활용해야했으며, 사용자별 최신 채팅방 리스트를 Redis에 저장했습니다.
메인 서버의 경우 사용자의 refreshToken을 Redis에서 관리했습니다.
4) Mongo DB/MySQL/Redis의 역할
Mongo DB는 실제 채팅 내역을 저장하는 용도로 사용했습니다.
NoSQL의 경우 RDB 대비 읽기, 쓰기 성능이 월등하기 때문에 채팅 내역을 저장하고 불러오기 위한 최적의 DB라고 판단했습니다.
MySQL의 경우, 메인 서버에서 필요한 데이터와 사용자 정보, 채팅방 정보를 관리했습니다. 앞서 말씀드렸다시피 채팅방 정보에 사용자 정보가 필수로 들어가야하므로 MySQL에 채팅방 정보를 저장했습니다.
Redis의 경우 채팅 도메인에서는 사용자별 최신 채팅방 리스트를 저장했습니다. 사용 목적은 채팅 전송 속도 개선을 위함이며, 이는 향후 보다 자세히 다룰 예정입니다.
1. 뭐할꺼야?(What?)
본격적으로 이번 포스팅의 주제에 대해 다뤄보고자 합니다.
앞서 말했다시피, 채팅방 리스트 상시 최신화를 유지하고자 했습니다.
1:1 채팅 시스템을 구축하면서 채팅방 리스트 최신화는 "채팅 시스템 완성도"에 있어 가장 중요하게 생각했던 요소 중 하나였습니다.
"채팅방 리스트 상시 최신화"를 위해 좀 더 세분화한 목표는 아래와 같습니다.
- 사용자 별로 각각 알맞은 채팅방 리스트를 사용자에게 보여줄 수 있는지?
- 새로운 채팅 메세지가 올 때마다 채팅방 리스트는 시간순으로 항시 정렬되어 사용자에게 보여줄 수 있는지?(폴링에서 불가)
- 채팅방의 최신 메세지를 갱신하여 채팅방 리스트에 보여줄 수 있는지?(폴링에서 가능하지만 시간차 발생)
위 목표에 맞춰 어떻게 구현할 수 있을까, 구현한다면 왜 꼭 이런 방식이어야만 할까 고민해보았습니다.
2. 이거 왜 해? 꼭 필요해? (Why?)
초기 프로젝트 기획 시 1초에 1번씩 리스트 최신화를 요청할 수 있는 폴링(Polling) 방식을 이용하여 채팅방 리스트 최신화를 유지하고자 했습니다. 다만 폴링 방식은 채팅방 리스트에 변화가 없어도 채팅방 리스트 최신화 요청을 일정주기마다 보내야하는 불필요함이 존재하며, 채팅을 보낸 즉시 채팅방 리스트가 최신화되지 않는다는 문제가 있었습니다.
저희가 앞서 정의한 채팅방 리스트 상시 최신화는 현재 로그인한 사용자의 채팅방 리스트가 시간순으로 정렬되어 있으며, 각 채팅방에서 진행 중인 대화의 최신 메세지 또한 보여주고 있다는 뜻입니다. 또한 당연하게도 이 화면은 로그인한 각 사용자에 맞게 모두 다르게 보여줘야 합니다.
당연하게도 폴링 방식으로는 위 정의를 충족할 수 없기 때문에 저희는 더 개선된 방법을 찾아야만 했습니다.
더불어 해당 작업을 통해 "채팅 시스템 완성도"를 향상시키기 위함입니다. 실제 서비스를 오픈한다고 가정했을 때, 이정도 편의성은 당연히 갖춰야한다고 생각했기 때문입니다.
하지만 보다 근본적인 목적은 실제로 쓸만한 채팅 서비스에 준하는 수준으로 만들어보고 싶다는 개인적인 욕심이 있었기에 더더욱 채팅 시스템의 퀄리티를 높이고자 했던 것 같습니다.(사실 만들면서 재밌기도 했습니다 ㅋㅋㅋㅋ)
3. 어떻게 구현했어?(How?)
1) 사용자 별로 각각 알맞은 채팅방 리스트를 보여주는 방법
일단 채팅방 리스트를 최신화해야하는 엔드포인트를 구분했어야 했습니다.
그에 따라 하나의 API로 처리가 가능할지, 아니면 API를 구분해야할지 정할 수 있다고 판단했기 때문입니다.
기획 작업물을 바탕으로 분석한 결과, 다행히 채팅 리스트 최신화가 필요한 지점은 딱 1가지로 구분할 수 있었습니다.
- 로그인 후 처음 채팅방 리스트로 진입하는 시점
(1) 로그인 후 처음 채팅방 리스트로 진입하는 시점
먼저, 로그인 후 처음 채팅방 리스트로 진입하는 시점에 대해 설명드리겠습니다.
쉽게 말해, 아래의 "채팅" 버튼을 누른 시점을 의미합니다.
채팅 아이콘을 누르는 시점에서 http 통신을 이용해 전체 채팅방 리스트에서 현재 로그인된 사용자의 채팅방 리스트를 필터링하여 불러오고자 했습니다.
사용자가 채팅 버튼을 누르면 Chat Server로 요청이 갑니다. 물론 로그인한 상태여야만 합니다.(accessToken 필수)
Chat Server는 해당 요청을 바탕으로 feign client를 이용하여 Main Server에 요청을 보내고, 메인서버에서는 JPA를 활용하여 MySQL chat_room 테이블에서 로그인한 사용자의 userId로 필터링하여 채팅방 리스트를 뽑아냅니다.
chat_room 테이블의 칼럼으로 buyer_id, seller_id를 구분해뒀기 때문에 사용자 userId로 필터링이 가능합니다.
필터링하여 뽑아낸 채팅방 리스트를 Chat Server로 반환해서 사용자에게 보여줍니다.
MySQL을 채팅서버에 직접 연결하여 쓰지 않는 이유
이유는 간단합니다. 채팅 서버가 MySQL을 메인 서버와 공유할 경우, 동시성 문제를 처리하는 난이도가 기하급수적으로 높아질 것이라 생각했습니다.
더불어 단일 서버 장애 지점 (Single Point of Failure)를 방지하고 싶었으나, 현재 구조 상 메인 서버와 연결된 MySQL 서버에 문제 생기면 채팅 시스템까지 문제가 발생하는 구조입니다. 이 문제는 향후 채팅 서버에 별도 MySQL 서버를 연결하여 채팅 관련 스키마와 그 외 스키마를 별도 분리하고, 인증에 필요한 사용자 정보만 feign client로 통신하는 구조를 고려하고 있습니다.
아래는 코드입니다.
<ChatRoomService> - Chat Server
// 채팅서버에서
public List<ChatRoomListGetResponse> getChatRoomListByFeign(Long userId, String accessToken) {
// 처음 HTTP 요청에서는 무조건 레디스 초기화 진행
// 1. feign client를 활용하여 메인서버에 채팅방 리스트 요청(with AccessToken)
List<ChatRoomListGetResponse> chatRoomListGetResponseList = mainFeignClient.getChatRoomList(accessToken);
// 2. 받아온 채팅방 리스트를 for문을 통해 각 채팅방 별 최신 메세지 정보 업데이트
chatRoomListGetResponseList.forEach(this::setListChatLastMessage);
// 3. redis에 채팅방 정보가 이미 저장되어 있다면, 삭제
chatRoomRedisRepository.initChatRoomList(userId, chatRoomListGetResponseList);
// 4. 최신 메세지 시간순으로 채팅방 리스트 정렬하여 리턴
return sortChatRoomListLatest(chatRoomListGetResponseList);
}
<MainFeignClient> - Chat Server
@FeignClient(
name = "mainFeign", url = "https://catchroom.xyz/v1",
configuration = FeignConfig.class
)
public interface MainFeignClient {
@RequestMapping(method = RequestMethod.GET, value = "/chat/room/list/feign")
List<ChatRoomListGetResponse> getChatRoomList(@RequestHeader("Authorization") String accessToken);
}
<ChatRoomController> - Main Server
@GetMapping("/list/feign")
public List<ChatRoomListGetResponse> findChatRoomListByMemberIdChat(
@AuthenticationPrincipal User user
) {
return chatRoomService.findChatRoomListByMemberId(user);
}
<ChatRoomService> - Main Server
@Transactional(readOnly = true)
public List<ChatRoomListGetResponse> findChatRoomListByMemberId(User user) {
// 1. 현재 로그인한 userId가 BuyerId or SellerId 중 1개라도 일치하면 리스트에 포함
List<ChatRoom> ChatRoomListUserIsBuyer = chatRoomRepository.findAllByBuyerIdOrSellerId(
user.getId(),user.getId()
);
List<ChatRoom> chatRooms = new ArrayList<>();
// 2. 현재 로그인한 user가 Buyer or Seller 이면서, 채팅방을 삭제(DONT_SEE)하지 않은 경우 리스트에 포함)
for (ChatRoom chatRoom : ChatRoomListUserIsBuyer) {
if ((chatRoom.getBuyer().getId().equals(user.getId()) && chatRoom.getBuyerState().equals(ChatRoomState.SEE)) ||
(chatRoom.getSeller().getId().equals(user.getId()) && chatRoom.getSellerState().equals(ChatRoomState.SEE))) {
chatRoom.updateUserIdentity(user.getId());
chatRooms.add(chatRoom);
}
}
return chatRooms.stream()
.map(ChatRoomListGetResponse::fromEntity)
.collect(Collectors.toList());
}
2) 새로운 채팅 메세지 발행 시 채팅방 리스트를 항상 최신순 정렬하는 방법
1)번 작업을 통해 이제 각 로그인 사용자별로 각자에게 알맞은 채팅방 리스트를 보여줄 수 있게 됐습니다.
문제는 채팅방 리스트가 동적 상태라는 점입니다. 매번 새로운 메세지가 올 때마다 채팅방 리스트에서는 최신 메세지를 미리보기로 보여줘야하고, 채팅방 정렬 순서 또한 최신순을 유지해야 합니다. 이런 점 때문에 기존 기획한 폴링 방식으로는 구현할 수 없었습니다.
저희는 이런 "동적 상태 유지"를 구현하고자 웹소켓 및 Redis Pub/Sub(MQ)를 이용하여 해결했습니다.
위 구조는 사용자가 Redis Pub/Sub을 이용하여 메세지를 전송하는 구조입니다.
메세지를 전송하는 시점에 맞춰서 최신화된 채팅방 리스트도 함께 사용자&채팅 파트너에게 보내는 구조인데요.
이렇게 구조를 구성한 이유는 채팅방 리스트 동적 상태 유지의 핵심이 채팅을 보낼 때만 채팅방 리스트가 최신화된다는 점이기 때문입니다.
폴링 등 일정 주기를 정해서 채팅방 리스트를 업데이트하는 것이 아닌, 필요한 시점에, 필요한 사용자에게만 그때그때 채팅방 리스트를 업데이트하면 됩니다.
저희는 위와 같은 생각을 바탕으로 채팅을 보낼 때 최신화된 채팅방 리스트를 업데이트해서 각 알맞은 사용자에게 채팅과 함께 최신화된 채팅방 리스트도 함께 보내도록 구성했습니다.
아래는 코드입니다.
<ChatController> - Chat Server
@MessageMapping("/chat/message")
public void message(ChatMessageDto message,
@Header("Authorization") String accessToken
) {
// 메세지 발행 시, 몽고DB에 메세지를 저장
ChatMessageDto chatMessageDto = chatMongoService.save(message);
chatService.sendChatMessage(chatMessageDto, accessToken);
}
<ChatService> _ Chat Server
public void sendChatMessage(ChatMessageDto chatMessage, String accessToken) {
Long userId = chatMessage.getUserId();
Long partnerId;
// 1. 채팅방이 삭제되는 것이라면 채팅방을 delete 해준다.
if (chatMessage.getType().equals(MessageType.DELETE)) {
chatRoomService.deleteChatRoom(accessToken, chatMessage.getRoomId(), userId);
}
// 2. 채팅방 리스트에 새로운 채팅방 정보가 없다면, 넣어준다. 마지막 메시지도 같이 담는다. 상대방 레디스에도 업데이트 해준다.
ChatRoomGetResponse newChatRoom = null;
if (chatRoomRedisRepository.existChatRoom(userId, chatMessage.getRoomId())) {
newChatRoom = chatRoomRedisRepository.getChatRoom(userId, chatMessage.getRoomId());
} else {
newChatRoom = chatRoomService.getChatRoomInfo(accessToken, chatMessage.getRoomId());
}
partnerId = getPartnerId(chatMessage, newChatRoom);
setNewChatRoomInfo(chatMessage, newChatRoom);
// 3. 마지막 메시지들이 담긴 채팅방 리스트들을 가져온다.
List<ChatRoomGetResponse> chatRoomList = chatRoomService.getChatRoomList(userId, accessToken);
// 4. 파트너 채팅방 리스트도 가져온다.
List<ChatRoomGetResponse> partnerChatRoomList = getChatRoomListByPartnerId(partnerId);
// 5. 마지막 메세지 기준으로 정렬 채팅방 리스트 정렬
chatRoomList = chatRoomService.sortChatRoomListLatest(chatRoomList);
partnerChatRoomList = chatRoomService.sortChatRoomListLatest(partnerChatRoomList);
MessageSubDto messageSubDto = MessageSubDto.builder()
.userId(userId)
.partnerId(partnerId)
.chatMessageDto(chatMessage)
.list(chatRoomList)
.partnerList(partnerChatRoomList)
.build();
redisPublisher.publish(messageSubDto);
}
3) 채팅방 리스트에서 채팅방의 최신 메세지를 보여주는 방법
3)번 같은 경우 2)번과 매우 밀접한 연관이 있습니다.
앞서 2)번에서 설명드렸듯 채팅방리스트 최신화를 항시 유지하는 기준은 각 채팅방의 최신 메세지가 발행된 시간순이라 설명드렸습니다.
그렇다는 건 역시 채팅이 발행될 때 마다 각 채팅방 리스트에게도 최신 채팅 메세지가 업데이트 된다는 뜻입니다.
위와 같은 방법을 수월하게 하기 위해 먼저 채팅방(ChatRoom) DTO를 개발했습니다.
<ChatRoomListGetResponse> - Chat Server
@Builder
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ChatRoomGetResponse implements Serializable {
private String chatRoomNumber;
private Long buyerId;
private Long sellerId;
private Long productId;
private String accommodationName;
private int sellPrice;
private UserIdentity loginUserIdentity;
private String accommodationUrl;
private String myNickName;
private String partnerNickName;
private DealState dealState;
private ChatRoomState buyerState;
private ChatRoomState sellerState;
private ChatMessageDto lastChatmessageDto;
private Boolean isNego;
public void updateChatMessageDto(ChatMessageDto chatMessageDto) {
this.lastChatmessageDto = chatMessageDto;
}
public void changePartnerInfo() {
String tmp = myNickName;
this.myNickName = partnerNickName;
this.partnerNickName = tmp;
if (this.loginUserIdentity.equals(UserIdentity.SELLER)) {
this.loginUserIdentity = UserIdentity.BUYER;
} else if (this.loginUserIdentity.equals(UserIdentity.BUYER)) {
this.loginUserIdentity = UserIdentity.SELLER;
}
}
}
위 코드에 보시면 채팅방을 구성하는 다양한 데이터 외에도 lastChatmessageDto를 확인하실 수 있습니다.
또한 아래 작성된 updateChatMessageDto를 통해 신규 메세지 DTO를 손쉽게 최신화 시켜줄 수 있습니다.
해당 메서드와 DTO가 쓰이는 코드는 아래와 같습니다.
(위에서 한번 언급한 ChatService 클래스의 sendChatMessage 메서드입니다!)
<ChatService> - Chat Server
public void sendChatMessage(ChatMessageDto chatMessage, String accessToken) {
Long userId = chatMessage.getUserId();
Long partnerId;
if (chatMessage.getType().equals(MessageType.DELETE)) {
chatRoomService.deleteChatRoom(accessToken, chatMessage.getRoomId(), userId);
}
ChatRoomGetResponse newChatRoom = null;
if (chatRoomRedisRepository.existChatRoom(userId, chatMessage.getRoomId())) {
newChatRoom = chatRoomRedisRepository.getChatRoom(userId, chatMessage.getRoomId());
} else {
newChatRoom = chatRoomService.getChatRoomInfo(accessToken, chatMessage.getRoomId());
}
partnerId = getPartnerId(chatMessage, newChatRoom);
// 채팅방에 최신 메세지 업데이트
setNewChatRoomInfo(chatMessage, newChatRoom);
List<ChatRoomGetResponse> chatRoomList = chatRoomService.getChatRoomList(userId, accessToken);
List<ChatRoomGetResponse> partnerChatRoomList = getChatRoomListByPartnerId(partnerId);
chatRoomList = chatRoomService.sortChatRoomListLatest(chatRoomList);
partnerChatRoomList = chatRoomService.sortChatRoomListLatest(partnerChatRoomList);
MessageSubDto messageSubDto = MessageSubDto.builder()
.userId(userId)
.partnerId(partnerId)
.chatMessageDto(chatMessage)
.list(chatRoomList)
.partnerList(partnerChatRoomList)
.build();
redisPublisher.publish(messageSubDto);
}
위 주석 달린 위치를 보면, setNewChatRoomInfo라는 메서드가 동작하고 있습니다.
setNewChatRoomInfo 메서드는 아래와 같습니다.
private void setNewChatRoomInfo(ChatMessageDto chatMessage, ChatRoomGetResponse newChatRoom) {
newChatRoom.updateChatMessageDto(chatMessage);
/** 상대방 채팅 리스트와 내 리스트 둘다 채팅방을 저장한다. */
// 1. 로그인 유저가 seller라면 지금 전송한 메세지를 레디스에 최신메세지로 저장한다.
if (newChatRoom.getLoginUserIdentity().equals(UserIdentity.SELLER)) {
if (!chatMessage.getType().equals(MessageType.DELETE)) {
chatRoomRedisRepository.setChatRoom(newChatRoom.getSellerId(),
chatMessage.getRoomId(), newChatRoom);
}
newChatRoom.changePartnerInfo(); //닉네임 체인지
chatRoomRedisRepository.setChatRoom(newChatRoom.getBuyerId(),
chatMessage.getRoomId(), newChatRoom);
} else if (newChatRoom.getLoginUserIdentity().equals(UserIdentity.BUYER)){
if (!chatMessage.getType().equals(MessageType.DELETE)) {
chatRoomRedisRepository.setChatRoom(newChatRoom.getBuyerId(),
chatMessage.getRoomId(), newChatRoom);
}
newChatRoom.changePartnerInfo(); //닉네임 체인지
chatRoomRedisRepository.setChatRoom(newChatRoom.getSellerId(),
chatMessage.getRoomId(), newChatRoom);
}
//다시 원상태로 복귀
newChatRoom.changePartnerInfo();
}
채팅 메세지 객체와 채팅방 정보가 담긴 객체를 파라미터로 받는 메서드입니다.
먼저 채팅방에 새로운 메세지를 lastMessage로 업데이트해줍니다.
이후 레디스에 최신 메세지가 업데이트된 채팅방을 새로 저장합니다. 저장하는 과정에서 현재 로그인한 유저와 채팅 대상자의 채팅방도 업데이트 해줍니다.
참고로 레디스에는 userId+"_CHAT_ROOM_RESPONSE_KEY"를 key값으로 채팅방을 저장하기 때문에, 자연스럽게 같은 key에 저장된 채팅방은 각 user의 채팅방 리스트가 됩니다.
메세지가 1개 전송될 때 위와 같은 로직이 동작하기 때문에 채팅방의 최신 메세지를 사용자가 항상 확인할 수 있습니다.
'Development > Spring&Springboot' 카테고리의 다른 글
스프링 빈이란?? (3) | 2024.02.28 |
---|---|
스프링 컨테이너 DI와 IoC (2) | 2024.02.28 |
[채팅] 채팅 메세지 전송 속도 개선 (3) | 2024.02.14 |
SpringBoot 내 html 파일을 어떻게 불러오는지 모르겠다면 (0) | 2023.07.04 |
[JPA] 스프링 관련 어노테이션 (0) | 2023.03.06 |