Realtime Chat / Engineering case
상대가 본 메시지는 먼저 저장돼 있어야 한다
accepted와 persisted를 구분하고 DB commit 뒤에만 broadcast해 실시간 화면과 복구 가능한 기록을 같은 순서로 맞췄습니다.
GitHub에서 코드 보기 ↗User impact
상대 화면에는 도착했지만 새로고침하면 사라지는 메시지는 연결 성공보다 더 나쁜 경험입니다.
01
어떻게 깨지는가
- persistence와 broadcast consumer가 독립이면 DB 실패보다 broadcast가 먼저 끝날 수 있습니다.
- 연결이 끊긴 동안의 메시지는 WebSocket만으로 복구할 수 없습니다.
- 실시간 payload에 DB ID가 없으면 history와 중복 없이 합치기 어렵습니다.
02
지켜야 할 경계
- 같은 room의 저장 순서를 진단할 수 있어야 합니다.
- Redis publish 실패는 성공으로 삼키지 않아야 합니다.
- 비멤버는 CONNECT 이후 SUBSCRIBE에서도 차단돼야 합니다.
- clientMessageId로 임시 메시지를 추적합니다.
- room partition에서 DB에 저장하고 message ID를 만듭니다.
- commit된 payload만 broadcast하고 재연결에도 같은 ID를 사용합니다.
03
선택한 설계
- Kafka consumer가 persistence service commit을 마친 뒤 Redis에 publish합니다.
- clientMessageId는 optimistic row를, DB ID는 persisted message dedupe를 맡습니다.
- 재연결 시 마지막 DB ID 이후를 sync API로 반복 조회합니다.
04
감수한 trade-off
- DB commit 뒤 broadcast라 실시간 지연이 조금 늘 수 있지만 phantom delivery를 제거합니다.
- at-least-once redelivery를 받아들이고 저장·수신 양쪽에서 dedupe합니다.
05
어떻게 확인했는가
- DB 실패를 주입한 동안 receiver에는 노출되지 않고, 재시도 뒤 DB와 수신이 한 건으로 수렴합니다.
- Redis publish 실패 뒤 기존 DB ID로 재전달돼 저장과 수신이 모두 한 건으로 유지됩니다.
- 재연결 sync가 누락 메시지를 보충하고 비멤버 REST·STOMP 접근은 거부됩니다.
Replay the boundary
글 대신 단계별로 따라보기
Sources
코드와 테스트로 돌아가기
- 근거 ↗
저장 뒤 전달로 재구성한 제품 흐름 merge
공개 저장소에서 확인 - 근거 ↗
비멤버 STOMP 구독 거부 테스트
공개 저장소에서 확인 - 근거 ↗
room 단위 Kafka ordering 테스트
공개 저장소에서 확인 - 근거 ↗
persisted ID 이후 동기화 API 테스트
공개 저장소에서 확인 - 근거 ↗
optimistic row 치환·DB ID 중복 제거 테스트
공개 저장소에서 확인 - 근거 ↗
DB identity·중복 부수 효과 경계 테스트
공개 저장소에서 확인 - 근거 ↗
Redis publish 실패 뒤 Kafka redelivery 테스트
공개 저장소에서 확인 - 근거 ↗
발신자 PERSISTED 상태 알림 통합 테스트
공개 저장소에서 확인 - 근거 ↗
두 노드 전달·오프라인·실패 복구 브라우저 E2E
공개 저장소에서 확인
한계와 다음 확인
- 읽음 receipt broadcast는 이번 범위에 포함하지 않습니다.
- 전역 순서가 아니라 room 단위 순서와 재연결 경계를 다룹니다.