이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Chat System에서 메시지 저장, 실시간 전달, 읽음 상태는 왜 분리해서 설계해야 하는가?
  • 1:1 채팅과 대형 단체방은 fanout, ordering, storage 선택이 어떻게 달라지는가?
  • Chat 설계는 성능, 정합성, 장애 격리, 운영 복잡도를 어떻게 바꾸는가?

개요

Chat System은 메시지를 저장하고 참여자에게 실시간으로 전달하는 시스템이다. 실시간 전달이 눈에 띄지만 최종 진실의 원천은 저장된 메시지다. WebSocket 전달이 실패해도 메시지가 저장되어 있으면 재접속 후 복구할 수 있다. 반대로 WebSocket으로 보냈지만 저장이 실패하면 사용자 간 상태가 갈라진다. 그래서 메시지 저장, fanout, presence, read receipt, push notification을 별도 책임으로 나눠야 한다.

이 문서가 바꾸는 설계 축

바뀌는 내용
성능room 크기와 online user 수에 맞춰 fanout 비용을 조절한다.
정합성room 단위 ordering, message idempotency, read receipt 기준을 정한다.
장애 격리WebSocket gateway, message store, push notification 장애를 분리한다.
운영 복잡도connection state, reconnect, backpressure, shard by room을 운영한다.

요구사항 분리

요구사항설계 질문
메시지 전송저장 성공을 언제 사용자에게 알려줄 것인가?
실시간 전달online user에게만 보내는가, offline도 보장하는가?
순서room 전체 순서가 필요한가, sender별 순서면 충분한가?
읽음 표시메시지별인지 마지막 읽은 offset인지 정한다.
presence정확해야 하는가, 몇 초 stale을 허용하는가?
대형방모든 참여자에게 즉시 fanout할 수 있는가?

대표 시나리오

1:1 채팅과 10,000명 공개방은 같은 구조로 처리하면 안 된다.

1:1 room:
  message write = 20 msg/s
  recipients = 1
  ordering = room sequence
 
large room:
  message write = 200 msg/s
  online users = 6,000
  fanout = 1,200,000 deliveries/s

대형방에서 모든 메시지를 모든 연결에 즉시 push하면 gateway와 network가 먼저 포화된다. Room size에 따라 fanout 전략과 backpressure가 달라져야 한다.

기본 흐름

flowchart LR
    A["Client WebSocket"] --> B["Gateway"]
    B --> C["Message API"]
    C --> D["Message Store"]
    D --> E["Room Event Stream"]
    E --> F["Fanout Worker"]
    F --> G["Online Connections"]
    F --> H["Push Queue"]

Client가 메시지를 보내면 먼저 저장한다. 저장된 메시지는 room event stream을 통해 online connection으로 전달한다. Offline user에게는 push queue나 unread counter를 갱신한다.

메시지 저장 계약

메시지 전송 API는 client generated id를 받아 중복 전송을 막는다.

{
  "clientMessageId": "ios-7f31-10001",
  "roomId": "room-42",
  "senderId": "member-9",
  "body": "hello",
  "sentAt": "2026-07-01T12:00:00Z"
}

DB에는 (room_id, client_message_id, sender_id) unique constraint를 둔다. 모바일 네트워크에서 사용자가 재전송하거나 client가 retry할 수 있기 때문이다. 서버는 저장 후 server message id와 room sequence를 반환한다.

Ordering

Ordering은 범위를 정해야 한다.

범위장점비용
room global sequence사용자 경험이 단순하다.room별 sequence 병목
partitioned room sequence대형방 처리량 증가merge와 표시 순서 복잡
sender sequence쓰기 분산 쉬움참여자마다 순서가 다르게 보일 수 있음
대부분의 작은 room은 room sequence가 단순하다.
대형 공개방은 exact ordering보다 near-real-time stream과 pagination consistency가 더 중요할 수 있다.

Presence

Presence는 정확한 DB 상태가 아니라 짧은 TTL 상태로 보는 편이 맞다.

presence:{memberId} = online, ttl 30s
connection:{connectionId} = gateway-3, ttl 30s

Client heartbeat가 끊기면 TTL로 offline 처리된다. Presence가 몇 초 늦게 반영되는 것은 대개 허용된다. 정확한 결제 상태처럼 다루면 write 부하와 장애 민감도가 커진다.

Read Receipt

읽음 상태를 메시지마다 저장하면 write가 폭증한다. 대부분은 room/member별 마지막 읽은 sequence로 충분하다.

create table room_read_offsets (
    room_id varchar(80) not null,
    member_id bigint not null,
    last_read_sequence bigint not null,
    updated_at timestamp not null,
    primary key (room_id, member_id)
);

이 구조는 “몇 번 메시지까지 읽었는가”를 빠르게 계산할 수 있다. 단, 메시지별 개별 읽음이 필요한 기업 메신저라면 비용을 별도 계산해야 한다.

Fanout 전략

전략장점비용적합
direct gateway push단순하고 빠름gateway affinity 필요작은 room
room event streamgateway 확장 쉬움stream 운영 필요중간 규모
pull on reconnect저장소 기준 복구실시간성이 낮음offline 복구
sampled delivery대형방 보호일부 메시지 지연/누락 표시 필요대형 공개방
Fanout은 room 크기, online user 수, 메시지 빈도에 따라 바뀐다.
하나의 전략으로 모든 방을 처리하려 하면 큰 방이 작은 방 SLO를 깨뜨린다.

Backpressure

Client connection이 느리면 gateway queue가 쌓인다.

max_pending_messages_per_connection = 500
if exceeded:
  close connection with reconnect_required

느린 client 하나 때문에 gateway memory가 커지면 안 된다. 대형방은 오래된 메시지를 drop하고 최신 상태를 다시 pull하게 할 수 있다. 1:1 채팅은 drop 대신 재접속 후 missed messages 조회를 보장하는 편이 낫다.

Reconnect와 Missed Messages

Mobile client는 네트워크가 자주 끊긴다. Client는 마지막으로 확인한 room sequence를 들고 다시 연결해야 한다.

GET /rooms/room-42/messages?afterSequence=9912&limit=100

서버는 9913 이후 메시지를 반환하고, gap이 너무 크면 snapshot이나 pagination을 요구한다. WebSocket gateway는 복구의 owner가 아니다. 복구 기준은 message store와 room sequence다. 이 경계를 두면 gateway 재시작, region 이동, client reconnect가 일어나도 대화 내역을 다시 맞출 수 있다.

장애 중 판단

장애대응
WebSocket gateway 장애reconnect, missed message pull
message store 지연send API 제한, read-only 모드 검토
fanout worker lag대형방 throttle, small room 우선순위
push provider 장애offline push 지연, message store 유지
presence store 장애presence degrade, message send 유지
Presence나 read receipt 장애가 message send를 막으면 안 된다.
사용자 핵심 성공은 메시지 저장과 복구 가능성이다.

개인 프로젝트 기준

  • WebSocket 연결보다 message store와 reconnect 복구를 먼저 설명한다.
  • clientMessageId로 중복 전송을 막는다.
  • 1:1 room과 대형 room의 fanout 차이를 계산한다.
  • presence는 TTL 기반 stale 허용 상태로 둔다.
  • read receipt는 last read sequence로 단순화한다.

기업 운영 기준

  • active connections, send p95, fanout lag, reconnect rate를 dashboard로 본다.
  • room size별 정책과 quota를 둔다.
  • gateway shard와 room routing 기준을 문서화한다.
  • message store backup과 retention 정책을 운영한다.
  • slow consumer와 abusive sender에 대한 제한 정책을 둔다.

위험 신호

  • WebSocket 전달 성공을 메시지 저장 성공으로 착각한다.
  • client retry 중복을 막을 idempotency key가 없다.
  • 모든 room에 같은 fanout 전략을 적용한다.
  • presence를 강한 정합성 데이터처럼 저장한다.
  • read receipt를 메시지별 row로만 설계하고 write 폭증을 계산하지 않는다.
  • fanout lag가 쌓여도 사용자에게 지연 상태를 보여주지 않는다.

확인 질문

확인 질문

  • 확인 질문: Chat에서 최종 진실의 원천은 무엇인가?
    • 답변: WebSocket 전달 여부가 아니라 저장된 메시지와 room sequence가 최종 복구 기준이다.
  • 확인 질문: Presence를 TTL 상태로 보는 이유는 무엇인가?
    • 답변: online 상태는 순간적으로 변하고 몇 초 stale이 허용되므로 강한 DB 정합성으로 다루면 비용이 과하다.
  • 확인 질문: 대형방이 어려운 이유는 무엇인가?
    • 답변: 메시지 하나가 수천~수만 connection fanout으로 증폭되어 gateway, network, ordering, backpressure 문제가 생기기 때문이다.