Redis pub/sub을 이용한 채팅 다시 돌아보기(1)

wellbeing-dough·2025년 1월 14일

문제상황

Redis pub/sub의 채팅 유실 문제 알아보기

Redis pub/sub은 기본적으로 채팅이 유실될 수 있는 문제가 있다.
케이스를 하나씩 살펴보자

  1. 구독자 연결 상태 문제
    구독자가 연결이 끊겨지거나 네트워크로 인해 메시지를 받을 수 없는 경우 메시지가 유실된다
    이게 가장 큰 이슈인데, 우리 서비스 기준, 레디스 입장에서 구독자는 ec2의 어플리케이션 프로세스 이다. 첫 구독이 시작되는 순간, ec2와 redis는 tcp기반 소켓으로 열리는데, 만약에 서버가 배포된다면? 소켓 연결이 끊기면서, 구독 리스트가 전부 사라지는 것이다.

  2. Redis의 비영구 저장
    Redis Pub/Sub은 메시지를 휘발성으로 다루며, 메시지의 상태를 영구적으로 저장하지 않는다. 메시지 큐(예: Kafka)와 달리 Pub/Sub에서는 메시지가 수신자에게 전달된 후 바로 삭제

  3. 구독자와 발행자의 동기화 문제
    Redis Pub/Sub은 구독자가 특정 채널을 구독한 후에만 메시지를 수신할 수 있음. 구독하기 전에 발행된 메시지는 유실

복합적으로 따져봤을때 가장 중요한 핵심은

구독이 끊기거나 유저의 네트워크 문제로 메시지를 못받으면 끊긴 동안에 메시지가 그대로 유실됨 그래서 다시 구독을 하거나 네트워크가 다시 연결되도 메시지는 못받아봄

그럼 구독은 언제 끊길까?

인프라를 다시 확인해보자

유저의 네트워크 문제는 어쩔수 없다 치자

서버가 redis의 채널에 구독을한다? 그럼 서버와 redis는 tcp로 연결되어있다. 만약에 배포를 해서 서버가 잠깐 내려간다? 그럼 tcp연결이 끊기는거고 구독은 자연스레 해지가 된다. 이는 배포할 때마다 모든 채널에 구독이 해제가 되어있는 것을 보고 확인을 했다.

그래서 최종적인 문제 정의

  1. 유저의 채팅중에 구독은 절대 끊기면 안됨
  2. 유저가 구독이 끊긴 후에 유실된 채팅을 어떻게 할건지 생각해야됨
  3. 배포할때, 모든 서버에 구독이 끊기는 문제를 해결해야됨

해결 과정

1. 유저가 채팅방에 들어오면 구독을 하고 나가면 구독을 끊자

웹소켓에 연결할때 클라이언트는 CONNECTED ACK가 떨어지면 바로 SUBSCRIBE로 구독을 하자 그리고 DISCONNECT가 오면 구독을 끊자
사실 UNSUBSCRIBE가 있긴하다 정석적으로는 유저가 채팅방을 나가면 UNSUBSCRIBE를 보내고 DISCONNECT를 보내는게 맞다 하지만 유저가 어플을 강제 종료 하거나 그랬을 때, 저 두개를 보낼 수 잇을까? 클라이언트 개발자분이 심히 염려된다 하시고 나도 어느정도 동의하기 때문에 DISCONECT만 빠르게 던져주면 서버에서 구독까지 해지해주자 나중에 UNSUBSCRIBE와 DISCONNECT의 책임과 역할이 더 커지면 분리하자

그럼 유저가 어플리케이션을 나간다면, 메시지를 어떻게 받지? 데이터베이스에 채팅 내역을 저장하니까 상관없다.

2. 채팅 기록은 데이터베이스에 저장하자

사실 채팅 기록을 전부 어플이나 웹에 저장하면 그 데이터가 얼마나 커질까?
유저가 어플을 삭제하면 그 기록도 전부 삭제되는거 아닌가? 우리서비스는 웹 앱 다 서비스하는데 웹에서 채팅내역과 앱에서 채팅 내역이 다르면? 절대안된다
일단 유저가 채팅할때 기록은 데이터베이스에 저장은 해야된다

그러면 하나는 해결된다. 유저가 채팅방을 보고 있을땐(여기서 보고있다는 뜻은 눈이 아닌 채팅 기능에 들어와 있는 상태) 구독을 해야하지만 채팅을 안보고 있을 땐, 데이터베이스에 저장하고 유저가 채팅방에 들어오면 채팅 내역 조회 api로 채팅을 안보고잇을 때, 왔던 채팅들을 볼 수 있어진다

채팅 내역은 join을 걸 필요가 없고 빠른 조회를 위해 mongoDB같은 NoSQL에 저장하는게 맞지만, 우리팀은 당장 인프라를 추가할 금액적 리소스가 없기 때문에 일단 RDBMS에 넣어놓자... 인덱스까지 걸었는데 성능 안나오고 유저가 많아져 돈이 많아지면 그때 마이그레이션 하자

3. 서버를 배포할 때 활성화된 채팅방을 전부 조회하고, 구독해볼까?

@PostContructor로 간단하게 구현할 수 있다. 하지만 채팅방은 활성화 되어있지만, 유저가 채팅방에 들어와있지 않은 상태라면 쓸모없는 구독이 생기고 이는 리소스 낭비가 아닐까?

4. 클라이언트에서 웹소켓이 끊기면 자동으로 웹소켓을 바로 재연결하자

우리의 인프라는 블루그린 무중단 배포다. 그렇더라도 서버가 재배포되면 웹소켓도 끊길 수 밖에 없다. 그럼 바로 웹소켓 재연결을 하면 배포된 서버에 연결이 될 것이도 연결과 동시에 해결 과정 1번에 있듯이 구독도 할 것이다.

이게 최선인가?

정말 이것만으로 유저의 채팅 유실 문제를 해결했다 생각할 수 있을까....
1. 만약에 알 수 없는 이유로 채널에 구독이 해지되면?
2. 만약에 알 수 없는 이유로 구독이 되어있지만 메시지가 유실된다면?
우리는 소개팅 서비스인데, 소개팅 전에 채팅하는데 누군가가 답장을 안하고 있다면... 만나기도 전에 서로의 신뢰가 떨어질 수 있는 심각한 문제가 될 수 있다

이정도면 그냥 kafka를 도입할 걸 그랬나? 금액적, 시간적 리소스가 장난이 아니라서 적절한 선이 필요하다

5. ping pong을 커스텀 하자

ping pong은 연결 상태 확인, Keep Alive 유지를 위해 필요하다 일정시간 동안 트래픽이 없다면 유휴 연결로 간주해 연결을 끊기 때문이다.
이 ping pong을 커스텀 해 보자, 우리는 클라이언트에서 ping을 보내고 서버에서 pong을 반환하기로 했다

  • 클라이언트는 20초마다 ping을 보내서 채팅방 접속 상태 알림
  • 서버는 ping이 오면 Redis에 세션 정보 저장 (TTL 40초)
  • 서버가 40초동안 ping을 받지 않았으면 Redis의 timeout으로 자동 세션 만료

이렇게 유저의 채팅 온라인 세션 관리를 할 것이다. 근데 여기서 하나 더 추가해보자

  • 유저가 메시지를 보내면 수신자의 세션을 확인함, 그때 세션에 메시지를 저장
  • 클라이언트가 ping을 보낼 때, 유저가 마지막으로 수신한 메시지를 서버에 전송
  • 서버는 ping을 받을 때, ping에 첨부된 메시지와 세션에 저장된 메시지가 다르면 pong이 아닌 “message mismatch”을 반환
  • 클라이언트는 “message mismatch”를 받으면 채팅 내역 다시 조회, 소켓 재연결, 재 구독 요청

이렇게하면 메시지 유실은 많이 개선할 수 있을 것 같다. 그림으로 간단하게 보면

한마디로 user1의 "내일 몇시?"가 유실됬는데, user2의 ping에서 마지막으로 받은 메시지가 "안녕" 이다
user1이 "내일 몇시?"를 보낼 때, 어플리케이션 서버에서 세션정보에

key: "user1:UUID1423,roomId:UUID123" value: 내일 몇시?

이렇게 저장이 되었을 거고 user2가 ping을 보낼 때, user2가 마지막으로 받은 메시지인 "안녕"을 보내겠지?

그럼 서버는 세션에 저장된 마지막 메시지와 클라이언트가 준 마지막 메시지가 다르면 pong이 아닌 message mismatch를 보낸다 그러면 클라이언트는 채팅 내역 다시 조회, 소켓 재연결, 재 구독 요청을 한다 이러면 메시지가 유실될 일은 크게 줄어든다

물론 이해를 돕기위해 텍스트 기반으로 설명했지만, 실제로는 텍스트가 아닌 유니크한 id를 기반으로 동작한다

해치웠나?

nope

구독자 연결 상태 문제에 대해서 해결을 했지만

아직 문제가 남아있다.

구독은 안끊겼는데, 중간 메시지가 유실된 경우는 어떻게 해결할 수 있을까?

2편에서 알아보자
https://velog.io/@wellbeing-dough/Redis-pubsub%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EB%8B%A4%EC%8B%9C-%EB%8F%8C%EC%95%84%EB%B3%B4%EA%B8%B0-2

0개의 댓글