본문 바로가기

사이드 프로젝트

웹 채팅 프로그램 개발 프로젝트 후기 - 1

안녕하세요.

웹 기반 채팅 프로그램을 개발한 사이드 프로젝트 과정과 후기를 남겨봅니다.

회사에서 웹 소켓과 관련된 업무를 담당하며 해당 기술에 관심을 갖게 되었고, 해당 기술 관련 개발 역량을 기르고자 시작한 프로젝트입니다.

Backend와 Infrastructure에 초점을 맞춘 글이며, 프로그램의 구성, 주요 기능 구현 방식, 그리고 성능 분석 및 트러블 슈팅 과정에 대해 구체적으로 설명했습니다.

 

 프로젝트 진행 과정을 리마인드하고 해당 분야에 관심있는 다른 방문자님들께 내용 공유하기 위한 목적으로 작성했습니다. 특정 부분에 대해서 더 궁금하다, 내용 설명이 잘 이해가 안된다, 부연 설명이 필요하다, 보완이 필요한 설명이다, 등등 모든 피드백은 주시면 감사히 받겠습니다.

 

 아래 링크에서 프로젝트의 전체 소스를 확인할 수 있고, README를 참고하여 Docker Compose로 로컬환경에서 실행하여 데모해보실 수 있습니다.

 

[Github Link] https://github.com/chrismrkr/chat-app

 

1. 사용 기술

  • Backend
    • Java 17, Spring Boot, Spring Web MVC, Spring STOMP Websocket, Spring Data JPA
  • Infrastructure
    • MySQL, Redis, Elastic Search, RabbitMQ, Kafka, Docker, Nginx
  • Frontend
    • Javascript, STOMP Client, React, HTML, CSS

 

2. 프로그램 구성

채팅 프로그램 구성도

 

프로그램 구성도는 위와 같고, 주요 기능 및 요구사항은 아래와 같이 정의하였습니다.

보편적으로 사람들이 알고 있는 채팅방 기능을 최대한 녹여내고자 하였습니다. 예를 들어, 카카오톡은 사용자가 여러 채팅방 생성 및 참여할 수 있도록 지원하고, 사용자가 카카오톡을 종료하고 다시 실행하더라도 이전에 본인이 참여했던 채팅방 목록 및 채팅 내용을 모두 확인할 수 있습니다. 이러한 내용을 최대한 요구사항에 반영하여 구현했습니다.

  • 유저 간 채팅 메세지 송수신 기능
    • 채팅방 내에서 사용자끼리 Web Socket을 통해 메세지를 안정적으로 주고받을 수 있다.
  • 채팅 메세지 저장 기능
    • 채팅방에 남은 메세지는 Elastic Search에 보관한다.
  • 채팅방 생성 기능
    • 사용자는 여러 채팅방을 생성할 수 있다.
    • 생성된 채팅방 엔티티(ChatRoom)는 MySQL에 저장된다.
    • 채팅방을 생성한다는 것은 채팅방에 최초 입장했다는 것을 의미하므로 사용자가 채팅방에 입장한 정보(MemberChatRoom)도 MySQL에 저장한다.
  • 채팅방 입장 기능
    • 사용자는 여러 채팅방에 입장할 수 있다.
    • 사용자(Member)와 채팅방(ChatRoom)은 다대다 관계이다.
    • 사용자가 채팅방에 입장한 이력(MemberChatRoom)이 MySQL에 저장된다.
      • (MemberId, RoomId, InitialEnterDateTime)
    • 사용자가 기존에 입장했던 채팅방에 재입장 시, 최초 입장한 시간 이후에 송수신된 모든 채팅 메세지를 조회하여 보여준다.
      • 13시에 최초 입장했던 사용자가 채팅방에 14시에 다시 입장하면, 13시 ~ 14시 사이에 송수신된 채팅 메세지를 볼 수 있어야 함
  • 채팅방 퇴장 기능
    • 사용자가 채팅방에서 퇴장하면, MySQL에 저장된 채팅방 입장 이력(MemberChatRoom)을 삭제한다.
    • 채팅방 퇴장 이후, 해당 채팅방에 남아있는 사용자가 없다면 채팅방(ChatRoom)을 MySQL에서 삭제한다.

 

3. 주요 기능 구현 방식

  • 1. 유저 간 채팅 메세지 송수신 기능

 Spring STOMP WebSocket을 활용하여 채팅 메세지 송수신 기능을 구현했습니다. 해당 기술은 Spring Framework에서 지원하는 기술로 PUB/SUB 형식으로 클라이언트끼리 웹 소켓을 통해 메세지를 주고받습니다.

 다만, 채팅 메세지 송수신 기능을 제공하는 서버 컨테이너가 여러 대인 상황에서는 아래와 같은 문제가 있었습니다. 

3명의 클라이언트가 id가 1번인 채팅방에 참여하고 있는 상황

 

 위는 3명의 클라이언트가 동일하게 id가 1인 채팅방에 입장한 상황입니다. 만약 Client1이 1번 채팅방으로 메세지를 보내면, Client 1, 2, 3 모두 해당 메세지를 수신(SUB)해야 합니다. 하지만, 지금 상황에서는 Client3은 Client1과 다른 컨테이너에 연결된 상태이므로 메세지를 받을 수 없습니다. 현재 상황에서는 Client1이 보낸 메세지는 Client1, 2만 수신하게 됩니다.

 이를 해결하기 위해서 컨테이너 간 메세지를 주고받는 것이 필요했습니다. Spring STOMP WebSocket에서는 RabbitMQ Message Broker를 통해 서버 간 메세지를 주고받을 수 있도록 지원하고 있었습니다. 그래서 RabbitMQ를 컨테이너 간 External Message Broker로 활용하여 위 문제를 해결할 수 있었습니다.

  Spring STOMP WebSocket과 RabbitMQ Message Broker와 관련된 내용은 아래 링크에 정리하였습니다.

 

[Github Link] https://github.com/chrismrkr/WIL/blob/main/Spring/websockets/websockets-summary.md#8-external-broker

 

WIL/Spring/websockets/websockets-summary.md at main · chrismrkr/WIL

what I Learned. Contribute to chrismrkr/WIL development by creating an account on GitHub.

github.com

 

 

 RabbitMQ를 Message Broker로 사용하기 위해, Producer 및 Consumer의 Ack 정책을 어떤 것으로 할 것인지에 대한 판단이 필요했습니다. 메세지가 External Broker에 전달되면, 그 메세지는 반드시 모든 클라이언트에 전달되어야 하므로 Manual Ack 정책을 선택했습니다.

  • Manual Ack: Consumer가 메세지를 수신에 성공하면 Message Broker에 Ack를 보낸다. 만약 Message Broker가 Ack를 받지 않으면 Consumer는 전송에 실패한 것으로 인식하여 메세지를 재전송함.

  Manual Ack는 클라이언트가 메세지를 반드시 받을 수 있다는 장점이 있지만, 동일한 메세지를 중복해서 받을 수 있다는 문제점도 있었습니다. 또한, RabbitMQ 공식 Document에서도 메세지 재전송에 대해 idempotence하게 처리할 것을 권고했습니다.

  • When manual acknowledgements are used, any delivery (message) that was not acked is automatically requeued when the channel (or connection) on which the delivery happened is closed.
    ...(중략)
    Due to this behavior, consumers must be prepared to handle redeliveries and otherwise be implemented with idempotence in mind. ...(중략)
  • RabbitMQ Document: https://www.rabbitmq.com/docs/confirms
 

Consumer Acknowledgements and Publisher Confirms | RabbitMQ

<!--

www.rabbitmq.com

 

 이 문제는 아래의 방법으로 메세지 중복 수신이 되지 않도록 해결하였습니다.

세션에서 현재 몇번째 Sequence까지 수신 완료했는지 지속적으로 기록하고, Sequence 순서에 맞지 않는 Message가 도착하면 이를 무시하는 방향으로 구현하였습니다.

Session History

  • 웹 소켓 컨테이너마다 위와 같이 Session History Map을 관리합니다.
  • 사용자가 채팅방에 입장하여 웹 소켓 컨테이너와 세션 연결이 되면, '특정 사용자가 다른 사용자에게 몇번째 Sequence까지 메세지를 받았는지'를 Session History Map 메모리에 저장합니다.
  • 예를 들어, Client 1, 2, 3이 동일한 채팅방에 입장하면, 서로 동일한 웹 소켓으로 연결됩니다.
  • Client 3이 Sequence 1 Message를 채팅방으로 전송하면, Client 1과 2는 이를 Session History에 기록합니다.
    • Client 1: Client-3-Session이 Sequence 1 Message를 송신하여 Client-1-Session에서 수신했음을 기록함
    • Client 2: Client-3-Session이 Sequence 1 Message를 송신하여 Client-2-Session에서 수신했음을 기록함
  • Client 3이 Sequence 2 Message를 채팅방으로 전송하면, Client 1과 2는 이를 Session History에 업데이트합니다.
    • Client 1: Cleint-3-Session이 Sequence 2 Message를 송신하여 Client-1-Session에서 수신했음을 기록함
    • Client 2: Cleint-3-Session이 Sequence 2 Message를 송신하여 Client-2-Session에서 수신했음을 기록함
  • Ack 수신 실패 등의 이유로 Client 3이 보낸 Sequence 2 Message가 다시 전송되면 에러가 발생합니다.
    • Client 1, 2: Client-3-Session으로 부터 마지막으로 수신 완료한 Sequence는 2 이므로 중복 수신으로 간주함

 또한, Session History 업데이트 되기 전에 동일한 Sequence 메세지를 받는 것도 방지하기 위해 Redis를 통한 분산 락을 구현하였습니다.

 

 메세지 중복 수신 방지를 위한 모든 메커니즘은 OutboundChannel의 Interceptor에서 동작하도록 하였습니다. 웹 소켓이 연결되면 세션마다 InboundChannel과 OutboundChannel을 갖게 되고, 각각 메세지 송신, 수신을 담당하는 채널입니다.

메세지 중복 수신 방지 기능은 부하 테스트 상황에서 그 역할을 제대로 해내는 것을 확인하였습니다. 

 

 

다음 편에서 또다른 주요 기능 구현 과정에 대해 이어서 설명하겠습니다. 감사합니다.