,

[채팅 백엔드 설계] 게이트웨이를 일부러 멍청하게 — 실시간 릴레이와 영속화를 분리한 이유

이 글의 위치: 기술 블로그 → Backend · 채팅 백엔드 설계 시리즈 1편. arm64 OKE에 배포한 바로 그 Go 백엔드의 내부 이야기다. 인프라를 어떻게 올렸는지는 OKE 인프라 학습 시리즈를 참고.

이 백엔드는 실시간 채팅과 게시글/피드를 다루는 멀티테넌트 SNS 백엔드다. Go 1.25 / Gin / Clean + Hexagonal 구조의 모노레포(go.work)이고, 서비스는 넷으로 나뉜다.

서비스 포트 책임 저장소
user-management 8081 JWT 발급, 사용자/프로필, 팔로우, 게스트 인증, 앱(테넌트) 레지스트리 Postgres
chat-management 8082 방/메시지 영속화, 멤버십, 읽음 커서, 이벤트 발행 Postgres, RabbitMQ
post-management 8083 게시글/댓글/좋아요/피드 Postgres
room-gateway 8032 실시간 WebSocket 릴레이(dumb pipe) + 크로스 인스턴스 fan-out RabbitMQ

이 글의 주인공은 마지막 room-gateway다. 그리고 이 서비스의 설계 원칙은 딱 한 줄로 요약된다 — 일부러 멍청하게 만든다.

문제 — 실시간과 영속화를 한 서비스에 묶으면

채팅 메시지 하나가 하는 일을 순진하게 구현하면 이렇게 된다: WebSocket으로 메시지를 받아 → DB에 저장하고 → 같은 방의 다른 연결들에 전달한다. 한 서비스가 실시간 전달과 영속화를 모두 책임진다.

이 구조는 두 가지가 아프다.

  1. DB 장애가 실시간을 죽인다. 저장이 실패하면 릴레이까지 막힌다. 채팅에서 "지금 이 순간 전달"은 "영구 보관"보다 중요할 때가 많은데, 둘이 한 트랜잭션에 묶여 함께 쓰러진다.
  2. 스케일링이 어렵다. WebSocket은 상태(연결)를 가진다. 거기에 DB 로직·비즈니스 규칙까지 얹으면, 상태 서비스가 무거워져 수평 확장이 까다로워진다.

결정 — 게이트웨이는 릴레이만, 영속화는 별도 경로

room-gateway에서 DB와 비즈니스 로직을 완전히 걷어냈다. 게이트웨이가 아는 것은 딱 세 가지 — JWT를 검증하고, 방에 join/leave 시키고, 받은 메시지를 같은 방 연결들에 send 한다. 그게 전부다. 영속화는 게이트웨이가 하지 않는다.

대신 클라이언트가 두 경로에 이중 쓰기(dual write) 한다.

  • (a) WS로 게이트웨이에 send → 실시간으로 같은 방에 릴레이
  • (b) REST로 chat-management에 POST → Postgres에 영속화

두 경로는 서로 독립적이다. 그래서 chat-management(영속화)가 장애가 나도 실시간 전달은 계속된다. 반대로 게이트웨이가 재시작돼 실시간 메시지를 놓쳐도, REST로 저장된 기록을 클라이언트가 다시 조회하면 복구된다. 실시간은 at-most-once, 영속화는 신뢰성 경로 — 역할을 명확히 나눈 것이다.

게이트웨이의 send 처리는 정말로 두 줄이다. 로컬 방에 broadcast하고, 다른 인스턴스로 fan-out publish 한다. (fan-out은 다음 편에서 다룬다.)

// handleSend — 로컬 broadcast + 크로스 인스턴스 publish 가 전부
func (s *Server) handleSend(room *Room, data []byte) {
    room.Broadcast(data)        // 이 인스턴스에 붙은 같은 방 연결들
    s.fanout.Publish(room, data) // 다른 게이트웨이 인스턴스들
}

DB 호출도, 멤버십 검증도, 메시지 파싱도 여기엔 없다. 멤버십 확인·멱등 저장 같은 "규칙"은 전부 영속화 경로(chat-management)로 밀어 넣었다.

graceful degradation — MQ가 없으면 단일 인스턴스로

fan-out은 RabbitMQ에 의존한다. 그런데 게이트웨이는 RabbitMQ가 없어도 죽지 않는다. 조립 루트에서 MQ 핸들이 nil이면 fan-out을 건너뛰고 단일 인스턴스로 동작한다.

// cmd/main.go — "RabbitMQ is optional: without it the gateway runs single-instance"
if mqConn != nil {
    fanout = ws.NewFanout(mqConn, instanceID)
}

로컬 개발이나 소규모 데모에선 브로커를 안 띄워도 그대로 돌아간다. 의존성을 "있으면 확장, 없으면 축소"로 둔 셈이다.

이 설계가 주는 것

  • 격리: 영속화 장애 ↔ 실시간 전달이 서로를 막지 않는다.
  • 가벼운 상태 서비스: 게이트웨이는 연결 맵만 갖는 얇은 서비스 → 수평 확장이 쉽다.
  • 명확한 책임: "규칙"은 chat-management, "전달"은 gateway. Hexagonal 경계가 서비스 단위로 드러난다.

물론 대가도 있다. 이중 쓰기는 클라이언트에 책임을 넘긴다 — 같은 메시지가 WS 릴레이로 되돌아와 중복될 수 있다. 이 중복을 클라이언트가 어떻게 지우는지는 Bibleana 낙관적 UI 편에서, 서버가 재시도 중복을 어떻게 막는지는 멱등키로 다룬다.

다음 편은 게이트웨이가 "가볍다"고 해서 단순하진 않다는 이야기 — WebSocket 게이트웨이의 동시성 설계로 이어간다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다