채팅 도메인 고민과 의존성 문제(feat: Spring Event)

상황, 문제 정의

프로젝트 진행 도중 실시간 채팅 서비스를 구축 하는 과정을 정리해 봤습니다. 코드 보다는 채팅을 진행하는 과정에서 기술을 쓴 이유와 겪었던 고민이나 장애를 정리해봤습니다.

채팅 서비스에 웹 소켓 , STOMP PUB/SUB 구조를 통한 서비스를 구현 하기 까지  과정과  Redis도입 과정과 도메인 의존성 문제를 개선하는 과정과 최종적으로 이벤트 적용, 트랜잭션 분리, 비동기 처리하는 과정을 정리해 봤습니다.

먼저 채팅 서비스는 크게 3가지로 나누어져 있습니다.

  • STOMP ,Redis를 이용한 Pub/Sub구독
  • 채팅 로그(다른 회원에게 보낼 채팅 메세지 이 메세지는 DB에 저장한다.) 보내기
  • 추가적으로 채팅을 보낼 시 알림 계획

첫 번째 시도

첫 번째 먼저 든 생각은 채팅을 하기 위해서는 polling, websocket 두 가지 중 선택해야겠다는 생각이 들었습니다.

  • WebSocket 방식은 Http가아닌  WS프로토콜을 사용하고 단순한 API로 통신이 가능하고 양방향성과 실시간성을 가지고 있습니다.
  • polling 방식(Long polling포함)은 자체가 서버에 주기적으로 HTTP 요청을 보내는 방식입니다.

요청을 지속적 연결을 통해 보내는 것이 아닌 일정 시간을 가지고 보냅니다.

이점이 실시간 채팅과는 맞지 않는다는 생각이 들었습니다. 또한 오히려  데이터가 자주 변경되면 서버에 부담감을 주어 비용도 만만치 않다고 합니다.

이벤트가 발생하게 되면 클라이언트가 요청을 동시에 보내게 되기 때문에 서버의 부담이 급증하게 됩니다. 만약 ScaleOut으로 서버를 확장한다면 부담이 더 커지게 됩니다.

참고:윈도우 폴링vs웹 소켓

최종적으로는 양방향성과 실시간성, 확정성을 고려해 Spring Websoket과 STOMP를 활용했습니다.

  • 사실 뚜렷하게 목적이 없다면 Http 프로토콜을 사용하는 Polling 방식도 좋은 방식이라는 생각이 들었습니다.  WebSocket의 양방향 연결 자원에 대한 지속적인 업데이트가 필요하지 않다면 즉 지금 저의 채팅 서비스가 1:N이나 실시간이 그렇게 중요하지 않고 단순 1:1 기능만 있다면 굳이 Web Socket은 필요하지 않겠다는 생각을 했습니다.

내가 작성한 메세지를 상대방에게 실시간으로  전송해야 할 때 여러 사용자가 접속할 경우를 대비하기 위해서는 STOMP 또한 사용 해야 합니다. STOMP 사용할 경우

  • 클라이언트와 서버 간의 통신에서 일관성을 유지(메시지 형식을 지정할 수 있습니다.)
  • STOMP의 PUB/SUB을 사용해  메세지 처리에 용이합니다.
  • 메시지 브로커 기능을 수행할 수 있습니다.

Spring에 있는**@EnableWebScoketMessageBroker** 어노테이션을 활용해 구현했습니다.

참고: Stomp

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/gs-guide-websocket");
  }

}

문제 발생

WebSocket STOMP를 활용해 구독/발행(토픽 기준)을 통한 웹소켓 연결  채팅을 simple WebSocket Client을 통해 잘 전달 되는 것 까지 확인했지만.

지금 현재 환경에서의 메시지 브로커, 메시지 큐는 스프링 부트 서버의 내부 메모리에 존재하기 때문에 만약 다수의 서버가 있을 경우 운영하기 쉽지 않다는 문제점이 발생합니다.

그렇다면 이 시점에 일종의 외부 메시지 브로커 기능을 하는 서버를 띄울 필요가 있다는 생각이 들었습니다.

스프링 문서의 그림(외부 브로커 연동전)

(외부 브로커 연동 후)

위의 그림과 같이 메시지 발행 시 인 메모리 기반일 경우 유실 될 가능성이 있지만 외부 브로커를 통해 PUB/SUB 하도록 합니다.  또한 메시지 브로커를 통해 구독하기 때문에  수평적 확장을 통해 인프라를 구축할 때 더 용이 합니다.

RabbitMQ, Redis, kafka 등이 많이 쓰이지만 Rdis를 도입 하기로 했습니다. 캐시의 역할도 가능하기 때문에 이를 생각했습니다. (이후에 진행하며 여기 서도 문제를 겪게 되는데요. redis는 인 메모리 기반이기 때문에 서버가 다운 될시 Redis내의 모든 데이터가 날라 가는 경우가 생기기도 합니다.)

또한 Redis-Cluster를 활용해 분산 저장이 가능합니다.

즉 단순 Spring Stomp를 이용한 인 메모리 저장 방식에서 벗어나 외부 Message Broker를 활용하여 아래와 같은 이점을 챙겼습니다.

  • 수평적 확장 시 이점
  • 서버가 다수일 경우 한 곳에서 PUB/SUB 하기 때문에 관리하기 용이하다.
  • 인 메모리 방식이 아닐 경우 PUB/SUB 할 때 내용을 저장 가능하다.

다음 WebSocketController를 구현 한 후 소켓을 통해 메시지가 들어오면 받아서 해당되는 채널로 전달하도록 합니다. 채팅 방 정보나 채팅 방 로그는 Mysql에 저장하도록 합니다.

참고

메시지/이벤트 브로커 두 가지로 구분할 수 있습니다.

Kafka(이벤트 브로커 ,메시지 브로커), RabbitMQ,Redis(메세지 브로커)• 메시지 브로커는 이벤트 브로커가 될 수 없으나, 이벤트 브로커는 메시지 브로커가 될 수 있습니다(메시지는 삭제되고, 이벤트는 삭제되지 않기 때문)

두 번째 시도

위의 상황을 정리해 보자면 Web Socket, Stomp 프로토콜을 사용하였고 Spring에서 지원해주는 내부 메시지 브로커 기능을 외부 메시지 브로커(Redis)를 사용하는 단계까지 진행했습니다.

이를 토대로 채팅 도메인을 개발하였습니다. 구조를 간단하게 요약하자면 밑과 같습니다.

⎿ product// 중고 상품.
⎿ member // 회원.
⎿ town// 동네 지역.
⎿ oauth // 회원 인증인가.
⎿ interested// 관심 상품.
///채팅 도메인
⎿ chatlog// 채팅 로그.
⎿ chatroom// 채팅 방.

chatlog와 chatroom 도메인 쪽의 기능을 크게

  • 채팅 읽기,보내기, 방 만들기 기능과 추가적으로 알림 ,안 읽은 메시지를 기획 했습니다.

문제 발생

하지만 여기서 추가적인 문제가 발생합니다. ChatService부분에서 여러 의존성이 겹치는 구간이 생겼습니다.

public class ChatService {

    private final ChannelTopic channelTopic;
    private final ChatRoomRedisRepository chatRoomRedisRepository;
    private final MemberRepository memberRepository;
    private final ProductService productService;
    private final ChatRoomService chatRoomService;
    private final RedisTemplate redisTemplate;
		.... 생략
}

또한 채팅 서비스 로직이 한 트렌젝션이 모두 묶이다 보니 여러 문제가 발생했는데요.

간단하게 의사 코드로 정리 해보자면

  • 사용자가 채팅 메시지를 발송한다.
  • 채팅 알람을 상대방이 받을 수 있다.
  • 채팅 메시지가 저장되어야 한다.
  • 채팅방 MetaInfo를 ‘안 읽은 메시지’ 개수와 ‘채팅방에 마지막으로 발송된 메시지’가 갱신되어야 한다.

원인

해결 방법

'서비스 간의 강한 의존성을 줄이자'

결과