Spring boot 채팅 기능 구현기

이승우·2024년 4월 9일

이번에 실제 배포를 준비하는 프로젝트에서 채팅기능을 맡아 구현했습니다.

많은 시행착오를 거친 끝에 완성했고 어떤 고민을 했고 어떤 방식으로 구현을 했는지 적어보려 합니다.
채팅기능이 생각보다 소스가 없어서 최대한 스스로 생각하여 구현한 기능이기 때문에, 부족함이 많을 것이라 생각됩니다.

각 제목은 제가 기능을 구현할 때 들었던 생각을 적어놨습니다.
깃허브 주소

웹소켓 연결시 인증?

처음 구현을 마음먹고 생각을 하던 중 웹소켓 연결시 인증을 어떻게 할까 고민을 해봤다. 프로젝트에서 토큰기반 인증을 하고 있었기 때문에, 웹소켓 연결시에도 토큰으로 인증하는 방법을 찾아야했다.
웹소켓만을 쓴다면 세션으로 인증이 가능한데, 토큰으로는 인증이 불가능하다.(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개의 채널만 구독할 수 있으며 불필요한 리소스 낭비를 줄일 수 있었다.

푸시알림을 줄지는 어떻게 구분해?

  1. 채팅방을 구독하고 있는 유저 -> publish
  2. 앱에 접속해있는 유저 -> notifee 이벤트 날리기
  3. 백그라운드에 있는 유저 -> push알림 날리기

이렇게 세 분류를 구분하여 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하는 메시지가 올 때 시간을 저장해 놓는다.

채팅을 칠 때마다 db에 insert해도 괜찮나?

1개의 메시지를 100번 insert하는 방식과 100개의 메시지를 한꺼번에 insert하는 방식은 큰 차이가 있다고 한다. 우아한 형제들에서 한 강의가 있었는데 지금은 못찾겠다...

그 강의를 보고 채팅 기능에 딱이라 생각해 적용해보기로 했다.

그럼 문제상황이 두개가 발생한다.
1. 메시지들을 어디다 저장을 해 놓을 것인가?
2. 메시지목록이 동기화가 되어야 한다.(모든 사용자가 같은 메시지를 봐야한다)

  1. 아무리 쓰기 작업이 빠른 nosql이더라도 in-memory의 속도보단 느린 것을 알았기에 메시지는 Redis에 저장해놓기로 했다.
    public void addMessageToRedis(ChatMessage chatMessage) {
        listOperations = redisTemplate.opsForList();
        listOperations.rightPush(MESSAGE_LIST_PREFIX + chatMessage.getRoomId(), chatMessage);
    }

이런 식으로 순서는 중요하기에 자료구조는 list로 선택했다.

  1. 만약 3명이 속해있는 채팅방에 A,B는 채팅방에 있고 C는 접속해있지 않은 상황이라고 가정을 해봤다.
    A와 B가 채팅을 나누고 있고 그 와중에 C가 채팅방에 들어온다면?
    A와 B가 나눈 채팅은 아직 Redis에 있고 database에는 commit되지 않은 상황이다. 따라서 나는 redis에서 database로 넘어가는 조건을 누군가가 방에 입장했을때로 설정했다.
    이렇게 모든 유저들이 같은 메시지를 볼 수 있게 하였다.

0개의 댓글