본문 바로가기
SJH
Navigation

Realtime Chat / Realtime Messaging

연결 성공이 아닌 수신자 기준으로 채팅 전달 경계를 검증하기

구독 권한, room ordering, 수신자별 누락·중복, reconnect cursor를 분리해 WebSocket 전달 정합성을 검증했습니다.

핵심 구현과 검증 파일은 저장소의 고정 permalink로 연결합니다.

GitHub 저장소

핵심 근거

수신자 기준 전달 완전성

측정 완료

1,000 users × 3회 · 회차별 expected 99,900 / unique 99,900 / missing 0 / duplicate 0

2026-05-23 · 단일 room 1,000명, sender 5명 × 20 messages, send interval 100ms, 회차당 expected delivery 99,900 · Docker Compose app-1/app-2 로컬 환경, 3회 반복

구독 인가

시나리오 검증

채팅방 비멤버의 room topic SUBSCRIBE를 실제 STOMP 연결에서 거부

비멤버 JWT로 연결한 뒤 room topic 구독 시 ERROR frame 또는 연결 종료가 발생하는지 검증

재연결 동기화 경계

시나리오 검증

afterMessageId 이후 누락 메시지를 오름차순으로 반환하고 비멤버·타 방 cursor를 거부

sync API 응답 순서, pagination cursor, membership, cross-room cursor validation을 통합 테스트로 검증

Structure

구조와 흐름

  1. 01STOMP SUBSCRIBE 시 채팅방 membership을 확인합니다.
  2. 02SEND 메시지는 roomId key로 Kafka 같은 partition에 전달합니다.
  3. 03저장된 messageId와 수신자별 raw row로 누락·중복·순서를 검산합니다.
  4. 04재연결 client는 마지막 messageId 이후 내역을 sync API로 보충합니다.

전체 구조도

STOMP 구독 인가에서 roomId Kafka partition, 메시지 저장과 fan-out, receiver matrix, reconnect sync API로 이어지는 채팅 전달 구조

실시간 fan-out 결과와 재연결 이후 DB sync를 별도로 검증해 전달 공백을 찾습니다.

Design review

문제를 좁히고, 실패 경계를 분리한 뒤, 재현 가능한 결과만 남겼습니다.

문제

  • WebSocket handshake 성공만으로는 비멤버 구독과 수신자별 메시지 누락을 발견할 수 없었습니다.
  • 재연결한 client가 어느 메시지부터 다시 받아야 하는지 명시적인 경계가 필요했습니다.

단순 구현에서의 문제

  • 발신자 self-echo 성공률은 모든 room member가 메시지를 받았다는 근거가 아닙니다.
  • 메시지 timestamp만 비교하면 broker partition과 DB 저장 순서를 정확히 진단하기 어렵습니다.

설계 판단

  • room topic SUBSCRIBE 시점에 membership을 다시 확인합니다.
  • roomId를 Kafka key로 사용하고 persisted messageId로 room 전체 순서를 진단합니다.
  • receiver matrix와 afterMessageId sync API로 실시간 수신과 재연결 복구를 분리합니다.

검증 결과

  • 1,000-user 로컬 receiver matrix를 3회 실행해 회차별 expected 99,900과 unique 99,900, missing 0, duplicate 0을 확인했습니다.
  • 비멤버 SUBSCRIBE 거부와 afterMessageId 이후 오름차순 복구, 타 방 cursor 거부를 통합 테스트로 고정했습니다.
  • persisted messageId 기반 사후 검산에서 3회 모두 room-global out-of-order 0을 기록했습니다.

Evidence

검증 근거

시나리오 검증integration

구독 인가

채팅방 비멤버의 room topic SUBSCRIBE를 실제 STOMP 연결에서 거부

비멤버 JWT로 연결한 뒤 room topic 구독 시 ERROR frame 또는 연결 종료가 발생하는지 검증

./gradlew test --tests '*WebSocketSubscribeAuthorizationIntegrationTest'
근거 파일
시나리오 검증integration

room 단위 순서

같은 roomId 메시지를 같은 Kafka partition에 저장하고 offset 순서와 발행 순서를 일치시킴

roomId를 Kafka key로 발행한 5개 메시지의 partition, content 순서, Kafka offset 정렬을 검증

./gradlew test --tests '*MessageOrderingIntegrationTest'
근거 파일
시나리오 검증integration

재연결 동기화 경계

afterMessageId 이후 누락 메시지를 오름차순으로 반환하고 비멤버·타 방 cursor를 거부

sync API 응답 순서, pagination cursor, membership, cross-room cursor validation을 통합 테스트로 검증

./gradlew test --tests '*MessageIntegrationTest'
근거 파일
측정 완료local-load

수신자 기준 전달 완전성

1,000 users × 3회 · 회차별 expected 99,900 / unique 99,900 / missing 0 / duplicate 0

2026-05-23 · 단일 room 1,000명, sender 5명 × 20 messages, send interval 100ms, 회차당 expected delivery 99,900 · Docker Compose app-1/app-2 로컬 환경, 3회 반복

근거 파일
측정 완료local-load

Persisted ID 순서 진단

1,000-user receiver matrix 3회 모두 room-global out-of-order 0

2026-05-23 · raw receive row의 persisted DB messageId를 기준으로 room-global ordering을 사후 검산 · Docker Compose app-1/app-2 로컬 환경, 3회 반복

근거 파일
시나리오 검증local-load

채팅방 목록 query shape · 보조 근거

채팅방 N개 조회 경로를 2N+1 query에서 JPQL projection 1 query로 변경

Hibernate SQL, JPQL projection, EXPLAIN ANALYZE를 성능 문서에 함께 기록하고 조회 구현과 대조

근거 파일
한계와 다음 확인

현재 한계

  • 1,000-user 결과는 동일한 로컬 Docker Compose 환경의 scenario evidence이며 production benchmark가 아닙니다.
  • persisted ID 순서 0건은 해당 세 실행의 진단 결과이지 모든 장애 상황의 전역 순서 보장은 아닙니다.

다음 확인

  • 다중 room mixed traffic과 instance 재시작을 함께 넣은 장시간 soak를 측정합니다.
  • Kafka rebalance와 reconnect 폭주 구간의 누락·중복·복구 시간을 분리해 측정합니다.
예상 면접 질문
  • SUBSCRIBE 인가를 handshake 인증과 별도로 둔 이유는 무엇인가요?
  • roomId key가 보장하는 순서의 범위는 어디까지인가요?
  • receiver matrix의 expected delivery 분모는 어떻게 계산했나요?