Redis 기반 채팅 리스트 읽음/읽지 않음 알림 시스템


Redis로 채팅 리스트의 읽음/읽지 않음 상태를 실시간 관리.

채팅서비스는 메세지의 읽음과 읽지 않음 상태가 표시되는 것이 일반적이다. 그래서 개발하려고 공부하던중 이것또한 하나씩 데이터 베이스에 변경사항이 있을 때 마다 들어간다면 부하를 일으킬 것 같았다

그래서 앞에서 메세지 저장에 사용한 것 처럼 Redis를 써보기로 했다.


목표

  • 채팅 리스트에서 각 채팅방의 읽은/읽지 않은 메시지를 실시간으로 표시.
  • 메시지량 증가 시 MySQL 과부하 방지 및 빠른 상태 업데이트를 위해 Redis 활용.

프론트엔드 유저 액티비티 트래킹

상태 변수:
  • isActive 플래그로 유저의 활성 상태 관리.
이벤트 감지:
  • 채팅방 입장: 처음 입장 시 활성 상태 설정.
  • 창 닫기/탭 전환: visibilitychange, blur, focus, beforeunload 이벤트로 비활성/활성 전환.
  • 기타: 앱 클릭, 브라우저 최소화, 페이지 이동 등 감지.
  • document.addEventListener("visibilitychange", () => {
              document.hidden ? setUserInactive() : setUserActive();
            });
            window.addEventListener("blur", setUserInactive);
            window.addEventListener("focus", setUserActive);
            window.addEventListener("beforeunload", setUserInactive);
          }
WebSocket 전송:
  • 활성 시: /app/user-activechatRoomId, userId 전송.
  • function setUserActive() {
            if (isActive) return;
            isActive = true;
            stompClient.publish({
              destination: "/app/user-active",
              body: JSON.stringify({ "chatRoomId": chatRoomId, "userId": userId })
            });
          }
  • 비활성 시: /app/user-inactive로 동일 데이터 전송.
  • function setUserInactive() {
            if (!isActive) return;
            isActive = false;
            stompClient.publish({
              destination: "/app/user-inactive",
              body: JSON.stringify({ "chatRoomId": chatRoomId, "userId": userId })
            });
          }

Redis로 실시간 상태 관리

키 구조:
  • "lastReadTimer::"ChatRoomUserDTO로 유저의 마지막 읽음 타임스탬프 저장.
동작:
  • 유저 활성화: /app/user-active 수신 → Redis에 ChatRoomUserDTO 저장 (addPendingLastReadAt), 현재 타임스탬프 갱신.
  • 유저 비활성화: /app/user-inactive 수신 → Redis에 타임스탬프 갱신.
  • public void addPendingLastReadAt(ChatRoomUserDTO chatRoomUserDto) {
            String key = "lastReadTimer:" + chatRoomUserDto.getUserId() + ":" + chatRoomUserDto.getChatRoomId();
            chatRoomUserRedisTemplate.opsForValue().set(key, chatRoomUserDto);
            log.info("마지막 접속 시간: {}, 유저id: {}, 챗id: {}",
                    chatRoomUserDto.getLastReadAt(),
                    chatRoomUserDto.getUserId(),
                    chatRoomUserDto.getChatRoomId());
        }
                  
  • 읽음 기준: ChatRoomUserDTO.lastReadAt과 메시지의 enrolledAt 비교로 읽음/읽지 않음 판단.
변경점:
  • 기존 단순 타임스탬프 대신 ChatRoomUserDTO로 데이터 구조화.

MySQL 동기화 및 정리

세션 종료 시:
  • SessionDisconnectEvent 발생 → Redis의 "lastReadTimer::*" 데이터를 MySQL에 반영 후 삭제 (removePendingLastReadAt).
활성 데이터 조회:
  • SimpMessageHeaderAccessorsessionId에서 userId 추출, Redis 데이터 매핑.

동작 방식

유저 입장:
  • 채팅 히스토리 로드 → Redis에 lastReadAt 업데이트 (addPendingLastReadAt) → 활성 상태 표시.
유저 활성화:
  • /app/user-active → Redis에 ChatRoomUserDTO 저장 → 읽음 상태 갱신.
유저 비활성화:
  • /app/user-inactive → Redis에 타임스탬프 갱신 → 이후 메시지 읽지 않음 처리.
유저 disconnection:
  • WebSocket 이벤트 감지 → Redis 데이터 MySQL 동기화 → Redis 정리 (removePendingLastReadAt).

신규 채팅방 문제 해결

문제:
  • 신규 채팅방 생성 시 Redis에 관계 미등록 ("user_chatrooms:", "chatroom_users:" 누락).
해결:
  • 생성 시 addChatRoomIdsAndUserIds(userId, chatRoomId) 호출 → Redis 즉시 업데이트.
  • public void addChatRoomIdsAndUserIds(Integer userId, Long chatRoomId) {
            String keyForUserId = "user_chatrooms:" + userId;
            Set chatRoomIds = chatRoomIdRedisTemplate.opsForSet().members(keyForUserId);
            //레디스에 없으면 디비에서 불러오기.(expired 되어서 없어짐)
            if (chatRoomIds == null || chatRoomIds.isEmpty()) {
                List dbChatRoomIds = chatRoomUserRepository.findChatRoomIdsByUserId(userId);
                chatRoomIdRedisTemplate.opsForSet().add(keyForUserId, dbChatRoomIds.toArray(new Long[0]));
                chatRoomIdRedisTemplate.expire(keyForUserId, 24 * 60 * 60, TimeUnit.SECONDS); // 24 hours TTL
                log.info("Cached {} chat room IDs for user {} in Redis with 24-hour expiry", dbChatRoomIds.size(), userId);
            }
            // Add chatRoomId to user's set
            chatRoomIdRedisTemplate.opsForSet().add(keyForUserId, chatRoomId);
            chatRoomIdRedisTemplate.expire(keyForUserId, 24 * 60 * 60, TimeUnit.SECONDS); // 24 hours TTL
            log.info("Added chatRoomId {} to user {} in Redis with 24-hour expiry", chatRoomId, userId);
    
    
            String keyForChatRoomId = "chatroom_users:" + chatRoomId;
            Set userIds = userIdRedisTemplate.opsForSet().members(keyForChatRoomId);
            //레디스에 없으면 디비에서 불러오기.(expired 되어서 없어짐)
            if (userIds == null || userIds.isEmpty()) {
                List dbUserIds = chatRoomUserRepository.findUserIdsByChatRoomId(chatRoomId);
                userIdRedisTemplate.opsForSet().add(keyForChatRoomId, dbUserIds.toArray(new Integer[0]));
                userIdRedisTemplate.expire(keyForChatRoomId, 24 * 60 * 60, TimeUnit.SECONDS); // 24 hours TTL
                log.info("Cached {} user IDs for chat room {} in Redis with 24-hour expiry", dbUserIds.size(), chatRoomId);
            }
    
            // Add userId to chat room's set
            userIdRedisTemplate.opsForSet().add(keyForChatRoomId, userId);
            userIdRedisTemplate.expire(keyForChatRoomId, 24 * 60 * 60, TimeUnit.SECONDS); // 24 hours TTL
            log.info("Added userId {} to chatRoomId {} in Redis with 24-hour expiry", userId, chatRoomId);
        }
                  
결과:
  • 신규 채팅방의 읽음 상태도 정상 반영.

요약

문제 해결:
  • 실시간 읽음/읽지 않음 표시, MySQL 과부하 방지, 신규 채팅방 지원.
Redis:
  • "lastReadTimer::"ChatRoomUserDTO 저장, 빠른 상태 관리.
프론트엔드:
  • 액티비티 이벤트로 활성/비활성 트래킹, WebSocket 전송.
MySQL:
  • 세션 종료 시 Redis 데이터 동기화로 영구 저장.
결과:
  • Redis로 성능 최적화, 모든 채팅방에서 실시간 읽음 상태 반영.


Posted on 2025.03.01