기본적인 Pub/Sub 채팅 서버 구현이 완료된 이후, 추가기능으로 카카오톡처럼 안읽은사람의 count를 구현해보기로 결정했다. 해당 기능 구

현에 필요함에 더해, 몇 가지 이유로 redis를 사용하기로 결정했다. 이 글에서 관련한 내용을 정리하려고 한다.

 

1. Redis 도입

- 최근 채팅 저장 용도 : 우리 서비스는 채팅 기록을 하나의 Mysql 테이블에 쌓고 있다. 따라서 채팅방 A,B,C,D에서 채팅이 활발할 경우, DB 삽입이 자주 일어나 다른 채팅방에 입장한 사람이 채팅방의 최근 채팅 목록을 조회하는 것이 느려질 수 있다고 생각했다. redis 역시 싱글스레드이므로 많은 쓰기 요청이 있을 경우 해당 요청들에 의해 block이 있긴 하겠지만, 디스크I/O보다 빠를 것이고, 채팅방 별 최근 채팅내역을 저장하는 상황에서는 조회도 훨씬 빠를 것이기에 성능상의 이점이 있을 것이라고 생각했다.(성능을 비교하여 검증하진 못함. 다만, 다른 조에서 구현했던 채팅 서버에 도배가 일어났을 때 채팅 로드 속도가 많이 저하됐던 것을 기억하여 이렇게 판단)

 

==> {chatId}:recentChat key에 chatMessageOutput object를 50개의 limit을 가지는 List로 저장하기로 결정

redis keys

- WebSocket Disconnect handling 용도 : 아래 작성할 안읽은 유저 count 구현 로직에서, 특정 유저가 채팅방의 구독을 끊었을 때, 채팅방의 마지막 메시지(유저가 읽은 마지막 메시지)를 저장하게 되었다. 유저가 얌전하게 채팅방의 구독을 종료해준다는 가정이 있다면 프론트에서 해당 시점에 chatId와 유저정보를 넘겨주면 되겠지만, 대부분의 경우는 구독이 되어있는 상황에서 브라우저를 닫아 websocket connection이 종료되는 상황일 것이다. websocket connection이 끊어지는 경우에 서버에서 알 수 있는 것은 DISCONNECT로 오는 stomp message 헤더에 있는 sessionId 뿐이다. 따라서, 이런 경우 구독되어있는 목록을 확인하고 UNSUBSCRIBE를 핸들링 해주기 위해 세션정보와 구독정보 등을 담을 저장소가 필요했고, 이에 적절한 것이 redis라고 판단했다. 

 

 ==> 이전 글에서 사용한 JWT 인증방식을 이제 CONNECT 요청에서만 검증하고, 해당 유저 정보와 sessionId를 묶어 redis에 저장한다.

redis template

 

추가로 채팅 안읽음 count 구현에 필요한 몇 가지를 redis에 저장한다.

 

 

2. 채팅 안읽은 숫자 구현

고민해야 할 부분은 다음과 같다.

(1) U 유저가 A 채팅방에 오래간만에 들어갔을 경우, A 채팅방을 보고 있는 사람들에게 U 유저가 읽은 메시지들의 숫자가 감소되어야 함

(2) 모든 채팅 기록들의 readCount를 나중에도 알 수 있어야함

(3) 채팅방 구성원의 변동에 대응할 수 있어야함

 

 

■구현 방법 구상

(1)과 관련하여, 무조건 유저가 해당 채팅방에 마지막으로 어떤 메시지를 읽었는지는 알아야함

(1-1) 중간테이블을 만들어 chatId, userId, lastMessageId를 기록

         ->  A 유저가 채팅창을 보고 있는 경우 메시지 하나마다  읽었다는 기록이 서버로 오고, 테이블을 update 해주어야함

(1-2) chatJoinEntry 테이블에 lastMessageId 컬럼을 추가

         ->DB 구성이 이상해지는 것 같고, 1-1과 동일한 문제 발생

 

==>공통적으로 lastMessage를 너무 자주 업데이트 해주어야 한다는 문제가 있다.

 

(2)와 관련하여 메시지별 readCount를 저장하거나, 시점에 관계없이 계산할 수 있는 로직이 필요

(2-1) 메시지 record 별 readCount 컬럼을 가지게 한다.

        -> 메시지 안읽은 메시지가 1000개인 사람이 채팅방에 들어오면 1000개의 row를 테이블에서 업데이트 해줘야함

(2-2)  A메시지의 readCount는 해당 채팅방에서 lastMessageId가 A메시지의 id보다 큰 사람일 것이므로, lastMessage를 통해 readCount를 계산하는 방법

        -> 조금 더 그럴듯해 보인다. 하지만, (1-1)의 문제와 마찬가지로 메시지가 하나 전송될 때마다 n명의 lastMessage를 모두 업데이트 해줘야하는 문제점이 동일하게 있음

 

----------------------------------------------------------------------------------------------------------------------------------------------------

 

개선안) 채팅방 구성원을 active 유저와 inactive 유저로 나누어 관리, active -> inactive(UNSUBSCRIBE) 시에만 lastReadMessage를 담아 저장. 메시지 m에 대한 readCount는

active 유저의 수 + inactive 유저 중 lastReadMessage Id > m.id

 

==> (3)과 관련해서도 readCount를 그 때 그 때 세는 방식이므로 구성원의 변경에 대응 가능

----------------------------------------------------------------------------------------------------------------------------------------------------

그림으로 나타내면 다음과 같다

Redis의 sorted set 자료구조를 사용하여 ZCOUNT(messageId, inf)를 구하면 inactive user들의 readcount를 구할 수 있다.

 

 

■구현

STOMP handler에서 unsubscribe를 처리해주는 메소드

ChannelInterceptor에서 command가 UNSUBSCRIBE일 경우와, DISCONNECT일 때 unsubscribe를 처리해주는 메소드

- sessionId와 subId를 통해 어느 채팅방에 구독되어있는지 확인한 후, Inactive(Unsubscribed) 유저의 lastMessage를 저장하는 메소드

- chatId와 lastMessage를 확인한 후, 실제 저장하는 addUnsubscribeUser 메소드를 호출한다

- 지금 보니 lastMessageId를 구하는데, 파라미터로 넘기지 않고 addUnsubscribeUser 메소드 내에서 lastMessage를 한번 더 확인하게 되어있다. 리팩토링 해야할듯 ?

 

redis에 lastMessage를 저장하는 메소드

-UnsubscribedUser는 {chatId}:lastMessage 의 key를 가지는 sorted set에 저장된다. 채팅방별로 recentchat 50개까지를 redis에 저장하고 있으므로 recentChat이 없으면 lastReadMessage를 0L로 설정 (유저가 채팅방에 가입할 경우 참여 메시지가 저장되므로 사실상 일어날 일은 없긴 하다)

 

리팩토링 필수 ;;;

subscribe 요청이 전달되고 난 후

- sessionId로 userInfo 가져옴

- redis에서 chatId에 대한 유저의 lastReadMessage 가져옴(message로 프론트에 전달, 안읽은 count 렌더링 위함)

- UnsubscribedUser(lastMessage 저장한 부분)에서 유저 삭제 (이제 active 유저이므로)

- SubscriptionChatId set에 유저Id 저장 (active user count)

 

messageId를 받아서 읽은사람 count

 

 

3.결과물 & 정리

테스트 결과

여러가지로 테스트 해본 결과 count는 정확하게 계산이 되고, 유저의 변동에도 대처할 수 있었다. (참가, 탈퇴시에 redis에

inactive 유저 관리를 해주어야 함)

 

구현 전에 생각했던 것과는 다르게 예상하지 못한 변수들이 많아 오래걸렸고, 코드도 마음에 들지 않게 됐다.

 

1. unsubscribe시에 lastMessage를 저장하면 될 것이라고 생각했는데, 실제로는 유저가 채팅창을 닫아서 unsubscribe 한 뒤에 브라우저를 종료하는 것이 아니라 그냥 채팅 중에 브라우저를 종료할 수 있다. 따라서 DISCONNECT 메시지를 받으면 유저가 subscribe중인 채팅방이 있는지 확인을 해서 서버에서 unsubscribe를 처리해주어야 한다.

DISCONNECT시에 유저의 subscription을 확인해서 unsubscribe처리를 하고 lastMessage를 저장하려면, SessionId를 통해 유저가 구독중인 채팅방을 알아야한다. (DISCONNECT시에 알 수 있는 것은 sessionId밖에 없음)

==> sessionId의 sub-Id, destination은 STOMP 내부에서 <Map, <Map, String>>의 형태로 관리되는데, 여기에 접근할 방법을 찾지 못해서 redis에 내가 별도로 저장해주어야 했다. 따라서 유저가 connect, disconnect할 때마다 redis에 삽입,삭제 등이 일어나게 되는데, 코드도 지저분해지고 중간에 프로세스가 꼬일 가능성 ↑, redis 병목현상 가능성도 ↑ 되었다. 성능 테스트 이후에 코드를 변경할 필요성이 있음

 

2. 메시지별 readCount를 계산할 때 메시지 개수만큼 redis에 요청이 가는 것이 별로인 것 같다. 원하는 범위의 zrange를 가져와서 내부적으로 계산하고싶은데, 추후에 리팩토링 하는 것으로 하고 일단은 메시지별로 redis에서 count를 계산해오는 방식으로 구현했다. 메시지들의 readCount를 계산하는 도중에 구독정보가 바뀌게 되면 같은 시점의 readCount를 계산한 것이 아니므로 오차가 발생하게 됨. (수정 대상, 어렵지 않게 할 수 있을듯)

 

3. Dynamic Programming의 개념으로 메시지별 readCount를 계산할 때 readCount가 변경되는 시점 (정렬된 Score가 distinct한 값을 가질 때) 에서만 이전  메시지의 readCount를 활용해 계산을 하려고 생각했으나, redis sortedset을 distinct score 별로 조회할 방법이 없다..

 ==> 어떤 방법으로 저장해야 더 효율적일지 고민 필요할 것 같다. -> 테스트 이후 고민해보기로 함 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

+ Recent posts