,

[채팅 백엔드 설계] 방이 두 pod에 흩어졌을 때 — RabbitMQ fan-out과 app_key 멀티테넌시

이 글의 위치: 기술 블로그 → Backend · 채팅 백엔드 설계 시리즈 3편. dumb pipe 게이트웨이동시성 있게 만들었다. 이제 그 게이트웨이를 HPA로 여러 pod로 늘린다. 그 순간 새 문제가 생긴다.

게이트웨이를 수평 확장하면 로드밸런서가 연결을 여러 pod에 분산한다. 그래서 같은 방의 두 사용자가 서로 다른 pod에 붙는 상황이 생긴다. pod A에 붙은 사람이 보낸 메시지를 pod B에 붙은 사람에게 어떻게 전달하나? sticky session만으로는 부족하다 — 방은 pod 경계를 넘나든다.

fan-out — local-first + origin 태그

해법은 RabbitMQ topic exchange를 통한 pod 간 fan-out이다. 각 게이트웨이 인스턴스는 exchange에 ephemeral(exclusive + auto-delete) 큐를 하나 바인딩한다. 인스턴스가 죽으면 큐도 자동으로 사라진다.

메시지 흐름은 이렇다.

  1. pod A가 메시지를 받으면 먼저 로컬 방에 broadcast(local-first) 한다.
  2. 동시에 envelope를 exchange에 publish 한다.
  3. 모든 pod가 그 envelope를 받는데, envelope의 Origin이 자기 자신이면 skip 한다(내가 방금 로컬로 뿌린 것이므로 중복).
  4. 나머지 pod는 자기에게 붙은 같은 방 연결들에 전달한다.
const fanoutRoutingKey = "room.broadcast"

type envelope struct {
    Origin string          `json:"origin"`
    RoomID string          `json:"room_id"`
    Data   json.RawMessage `json:"data"`
}

// 수신: 내가 보낸 것이면 중복이므로 버린다
if env.Origin == f.instanceID {
    return nil
}

함정 — 방 ID를 routing key로 쓰지 마라

여기 흥미로운 설계 결정이 하나 있다. 상식적으로는 routing key를 방 ID로 두고(room.<id>) 관심 있는 방만 구독하게 만들 것 같다. 그런데 이 백엔드는 routing key를 단일 room.broadcast로 고정하고, 방 ID는 envelope 안에 넣는다.

이유는 코드 주석에 명시돼 있다 — 임의의 opaque room id를 AMQP routing key로 쓰면 문자 제약에 걸린다. 방 ID가 어떤 문자를 담을지 모르는데, AMQP routing key는 허용 문자·구분자 규칙이 있다. 그래서 "라우팅은 단일 키로 단순하게, 필터링은 애플리케이션에서(origin·room_id 비교)" 로 뒤집었다. 모든 pod가 모든 broadcast를 받지만, envelope를 열어 자기 방 것만 처리한다. pod 수가 많지 않은 규모에선 이게 훨씬 견고하다.

멀티테넌시 — app_key를 JWT claim + 모든 테이블 스코프로

이 백엔드는 여러 앱(테넌트)이 공유한다. 예를 들어 Bibleanacom.seongwon.myapp라는 테넌트로 붙는다. 테넌트 간 데이터·방은 완전히 격리돼야 한다.

식별자는 reverse-DNS 문자열 app_key(예 com.seongwon.myapp)다. 이게 JWT의 app_id claim에 실려 매 요청에 전파되고, 모든 스코프 테이블에 app_id 컬럼 + 복합 유니크로 격리된다. 유저 유니크가 전역이 아니라 (app_id, username) 이 되는 식이다.

-- V20260709_3__multitenant.sql (요지)
-- 전역 유니크 → 앱 스코프 복합 유니크
ALTER TABLE users DROP CONSTRAINT users_username_key;
CREATE UNIQUE INDEX users_app_username ON users (app_id, username);
-- 멱등키도 앱 스코프
CREATE UNIQUE INDEX msg_idem ON messages (app_id, room_id, client_msg_id)
  WHERE client_msg_id <> '';

WS에서의 테넌트 격리 — 내부 키 vs 노출 키

WebSocket 게이트웨이에도 같은 격리가 필요하다. 여기서 좋은 디테일이 하나 있다. 게이트웨이는 방을 appID:roomID로 namespacing한 내부 키로 관리한다. 서로 다른 테넌트의 같은 이름 방이 섞이지 않게. 그런데 클라이언트에게 응답할 때는 원래 roomID만 노출한다. namespaced 내부 키가 밖으로 새지 않도록, 내부용 RoomID와 노출용 ClientRoomID를 분리해 둔 것이다.

정리

  • 여러 pod로 확장하면 방이 pod 경계를 넘는다 → RabbitMQ fan-out으로 pod 간 전달.
  • local-first + origin 태그 skip으로 자기 메시지 중복을 막는다.
  • 방 ID를 routing key로 쓰지 않는다 — 단일 room.broadcast + envelope 내부 room_id로 AMQP 문자 제약을 피한다.
  • app_key(reverse-DNS) 를 JWT claim + 모든 테이블 복합 유니크로 흘려 테넌트를 격리한다.
  • WS는 내부 namespaced 키 / 외부 노출 키를 분리한다.

여기까지가 서버 쪽 이야기다. 그런데 이 이중 쓰기·멱등키 설계는 클라이언트에서 완성된다. 서버의 client_msg_id 멱등키가 Bibleana의 낙관적 UI에서 어떻게 중복 제거로 이어지는지, 클라이언트 편에서 반대쪽을 본다.

답글 남기기

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