실시간 채팅 시스템 설계: WebSocket 재연결·메시지 순서·오프라인 동기화 결정하기
실시간 채팅 시스템 설계는 같은 기능 목록으로도 입력에 따라 답이 달라진다. 1:1 대화가 몇 분 끊긴 경우라면 DB 원장과 대화별 순번으로 충분할 수 있다. 한 방에 1,000명이 모이거나 사용자가 수개월 뒤 돌아온다면 메시지 원장은 유지하되 fan-out과 동기화 경로를 갈라야 한다.
이 글은 이 세 workload profile을 같은 결정표에 놓고 어떤 선택이 유지되고 어디서 분기되는지 따진다. 근거는 2026년 8월 15일 확인한 공식 문서와 Linux·Python 3 인메모리 통제 비교다. 실제 소켓, DB transaction, 모바일 네트워크, 멀티리전 지연과 p99 성능은 측정하지 않았으며 음성·영상, 대용량 첨부 전송, 종단간 암호화 키 교환은 범위에서 제외한다.
20초 핵심 요약
- 무엇: DB 원장, 대화별
seq, 클라이언트 멱등 키와 마지막 연속 cursor를 소그룹의 기본 경로로 둔다. - 왜: workload를 구분하지 않으면 대형 방에서는 fan-out이 막히고, 장기 복귀에서는 보존 범위 밖 cursor를 빈 동기화로 처리해 메시지를 놓친다.
- 어떻게: 네 결정 필드를 고정하고 규모 민감도, API·스키마, 세 저장 경계, 장애 복구를 대조해 large-room fan-out·replay cache·snapshot 전환 시점을 정한다.
요구사항은 기능 목록 대신 네 결정 필드로 고정한다
사용자 흐름은 메시지를 보내고, 저장 성공을 확인하고, 온라인이면 빠르게 받고, 오프라인 뒤에는 놓친 구간을 복구하는 것이다. 이 흐름을 구현 선택으로 바꾸려면 다음 네 값을 먼저 고정해야 한다.
| 결정 필드 | 1:1·소그룹, 짧은 오프라인 | 1,000명급 대형 방 | 수개월 만의 복귀 | API·저장·복구에 미치는 영향 |
|---|---|---|---|---|
| canonical order scope | 대화별 전체 순서 | 대화별 순서를 우선하되 hot conversation 한계를 측정 | 보관된 이력 안에서는 대화별 순서 유지 | (conversation_id, seq) 정렬과 범위 조회를 공통 계약으로 둔다 |
| sender success boundary | DB commit 뒤 ACK | fan-out 완료를 기다리지 않고 DB commit 뒤 ACK | 동일 | live delivery 실패가 저장 성공을 되돌리지 않는다 |
| offline recovery horizon | 수분 단위 delta | 연결 복구 뒤 delta | hot retention 밖일 수 있음 | after_seq replay와 resync_required 경로를 분리한다 |
| fan-out class | 작은 recipient 집합 | delivery 증폭이 큰 별도 class | 복귀 시 live fan-out보다 history read가 중요 | 일반 fan-out, large-room fan-out, snapshot sync를 같은 큐에 넣지 않는다 |
공통 불변조건은 세 가지다. 같은 client key는 한 저장 결과로 수렴하고, seq의 범위는 conversation이며, cursor는 마지막으로 본 값이 아니라 마지막으로 연속 적용한 값이다. WebSocket은 이 계약을 운반할 뿐 정의하지 않는다. RFC 6455가 보장하는 순서는 한 WebSocket 메시지의 fragment가 송신 순으로 전달된다는 범위이며, 재연결 replay나 여러 worker를 가로지르는 채팅 전체의 exactly-once 전달은 포함하지 않는다.
규모 가정은 fan-out과 replay에서 설계를 갈라놓는다
아래 값은 실제 서비스 관측이나 성능 결과가 아니라 선택 차이를 드러내기 위한 설계 가정이다.
| 입력 가정 | 계산 결과 | 현재 선택에 주는 신호 |
|---|---|---|
| DAU 1,000,000, 1인당 40 msg/day | 40,000,000 msg/day, 평균 약 463 writes/s | 전체 평균과 별개로 conversation별 peak를 측정한다 |
| peak/average 10배 | 약 4,630 writes/s | 저장 계층을 확장하되 hot conversation 직렬화는 따로 본다 |
| payload+index 1KB/msg | 40GB/day, 복제·백업 전 14.6TB/year | hot retention과 archive 분리를 검토한다 |
| 동시 접속이 DAU의 10% | 100,000 sockets | gateway memory, heartbeat와 egress를 용량 항목으로 둔다 |
| 평균 fan-out 3명 | peak 약 13,890 deliveries/s | 소그룹 기본 fan-out 경로를 유지할 수 있다 |
| 10분 오프라인, 0.1 msg/s 수신 | 60 msg, 약 60KB/user | cursor delta replay가 맞는다 |
| 위 조건에서 100,000명 동시 복귀 | 약 6GB replay | jitter, pagination과 rate limit이 필요하다 |
기준값 하나만 계산하면 설계가 언제 바뀌는지 알 수 없다. 민감도를 프로필별로 펼치면 분기점이 드러난다.
| 바뀌는 입력 | 유지되는 선택 | 바뀌는 선택 |
|---|---|---|
| group size 3 → 1,000 | DB 원장, commit 뒤 ACK, 대화별 메시지 식별 | peak delivery가 약 4.63M/s라는 설계 계산에 이르므로 recipient filtering, batch와 large-room fan-out을 별도 경로로 둔다 |
| payload 1KB → 10KB | 멱등 키와 cursor 의미 | 저장·replay·egress가 10배가 되므로 첨부는 object storage에 두고 행에는 metadata/reference만 두는 방향을 검토한다 |
| offline 10분 → 수개월 | durable history가 복구 원장이라는 원칙 | delta가 retention 밖이면 replay buffer 대신 snapshot/full pagination과 retention floor 응답을 사용한다 |
DAU보다 먼저 봐야 할 값은 가장 뜨거운 conversation의 write rate일 수 있다. 실제 용량 계획에서는 messages/conversation, serialized bytes, active sockets/instance, send queue bytes, reconnect rate와 sync page p95·p99를 측정해야 한다. 이 글의 계산만으로 운영 임계값을 만들 수는 없다.
API와 데이터 모델은 세 불변조건을 직접 강제한다
같은 client key는 한 저장 결과로 수렴한다
발신자는 로컬 outbox에 client_message_id를 먼저 만들고 같은 재시도에 계속 사용한다.
POST /v1/conversations/{conversation_id}/messages
Idempotency-Key: 018f47a0-6b72-7d54-9d12-4c7a2b110001
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
{"text":"배포 끝났습니다","client_sent_at":"2026-08-15T09:00:00+09:00"}
201 Created
{"message_id":"0198b520-8f38-7d7e-91f3-b54bb2f11842","conversation_id":"c_123","seq":1842,
"created_at":"2026-08-15T09:00:00.123+09:00","status":"stored"}
UNIQUE (conversation_id, client_message_id)가 같은 key의 중복 insert를 막는다. payload까지 같으면 기존 message_id와 seq를 반환하고, 같은 key에 다른 payload를 보내면 409 Conflict로 거절한다. timeout은 실패 확정이 아니므로 새 key가 아니라 같은 key로 재시도한다. 이는 변경 요청에 같은 idempotency token을 사용하라는 AWS Well-Architected 지침과도 맞는다.
seq의 범위는 conversation이다
conversations(
conversation_id PK,
next_seq BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
)
messages(
conversation_id,
seq BIGINT,
message_id UUID/ULID,
sender_id,
client_message_id UUID,
payload,
created_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ NULL,
PRIMARY KEY (conversation_id, seq),
UNIQUE (conversation_id, client_message_id),
UNIQUE (message_id)
)
conversation_members(
conversation_id,
user_id,
joined_seq,
left_seq NULL,
last_delivered_seq,
last_read_seq,
PRIMARY KEY (conversation_id, user_id)
)
message_id는 전역 식별, client key는 재시도 중복 제거, (conversation_id, seq)는 대화 정렬과 범위 조회를 맡는다. conversations.next_seq는 행 잠금 또는 conditional update로 올리고 message insert와 같은 transaction에 둔다. PostgreSQL 전역 SEQUENCE는 rollback·crash·ON CONFLICT 뒤 값을 회수하지 않으므로 gapless하지 않다. PostgreSQL 문서의 이 특성 때문에 전역 serial을 누락 탐지 cursor로 쓰면 안 된다. 삭제는 tombstone으로 남겨 이미 발급한 seq를 재사용하지 않는다.
cursor는 마지막 연속 값이다
GET /v1/conversations/{conversation_id}/messages?after_seq=1830&limit=200
200 OK
{"messages":[{"message_id":"0198b520-8f38-7d7e-91f3-b54bb2f11842","seq":1842,"text":"배포 끝났습니다"}],"last_contiguous_seq":1842,"has_more":false,
"retention_floor_seq":1200}
after_seq는 exclusive cursor이고 결과는 seq ASC다. 수신자는 seq == cursor + 1만 적용해 cursor를 전진시키고, seq <= cursor는 중복으로 제거한다. 현재 cursor가 4인데 10이 오면 10을 확정하지 않고 after_seq=4를 조회해 5~10의 연속 구간을 채운다. sync와 live subscribe 사이 race는 high_watermark_seq를 먼저 고정하거나 live event를 임시 보관한 뒤 seq로 합친다.
after_seq < retention_floor_seq라면 빈 200을 주지 않는다. 410 Gone 또는 명시적인 resync_required로 snapshot/full pagination 경로를 안내한다. 읽음 상태는 메시지별 행 대신 단조 증가하는 last_read_seq로 압축하되, 기기별 비연속 감사가 필요하면 per-message receipt 비용을 별도로 받아들인다.
컴포넌트 흐름은 세 저장 경계에서 책임이 바뀐다
컴포넌트 이름보다 commit을 기준으로 보면 재시도와 복구 원장이 선명해진다.
| 경계 | 책임 주체와 수행 작업 | 재시도 키 | 실패 시 복구 원장 |
|---|---|---|---|
| commit 전 | client local outbox가 pending을 보존하고 ingress가 인증·membership·payload size·rate limit을 검사한다 | (conversation_id, client_message_id) |
client local outbox와 동일 key 재전송 |
| commit 후, live 알림 전 | message service가 seq와 message를 같은 transaction에 저장하고 sender에게 stored(seq, message_id) ACK를 보낸다 |
outbox event ID 또는 message ID | durable DB와 transactional outbox |
| 클라이언트 적용 전 | fan-out worker와 gateway가 live event를 보내고 client가 seq를 검사한다 |
message_id 또는 (conversation_id, seq) |
DB의 after_seq replay 또는 snapshot |
Transactional Outbox Pattern은 message commit과 event publish 사이를 연결하는 선택지다. broker 발행이 실패해도 저장 성공을 유지하고 outbox relay를 재시도할 수 있다. live 재처리는 at-least-once일 수 있으므로 worker와 client도 message_id나 seq로 멱등 처리해야 한다.

Redis Pub/Sub은 at-most-once이므로 빠른 live hint에는 맞지만 오프라인 사용자의 원장이 될 수 없다. 짧은 replay 이력이 필요하면 Redis Streams의 range read와 retention을 검토할 수 있으나 stream key sharding, memory와 pending 상태를 운영해야 한다. Cloudflare Durable Objects의 WebSocket Hibernation 문서도 연결이 유지되는 동안 in-memory state는 reset될 수 있다고 설명한다. 연결 최적화와 durable message·cursor를 분리해야 하는 이유다.
병목과 용량은 세 축별로 격리한다
| 병목 축 | 기본안을 유지할 조건 | 별도 경로로 격리할 신호 | 관측값 |
|---|---|---|---|
| hot conversation | 대화별 순번 부여 대기가 SLO 안에 있음 | 한 대화의 직렬 write가 지속적으로 한계를 넘음 | per-conversation writes/s, seq allocation wait, lock conflict |
| fan-out amplification | 소그룹 recipient filtering과 일반 worker로 delivery가 감당됨 | 대형 방의 delivery·egress가 저장보다 먼저 포화됨 | recipients/message, delivery p99, serialized egress |
| replay horizon | 짧은 delta를 page replay로 복구 가능 | reconnect storm 또는 장기 복귀가 DB read와 hot retention을 압박 | replay bytes/s, sync page latency, retention-floor resync rate |
hot conversation을 해결하려고 모든 대화의 total order를 미리 포기할 필요는 없다. 측정 한계를 넘은 large room에만 substream이나 완화된 ordering을 적용한다. fan-out은 온라인 상태와 알림 선호도로 recipient를 거르고 batch와 별도 worker를 사용한다. presence와 typing은 짧은 TTL과 손실 허용 채널로 분리해 원장 쓰기를 늘리지 않는다.
reconnect storm에는 capped exponential backoff와 jitter, 최대 시도 횟수 또는 경과 시간을 둔다. 서버는 Retry-After, sync pagination과 사용자·IP별 rate limit으로 인증과 replay의 동시 폭주를 줄인다. 연결별 queued_messages, queued_bytes, oldest queued age도 제한한다. 상한 초과 시 오래된 메시지를 조용히 버리지 않고 close code와 이유를 남긴 뒤 durable cursor 재동기화로 전환한다.
통제 비교는 이 경계가 실패를 드러내는지 확인하기 위한 결정 모형이었다. 2026년 8월 15일 KST, Linux와 Python 3 collections.deque 환경에서 메시지 1~12 중 5~8을 오프라인으로 만들고 재연결 live 도착을 [10, 9, 11, 12]로 섞었다. msg-8은 같은 client key로 재시도했고 느린 client에는 소비 없이 1,000건을 생산했다.
scenario=reconnect_duplicate_gap_slow_client
baseline visible=[1, 2, 3, 4, 10, 9, 11, 12] db_rows=13 missing_offline=4 duplicate_rows=1 gap_transitions=2 reorder_transitions=1 queue_peak=1000 memory_bound=no
selected replay_after_cursor_4=[5, 6, 7, 8, 9, 10, 11, 12] db_rows=12 missing_offline=0 duplicate_rows=0 gaps_detected=[(5, 9)] queue_peak=3 memory_bound=yes action=disconnect_then_cursor_resync
assertions=passed exit=0
baseline은 누락 4건, 중복 행 1건, 역순 전이 1회와 상한 없는 peak queue 1,000건을 남겼다. 선택안은 cursor 4 뒤 5~12를 replay하고 같은 client key를 한 행으로 수렴시켰으며, 10이 먼저 도착했을 때 예상 범위 5~9의 gap을 감지했다. queue 3과 burst 1,000은 실패를 드러내기 위한 합성값이지 운영 권장값이 아니다. 이 결과는 처리량 benchmark가 아니며 실제 DB, socket, 압축과 p99 latency를 말해주지 않는다.
cursor 없이 live-only 결과가 전체 이력과 같다는 실패 조건은 다음 입력과 종료 상태로 확인했다.
live_only = [1, 2, 3, 4, 10, 9, 11, 12]
expected_after_reconnect = list(range(1, 13))
assert live_only == expected_after_reconnect, f'offline replay/order failed: got={live_only} expected={expected_after_reconnect}'
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
AssertionError: offline replay/order failed: got=[1, 2, 3, 4, 10, 9, 11, 12] expected=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
exit=1
장애 대응은 오래된 cursor부터 복구한다
retention floor를 지난 cursor
- 감지: sync API가
after_seq < retention_floor_seq를 확인한다. - 완화: 무한 replay나 빈 성공 대신
resync_required를 반환한다. - 복구: 최신 snapshot 또는 full paginated history로 기준 cursor를 다시 만들고 이후 delta를 합친다.
- 정합성 확인: snapshot high watermark와 첫 delta의
seq가 연결되는지 검사한다.
저장 commit 뒤 사라진 ACK
- 감지: client timeout 뒤 local outbox가
pending으로 남는다. - 완화: 새 ID를 만들지 않고 같은 client key로 재시도한다.
- 복구: unique constraint가 기존 행을 찾아 같은 server ACK를 반환한다.
- 정합성 확인:
(conversation_id, client_message_id)당 행이 하나인지, 다른 payload를 쓴 key 재사용이409인지 검사한다.
slow consumer의 가득 찬 송신 큐
- 감지:
queued_messages,queued_bytes, oldest queued age가 상한을 넘는다. - 완화: drop으로 숨기지 않고 연결을 종료하며 close reason을 기록한다.
- 복구: client가 마지막 durable cursor로 sync한다.
- 정합성 확인: replay 뒤 high watermark까지 연속이고 중복 적용이 없는지 확인한다.
broker의 live 알림 손실
- 감지: outbox lag, publish failure, stored-to-delivered latency와 client gap rate가 오른다.
- 완화: 메시지 저장은 성공으로 유지하고 live delivery만 degraded 상태로 둔다.
- 복구: outbox를 재처리하거나 client cursor replay를 실행한다.
- 정합성 확인: DB high watermark, fan-out checkpoint와 client receipt cursor를 비교한다.
cross-worker에서 10이 9보다 먼저 오는 상황은 별도 순서 장치로 숨기지 않는다. incoming > cursor + 1이면 cursor 불변조건이 gap을 감지하고, after_seq=cursor 결과를 순서대로 적용한 뒤 이미 받은 10을 제거한다. timestamp는 clock skew가 있어 canonical order로 사용하지 않는다.
보안과 비용은 연결보다 메시지 권한과 복구량에서 커진다
연결은 wss를 사용하고 handshake에서 인증과 Origin allowlist를 검사한다. 연결 인증이 끝났더라도 각 message action에서 conversation membership과 권한을 다시 확인한다. access token 만료, logout과 membership removal도 장기 연결에 반영할 재인증 또는 server-side revocation 경로가 필요하다.
OWASP WebSocket Security Cheat Sheet에 따라 payload schema·type·최대 크기, message rate와 connection rate를 제한한다. abnormal disconnect와 protocol error는 남기되 token, session ID와 메시지 전체 본문은 로그에 기록하지 않는다.
비용은 socket 수 외에도 heartbeat, gateway memory, serialized egress, replay read, group fan-out, retention과 replication에서 발생한다. hot DB 보존 기간과 archive 경계는 법적 요구, 비용과 복구 목표가 정해진 뒤 선택해야 한다. E2EE를 넣으면 서버의 plaintext moderation·search와 multi-device key sync 의미가 달라지므로 현재 메시지 경로의 옵션이 아니라 별도 요구사항으로 다뤄야 한다.
대안은 세 workload profile의 전환 신호로 비교한다
| workload profile | 기본안 | 전환 신호 | 추가 복잡도 | 아직 채택하지 않을 것 |
|---|---|---|---|---|
| 1:1·소그룹, 짧은 오프라인 | 관계형 DB 원장 + live Pub/Sub hint + 대화별 seq + cursor replay |
replay read, outbox lag 또는 DB partition 크기가 지속 한계를 넘음 | changed-conversation index, archive, replay cache | 작은 서비스에 Kafka·Pulsar cluster를 미리 운영하지 않는다 |
| 1,000명급 대형 방 | 원장은 유지하고 fan-out worker를 별도 class로 분리 | delivery·egress 포화 또는 단일 conversation 순번 대기가 SLO 초과 | batch, topic 계층, partitioned log, 필요 시 substream과 client merge | 측정 전 모든 대화의 순서를 완화하지 않는다 |
| 수개월 만의 복귀 | retention floor가 포함된 sync API와 durable history | retention-floor resync와 replay bytes가 hot DB를 지속 압박 | snapshot 생성, full pagination, archive retrieval과 delta 병합 | WebSocket buffer를 장기 이력으로 사용하지 않는다 |
Redis Streams는 짧은 replay와 consumer ACK가 필요한 조건에서 후보가 된다. Kafka·Pulsar 계열 partitioned log는 높은 지속 처리량, 다수 downstream과 긴 replay가 실제로 필요할 때 partition 내 순서와 독립 consumer를 제공하지만 cluster·partition·rebalance·schema 운영 부담이 따른다. 어느 제품도 DB 원장, cursor와 멱등 적용의 필요를 자동으로 없애지 않는다.
메시지마다 receipt 행을 쓰면 기기별 감사를 정밀하게 만들 수 있지만 messages × recipients만큼 쓰기가 늘어난다. 일반적인 읽음 표시는 cumulative last_read_seq가 더 단순하다. connected-only broadcast는 presence·typing에는 맞아도 offline replay와 gap 복구가 필요한 채팅 본문에는 맞지 않는다.
단계별 확장은 관측된 분기만 연다
- 단일 리전 관계형 DB, 대화별 transaction sequence, REST send·sync, WebSocket live fan-out, client local outbox와 cursor로 시작한다. 중복 key, gap, retention 밖 cursor와 bounded queue의 invariant test를 먼저 둔다.
- gateway를 수평 확장하고 connection registry와 live Pub/Sub을 붙인다. DB 원장은 유지하고 outbox lag와 gap recovery를 관측한다.
- replay read와 테이블 크기가 커질 때 conversation 또는 tenant 기준 partition, changed-conversation sync index, archive와 replay cache를 추가한다.
- group size와 downstream이 커지면 partitioned log와 fan-out worker를 분리한다. partition key는 conversation ID로 유지해 대화별 순서를 보존한다.
- 단일 hot conversation이 한 partition의 측정 한계를 넘을 때만 large-room 요구를 다시 협상하고 substream, batch 또는 완화된 ordering을 별도 경로에 둔다.
지금 기본안을 유지할지 결정하려면 네 값을 먼저 수집해야 한다. per-conversation peak write가 지속 한계를 넘으면 large-room fan-out과 순서 완화 검토를 연다. replay bytes/s가 DB sync를 압박하면 replay cache를 검토하고, retention-floor resync rate가 높으면 snapshot sync를 독립 경로로 만든다. send queue disconnect rate가 높다면 큐를 무작정 키우기 전에 slow consumer와 egress 정책을 고친다. 숫자 임계값은 이 통제 비교가 아니라 실제 부하 시험과 SLO에서 정해야 한다.
참고 링크
- RFC Editor — RFC 6455: The WebSocket Protocol
- Redis Docs — Pub/Sub
- Redis Docs — Streams
- PostgreSQL 18 — Sequence Manipulation Functions
- AWS Well-Architected — Make all responses idempotent
- AWS Well-Architected — Control and limit retry calls
- Cloudflare Developers — Use WebSockets with Durable Objects
- OWASP — WebSocket Security Cheat Sheet