Spring 라이브 채팅 : Flow & 아키텍처 설계

Yeonghwan·2024년 10월 13일
2

채팅

목록 보기
1/1
post-thumbnail

라이브 채팅 도입 배경

Dears는 웨딩 시장 정보 비대칭을 해결하는 SW 마에스트로 15기 팀입니다. 입소문에 의지하던 기존 시장의 정보 비대칭을 해소하기 위해, Dears에서는 웨딩플래너들의 정보를 비교하고 바로 라이브채팅으로 상담할 수 있어야 했습니다.
채팅 기능은 웹소켓, 푸시 알림 등 기능만 구현하기에도 난해한 과제였습니다. 하지만 저희 서비스에 라이브채팅이 꼭 필요하다고 판단 했고, Comport Zone을 벗어나기로 결정했습니다.

자체 구현 vs Sendbird API

센드버드는 채팅 서비스를 도입할 때 가장 우선적으로 고려되는 API 서비스입니다.
하지만 센드버드는 무료정책에서 최대 20명의 동시 접속만 허용하며, 메시지 읽음 처리 기능을 사용하려면 월 600달러를 지불해야 합니다.
때문에 저희는 읽음 처리와 푸시알림 추가, 데이터 수집 등의 목적을 위해 센드버드 API가 아닌 자체 구현을 선택했습니다.

플로우 설계

고려해야 하는 상황

1. 메세지 전송

메세지는 HTTP 통신이 아닌 웹소켓 통신을 선택했습니다.
웹소켓은 특정 포트를 서버와 클라이언트가 연결하여 실시간 양방향 통신을 가능하게끔 하는 연결 방식입니다.

저희는 웹소켓의 STOMP 프로토콜을 채택했는데요. STOMP는 클라이언트-서버 간 주고 받는 메세지의 유형, 내용 등을 정의하는 규약입니다.
기본적으로 pub/sub 구조로 되어 있어서 메세지를 브로커의 특정 토픽(ex. 채팅방)에 넣어두면, 브로커가 토픽을 구독하는 클라이언트에게 전송하는 구조입니다.

COMMAND 
header1:value1 
header2:value2 

Body^@

STOMP 프로토콜의 Frame은 다음과 같이 구성되는데요.

  • COMMAND : CONNECT / SEND / DISCONNECT
  • header : key:value 형태의 헤더
  • body(payload): 데이터

보통 Spring에서 STOMP 통신을 구현할 때는 Frame을 직접 구현할 필요 없이 SimpMessagingTemplate 이라는 객체를 조작하는 방식으로 간단하게 body를 담아 보낼 수 있습니다.

저희는 다음과 같이 STOMP header와 body에 들어갈 json 포맷을 확정했습니다.

# header
UUID : "550e8400-e29b-41d4-a716-446655440000"

# body
{
    "messageType": "SEND",
    "chatRoomId": 2,
    "senderRole": "CUSTOMER",
    "contents": "안녕하세요"
}

2. 메세지 읽음처리

처음에는 위와 같이 ChatRoomReadFlag Map을 일급컬렉션으로 관리하는 것이 좋겠다고 생각했습니다. 확장성을 고려했을 때, 메세지를 어디까지 읽었는지 pinpoint를 관리 및 갱신하는 것이 직관적으로 적합해보였습니다.

하지만 서비스 특성상 모든 채팅방은 1대1 채팅방이었고, 1대1 채팅방에서 ReadFlag는 하나의 Map 필드를 테이블 하나로 분리하는 것 같아 관리 대상이 많다고 느꼈습니다. 또한 속도가 중요한 채팅인 만큼, DB 접근을 한 번이라도 더 줄이는 것이 중요하겠다고 판단했습니다.

때문에 구조를 위과 같이 변경했습니다. ReadFlagChatRoom에서 관리할까도 싶었지만, 역시 1대1 채팅이기 때문에 메세지에 senderRoleoppositeReadFlag 필드를 두어 상대방이 읽은 메세지는 flag 처리하게 해주었습니다.

3. 푸시 알림

먼저 푸시 알림은 2가지 고민이 있었습니다.

  • 언제 푸시 알림을 보낼 것인가
  • 어떤 솔루션으로 푸시알림을 보낼 것인가

푸시알림을 언제 보내야 하나?

푸시알림은 보통 상대방이 채팅방에 없을 때 발송되어야 하는데, 상대방이 없는 경우를 어떻게 정의할 것인가가 고민이 필요한 부분입니다.

  • 상대방이 현재 메세지를 발송한 채팅방을 제외한 다른 화면일 때
  • 상대방의 STOMP 세션이 DISCONNECT 일 때

하지만 이는 너무 번거로운 체킹이어서, 직관적으로 상대방이 방에 있는 상황을 먼저 고려하고 이에 대한 not case에서 푸시알림을 전송하는 것으로 접근했습니다. 이 때 예상되는 문제점은 아래와 같았습니다.

  • 메세지를 보낼 때마다 읽기 연산이 수행되므로, 입장 여부가 캐싱되는 것이 유리함
  • DB를 사용할 경우, DB에 접근하는 동안 상대방의 입장 여부가 변경될 수 있음

위와 같은 이유로 상대방이 채팅방에 접속 중인지 계속 in-out을 관리하는 것은 RDB가 아닌 Redis를 사용했습니다.

어떤 솔루션으로 푸시알림을 보내야 하나?

그리고 솔루션의 경우 다음과 같이 3가지를 크게 고려할 수 있었습니다.

  • FCM : 무료, 구현이 쉽고 자료가 많다
  • One Signal : 대시보드, 마케팅 기능
  • Amazon SNS : AWS 통합 가능성, 커스텀 설정

저희는 당장 푸시알림 데이터 트래킹, 마케팅 등이 필요하지 않기도 했고 무료로 빠르게 구현할 수 있는 솔루션을 원하여 FCM을 선택했습니다.

Flow Diagram

앞서 고민한 상황들을 고려하여 위와 같이 플로우 다이어그램을 만들어보았습니다.
각 도메인과 Redis, Broker 등의 미들웨어 컴포넌트, 그리고 STOMP 동작이 유기적으로 이해되게끔 다이어그램을 작성하려 노력했습니다.
섹션 하나씩 자세히 설명해보겠습니다.

User & Session

  1. 먼저 예비부부(Cusotmer)와 웨딩플래너(WeddinPlanner) 모두 어플을 실행할 때 STOMP CONNECT 요청을 보내 클라이언트-서버 간 웹소켓 연결을 시작합니다.
  2. 이 때 정보는 세션에 저장되어 추후 푸시알림 분기 등에 사용됩니다.
  3. 채팅방에 입장하고 읽은 메세지는 읽음 처리 합니다.

저희 서비스는 채팅하기 버튼을 눌러서 채팅방 생성 및 입장이 가능하며 채팅 목록을 통해서도 입장이 가능하도록 원했습니다. 이를 위해서 채팅방이 개설되어 있다면 바로 입장하고, 채팅방이 개설되지 않았다면 개설 후 입장하도록 구현했습니다.

Server & Redis

  1. 유저 채팅방에 입장할 때, 그동안 읽지 않은 메세지들을 읽음 처리합니다.
  2. {chatRoomId: List<UUID>} 로 구성된 레디스 Set에 유저 UUID를 등록합니다.
  3. 메세지를 입력하여 전송합니다.
  4. 상대방의 온라인 여부를 체크하고 오프라인이라면 푸시알림을 전송합니다.
  5. 상대방이 온라인이고 같은 채팅방에 입장했다면 바로 읽음 처리합니다. 만약 상대방이 같은 채팅방에 없다면 푸시알림을 전송합니다.
  6. 채팅을 마친 후 채팅방에 퇴장합니다.
  7. 레디스 Set에서 유저 UUID를 제거합니다.

MQ Broker

  1. 만약 채팅방이 최초 개설되는 것이라면 웨딩플래너 Topic(weddingPlannerUUID)에 메세지를 publish 하여 추후 웨딩플래너 채팅방이 개설된 것을 알 수 있도록 합니다.
  2. 메세지를 입력하면 STOMP SEND 요청을 보내고 Broker가 해당 채팅방 Topic(chatRoomId)에 메세지를 publish 합니다.
  3. 어플을 실행하여 STOMP CONNECT 요청을 보낼 때, 동시에 내가 Subscribe한 채팅방에 쌓인 메세지들을 받아옵니다.
  4. 웨딩플래너의 경우, STOMP CONNECT 요청을 보낼 때, 동시에 자신이 속한 새로운 채팅방이 생겼는지 확인합니다.

오직 고객만 웨딩플래너에게 먼저 연락할 수 있는 특수한 구조로 인해 웨딩플래너 UUID 토픽을 따로 두게 되었습니다.

아키텍처 Overview

마무리

이번 글에서는 읽음 처리, 푸시알림 등을 고려한 채팅 아키텍처에 대한 고민들을 정리해보았습니다.
다음에는 실제로 구현 단계에서 마주한 문제점들과 그 해결방안을 코드레벨에서 소개해보겠습니다.

References

profile
Non-volatile Memory

0개의 댓글