⚠️ 왜 ≤200ms 0.38% → 99.97%가 되었는지 전체 실험 과정
원본 분석 노트: GitHub에서 보기


요약

항목 Before After
≤200ms 수신 성공률 0.38% 99.97%
  • 환경: 200명 동시 접속, 송신자 20명, 20Hz → 80,000 send/s fan-out
  • 문제 1: 동일 세션에 동시 send → TEXT_PARTIAL_WRITING → room 붕괴
  • 문제 2: Drop 전략 없이 All-delivery → 지연 누적
  • 해결: ConcurrentWebSocketSessionDecorator + 최신값 Coalescing + Dirty Flag

배경 및 실험 목적

실시간 협업 캔버스에서 마우스 커서, 드래그 미리보기, 하이라이트 등 휘발성(Ephemeral) 이벤트를 브로드캐스트한다. 이 데이터는 완전한 전달보다 최신 상태가 빠르게 도달하는 것이 중요하다.

항목 설정
동시 접속자 200명 (동일 룸)
송신자 비율 10% (20명), 20Hz
인바운드 이벤트율 400 msg/s
아웃바운드 fan-out 최대 80,000 send/s
SLO ≤200ms 수신 성공률

RAW WebSocket과 Spring STOMP를 비교하여 휘발성 이벤트에 적합한 구조를 검증했다.


문제 1: RAW WebSocket 동시 sendMessage() 충돌

증상

WARN [exec-144] [RAW] send fail session=b508ac... ex=IllegalStateException: TEXT_PARTIAL_WRITING
WARN [exec-21 ] [RAW] send fail session=c1e70c... ex=IllegalStateException: TEXT_PARTIAL_WRITING
INFO [exec-24 ] [RAW] left session=59c431... roomKey=1:1 size=13 → ... size=0

200명 브로드캐스트 중 동일 세션에 멀티스레드 sendMessage()가 동시 호출되어 Tomcat RemoteEndpoint가 예외를 발생시키고, 세션이 room에서 연쇄 탈락했다.

개선 전 수신량: 6,509 events (이상적: 2,400,000)

해결: ConcurrentWebSocketSessionDecorator

synchronized 대신 Decorator를 선택한 이유:

  • synchronized는 느린 세션이 다른 스레드를 블록
  • Decorator는 세션별 버퍼링 + 전송 시간/버퍼 제한으로 느린 세션이 전체 성능을 끌어내리는 것을 방지 (백프레셔)
int sendTimeLimitMs    = 5_000;
int bufferSizeLimit    = 512 * 1024;
WebSocketSession safe  = new ConcurrentWebSocketSessionDecorator(session, sendTimeLimitMs, bufferSizeLimit);
registry.join(roomKey, safe);

결과

Type Received Recv/s ≤200ms
개선 전 RAW 6,509 99.52 1.03%
개선 후 RAW 2,399,800 37,140 0.38%

수신량은 이상적인 수치(2,400,000)에 근접하게 회복되었다. 그러나 ≤200ms 성공률은 여전히 낮아 다음 문제로 이어졌다.

→ 여기까지는 동시성 문제 해결
→ 이후 “왜 여전히 느린가?” 분석은 아래 참고

🔍 전체 분석 과정 보기 → GitHub에서 보기


문제 2: All-delivery 구조의 지연 누적

수신량 회복 이후에도 ≤200ms 성공률이 0.38%에 불과했다. 세션 직렬화 + 버퍼링으로 인해 모든 이벤트를 순서대로 전송하면 이전 이벤트가 밀려 최신 이벤트가 늦게 도달한다.

해결: 메시지 타입별 전략 분리

타입 전략
CURSOR, HIGHLIGHT 등 (휘발성) 최신값만 유지 (Coalescing)
CONTROL — 노드 이동·수정 확정 전량 전달 보장 (LinkedQueue)

스케줄러(33ms, ≈30Hz)마다 Coalescer에서 최신값 flush.


문제 3: 더티 플래그 없는 Coalescer의 중복 재전송

초기 구현에서 Coalescer는 전송 후 맵을 비우지 않아, 이미 보낸 최신값이 매 tick마다 반복 전송되는 문제가 발생했다.

  • 불필요한 브로드캐스트 폭증 → CPU/네트워크 낭비 → tail latency 악화
  • ≤200ms 성공률: 48.55% (수신량 자체는 오염됨)

해결: Dirty Flag + Drain

// publish 시 dirty=true 마킹
public void publishLatest(String roomKey, String key, T msg) {
    latestByRoom.computeIfAbsent(roomKey, rk -> new ConcurrentHashMap<>()).put(key, msg);
    markDirty(roomKey);
}

// flush 시 dirty=true인 경우만 스냅샷 후 clear
public Collection<T> drainLatestIfDirty(String roomKey) {
    AtomicBoolean dirty = dirtyByRoom.get(roomKey);
    if (dirty == null || !dirty.compareAndSet(true, false)) return List.of();
    ArrayList<T> out = new ArrayList<>(latestByRoom.get(roomKey).values());
    latestByRoom.get(roomKey).clear();
    return out;
}

최종 결과 (Sender=20, Room=200, 20Hz)

Type Dirty Flag Received ≤200ms
RAW ❌ 미적용 2,392,680 48.55%
STOMP ❌ 미적용 2,396,660 48.21%
RAW ✅ 적용 1,958,600 99.97%
STOMP ✅ 적용 2,028,600 98.69%

RAW vs STOMP 최종 비교 (Sender=40 고부하)

Sender Type ≤200ms
20 RAW 99.97%
20 STOMP 98.69%
40 RAW 89.89%
40 STOMP 78.04%

고부하(송신자 40명) 구간에서 STOMP의 ≤200ms 성공률이 약 12%p 하락했다. JFR 분석 결과 STOMP의 프레임 파싱·헤더 처리 오버헤드가 추가 allocation을 유발했다.

RAW WebSocket 채택


핵심 인사이트

휘발성 데이터에서 “전량 전달”과 “최신성 보장”은 반대되는 목표다. 전량 전달을 포기하고 Dirty Flag 기반 최신값만 flush하는 전략으로 ≤200ms 성공률을 48% → 99.97%로 회복했다. 중복 재전송 루프가 병목이었으며, 수신량 지표만 보면 문제가 보이지 않는다.

🔍 과정 상세히 보고 싶다면 → GitHub에서 보기