이번에 실제 배포를 준비하는 프로젝트에서 채팅기능을 맡아 구현했습니다.
많은 시행착오를 거친 끝에 완성했고 어떤 고민을 했고 어떤 방식으로 구현을 했는지 적어보려 합니다.
채팅기능이 생각보다 소스가 없어서 최대한 스스로 생각하여 구현한 기능이기 때문에, 부족함이 많을 것이라 생각됩니다.
각 제목은 제가 기능을 구현할 때 들었던 생각을 적어놨습니다.
깃허브 주소
처음 구현을 마음먹고 생각을 하던 중 웹소켓 연결시 인증을 어떻게 할까 고민을 해봤다. 프로젝트에서 토큰기반 인증을 하고 있었기 때문에, 웹소켓 연결시에도 토큰으로 인증하는 방법을 찾아야했다.
웹소켓만을 쓴다면 세션으로 인증이 가능한데, 토큰으로는 인증이 불가능하다.(header를 쓸 수 없기 때문)
해결방법을 찾던 중 STOMP를 찾을 수 있었고, header를 쓸 수 있다는 것을 알게 되었다.
STOMP: 채널을 구독(subscribe)하고, 메시지를 발행(publish)하는 방식.
ex) 채팅방을 구독하고, 내가 메시지를 보내면 구독한 유저들이 메시지를 받는 방식
또한 STOMP가 제공하는 기능 중 COMMAND가 있는데 메시지의 목적을 정의해주지 않아도 되어 편했다.
예를들어 내가 채팅방을 구독할 때 Message의 command를 SUBSCRIBE로 지정할 수 있다.
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
setOperations = redisTemplate.opsForSet();
// 메시지의 구독 명령이 CONNECT인 경우에만 실행
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// ...중략
} else if (StompCommand.UNSUBSCRIBE.equals(accessor.getCommand())) {
handleUnsubscribe(accessor);
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
handleSubscribe(accessor);
}
return message;
}
처음 구상은 로그인시 내가 속한 모든 채팅방을 구독하고, 메시지가 오면 현재 focus되어있는 화면이 어디인지 확인 후 메시지를 업데이트하거나 알림을 주는 간단한 방식이었다.
그런데 카카오톡을 생각해 봤을 때, 나는 수백개의 채팅방에 속해 있지만 10개 남짓한 채팅방만 활성화 되어있었다. 이렇게 되면 내 방식은 불필요한 채널을 모든 유저가 매번 구독을 하게될 것이고, 불필요한 리소스가 낭비될 것이라 생각했다.
그래서 생각해낸 방법은 로그인을 하면 각 유저는
1. 자신의 고유의 채널을 구독을 한다.
2. 채팅방에 입장하면 채팅방을 구독한다.
채널1은 어플을 켜놓고 있을 때 메시지를 알림으로 줄 때 쓰인다.
채널2는 채팅방에 접속해있을 때 실시간으로 메시지를 업데이트할 때 쓰인다.
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub"); // 이 경로로 온 것은 바로 구독자들에게 전달
}
//웹소켓 주소 설정하는 메서드
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/new-message") // 유저 고유 채널 경로
.setAllowedOriginPatterns("*");
registry.addEndpoint("/ws/chat") // 개별 채팅방 구독 경로
.setAllowedOriginPatterns("*");
}
이 방식으로 유저는 최대 2개의 채널만 구독할 수 있으며 불필요한 리소스 낭비를 줄일 수 있었다.
이렇게 세 분류를 구분하여 publish를 하거나 푸시알림을 줘야했다.
따라서 나는 채팅방에 접속해있는 유저, 어플을 켜놓은 유저를 redis에 저장해놓고 이를 기반으로 어떤 이벤트를 발생시킬지 결정했다.
public void sendMessage(String roomId, ChatMessage message) {
// 현재 방에 접속해 있는 사용자들
Set<String> currentChatUsers = findInRoomUsers(roomId);
// 해당 방의 전체 사용자들
List<String> allChatUsers = userService.findUsersByRoomId(roomId);
// 현재 로그인한 모든 사용자들
Set<String> onlineUsers = userService.findActiveUsers();
// 채팅방에 이벤트 보내기
template.convertAndSend("/sub/chat/" + roomId, message);
// 새로운 메시지 이벤트를 받을 사용자들(웹소켓으로 전송)
Set<String> newMessageEventUsers = new HashSet<>(allChatUsers);
newMessageEventUsers.removeAll(currentChatUsers);
newMessageEventUsers.retainAll(onlineUsers);
// 푸시 알림을 받을 사용자들
Set<String> pushNotificationUsers = new HashSet<>(allChatUsers);
pushNotificationUsers.removeAll(currentChatUsers);
pushNotificationUsers.removeAll(onlineUsers);
// 새로운 메시지 이벤트를 받을 사용자들에게 웹소켓으로 메시지 전송
newMessageEventUsers.forEach(email -> template.convertAndSend("/sub/new-message/" + email, message));
// 푸시 알림을 받을 사용자들에게 푸시 알림 전송
// 이 부분 fcmService로 따로 빼야겠다..
pushNotificationUsers.forEach(email -> {
try {
fcmService.sendPushMessage(
PushMessageRequestDTO.builder()
.email(email)
.title(message.getSenderEmail())
.body(message.getMessage())
.build()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
이 부분은 구현을 하긴 했지만, 아직도 많이 고민하고 있는 부분이다.
읽지 않은 메시지를 세려면 채팅방별 마지막 읽은 시간을 클라이언트에 저장해놓고 채팅방 리스트를 요청할 때 같이 보내야한다고 생각했다.
하지만 이 경우 다른 기기에서 로그인을 하거나 어플을 재설치할 경우 모든 기록이 사라질 것이기 때문에 서버집중화 방식으로 변경했다.
많은 조회가 일어나는 데이터고, collection을 생각해봐도 email, chatRoomId, lastReadAt만 저장해 놓을 것이기 때문에 rdbms에서 발생하는 문제는 없을 것이라 생각하여 mongodb에 저장해 사용하기로 결정했다.
lastReadAt은 /sub/chat/{chatId}에서 unsubscribe하는 메시지가 올 때 시간을 저장해 놓는다.
1개의 메시지를 100번 insert하는 방식과 100개의 메시지를 한꺼번에 insert하는 방식은 큰 차이가 있다고 한다. 우아한 형제들에서 한 강의가 있었는데 지금은 못찾겠다...
그 강의를 보고 채팅 기능에 딱이라 생각해 적용해보기로 했다.
그럼 문제상황이 두개가 발생한다.
1. 메시지들을 어디다 저장을 해 놓을 것인가?
2. 메시지목록이 동기화가 되어야 한다.(모든 사용자가 같은 메시지를 봐야한다)
public void addMessageToRedis(ChatMessage chatMessage) {
listOperations = redisTemplate.opsForList();
listOperations.rightPush(MESSAGE_LIST_PREFIX + chatMessage.getRoomId(), chatMessage);
}
이런 식으로 순서는 중요하기에 자료구조는 list로 선택했다.