채팅 SDK를 만들면서 팀원과 함께 어떻게하면 부하를 견뎌내면서도 채팅 순서를 지킬 수 있는 통신 방법에 대해서 고민해보았다.
[Frontend] → [API Gateway] → [Message Service] →
[Kafka/RabbitMQ] → [Consumer (Microservices)] → [Database]
만약 MSA환경이었다면 위와 같은 플로우로 채팅 서버를 설계할 수 있다. 요청이 오는대로 메세지서비스가 바로 DB에 CRUD를 하면 요청이 많을수록 DB 부하가 증가되고 → 트랜잭션이 밀려서 → 서비스 속도가 저하된다. 하지만 위와 같이 사이에 큐를 끼게되면 DB에 저장은 순서보장과 함께 비동기로 돌리면서 프론트에게는 응답을 바로 줄 수 있게 된다.
(1) [프론트] → (STOMP) → [백엔드 (메시지 서비스)]
- 클라이언트가 STOMP(WebSocket)으로 메시지를 보냄.
(2) [백엔드] → [Redis Queue] → (즉시 프론트에 응답)
- 메시지를 Redis Queue에 저장한 후, "메시지 전송 완료" 이벤트를 즉시 클라이언트에 반환.
(3) [Worker] → [DB 저장]
- Worker 프로세스가 Queue에서 메시지를 가져와 비동기적으로 DB에 저장.
(4) [DB 저장 완료] → [WebSocket으로 클라이언트에 저장 완료 이벤트 전송]
- 메시지가 DB에 저장되면, WebSocket을 통해 "저장 완료" 이벤트를 클라이언트에 전달.
현재 우리팀은 모놀리스 아키텍쳐 + 로드밸런싱으로 부하를 분산시키기로해서 고민해본 결과 위와 같은 구조로 채팅 시스템을 설계했다. 먼저 Stomp로 레디스큐에 저장하면 프론트에겐 응답을 바로 주고 레디스를 구독해서 비동기로 DB에 CRUD하는 방식이다. (설계하면서 프론트의 Optimistic Response랑 굉장히 유사하다는 느낌을 받았다.) 이러한 설계에서 프론트 코드에 직접적인 형향을 주는 부분은 Stomp이다. 그렇다면 Stomp는 무엇일까?
Stomp란?
STOMP(Simple/Streaming Text Oriented Messaging Protocol)는 클라이언트와 메시지 브로커 간의 통신을 단순화하는 프로토콜로 여러 기능을 손쉽게 쓸 수 있게 해준다. 그중에서도 Stomp의 가장 큰 장점은 Socket을 "메시지 브로커 기반의 Pub/Sub 구조" 기능으로 제공해준다는 것이다.
왜 소켓연결에서 pub/sub 기능이 우리에게 꼭 필요했을까? 바로 오픈채팅기능 때문이다. 오픈채팅의 경우 1명이 채팅방에 메세지를 보내면 여러명이 채팅을 실시간으로 읽을 수 있어야한다. 그냥 웹소켓을 쓸경우 백엔드에서 1대1 로직을 따로 작성해야하는데 Stomp의 경우 소켓통신을 "Topic"으로 하기 때문에 프론트는 Topic으로 Publish하면 백엔드는 이를 Redis나 Kafka/RabbitMQ로 메세지브로커에 퍼블리쉬하고 이를 Subscribe하게 되면 DB CRUD를 비동기로 돌려 부하에 견디기도 좋고 1대 다 실시간 메세지 시스템을 구축하는데 용이해진다. (현재 프로젝트의 경우 모놀리스로 Redis를 큐로 활용)
정리하자면 WebSocket이 단순 1:1 연결이라면, STOMP는 토픽(topic)을 활용하여 다중 구독 구조를 쉽게 구현할 수 있는 것이다.
Flutter에서 Stomp로 소켓통신하기
stomp_dart_client 패키지를 사용하면 flutter 에서 간편하게 Stomp 소켓통신을 할 수 있다.
1. STOMP 클라이언트 생성 및 초기화
STOMP 클라이언트가 WebSocket을 통해 서버에 연결될 때 기본적으로 WebSocket 핸드셰이크(Handshake) 과정이 먼저 이루어진다. 위 코드는 처음 connect할 때의 코드로 실행시키면 핸드셰이크 과정에서 CONNECT 프레임을 사용한다. (TCP 통신이기 때문에 패킷이 아니라 프레임이다!)
📌 STOMP 연결 요청 (CONNECT 프레임)
CONNECT
accept-version:1.2
host:example.com
login:user123
passcode:password
📌 서버 응답
CONNECTED
version:1.2
session:xyz-123
WebSocket 연결이 완료되면, STOMP 프로토콜이 추가적인 핸드셰이크(CONNECT → CONNECTED)를 수행하여 메시징 채널을 설정한다. Flutter에는 STOMP 클라이언트를 생성하고 stompClient.activate()를 호출하면, 이 모든 과정이 자동으로 수행된다!
2. STOMP 구독 (Subscribe)
connect 가 되었다면 해당 토픽을 구독하여 실시간 통신을 진행한다.
📌 Subscribe 프레임
SUBSCRIBE
id:sub-0
destination:/topic/chatroom123
id를 통해 구독을 식별하고 destination을 통해 메세지 받을 채너을 파낸다. 즉 클라이언트는 특정 주제를 구독하고, 서버는 해당 주제에 새로운 메시지가 도착하면 자동으로 구독자에게 데이터를 보낸다.
3. STOMP 메세지 전송 (Send)
STOMP에서 메시지를 보낼 때는 destination을 지정하여 특정 Topic 또는 Queue로 메시지를 보낸다.
📌 Send 프레임
SEND
destination:/app/chatroom123
content-type:application/json
{"sender": "Alice", "content": "Hello, World!"}
destination은 메시지를 보낼 대상 (ex: /app/chatroom123), content-type은 메시지 형식 (JSON, text 등), body는 실제 메시지 데이터를 나타낸다 메시지가 WebSocket을 통해 서버로 전송되면, 서버는 SUBSCRIBE한 모든 클라이언트에게 메시지를 전달한다.
3. STOMP 연결 해제
Stomp 연결해제할 때 주의할점은 WebSocket은 단순히 CLOSE 프레임을 보내는 것만으로 종료할 수 있다. 하지만, STOMP는 WebSocket 위에서 동작하는 메시징 프로토콜이므로, WebSocket을 닫기 전에 STOMP 세션을 먼저 정리해야 한다. (백엔드에서 처리해야할 일이라 프론트 코드는 그냥 deactiveate 만 실행시키면 되긴하다.)
1. [클라이언트] → WebSocket → [서버]
- STOMP `DISCONNECT` 프레임 전송
2. [서버] → 내부 처리
- 클라이언트 세션 삭제
3. [서버] → WebSocket → [클라이언트]
- WebSocket `CLOSE` 프레임 전송
4. [클라이언트 & 서버]
- WebSocket 연결 종료
📌 disconnect 프레임
DISCONNECT
receipt:msg-456
receipt는클라이언트가 서버로부터 연결 해제 확인을 받을 때 사용한다. 서버는 클라이언트가 DISCONNECT 요청을 보냈다는 사실을 인지하고 세션을 정리한다.
'Flutter' 카테고리의 다른 글
어떻게 플러터는 변경된 위젯만 콕찝어서 다시 페인팅할 수 있는걸까? (0) | 2025.02.02 |
---|---|
Stateless Widget 과 Stateful Widget 의 선택 기준 (0) | 2025.02.02 |
Package 버전관리 전략 (0) | 2025.01.27 |
Expanded 과 Flexible 의 차이 (0) | 2025.01.25 |
Segment 버튼에 애니메이션 적용하기 (0) | 2025.01.25 |