Skip to content

[RFC] MatchHandler를 “상태 변화 기반 즉시 푸시”로 전환 #60

@wjkim9

Description

@wjkim9

[RFC] MatchHandler를 “상태 변화 기반 즉시 푸시”로 전환

배경

현재는 서버가 1초마다 전체 세션에 STATUS를 푸시(서버 사이드 interval push).
트래픽/비용이 불필요하게 발생하고, “실시간”의 본질(이벤트 순간 전달)과도 다름.

목표(Goals)

  • 상태 변화 발생 시에만 STATUS 푸시 (JOIN, CANCEL, OPEN→LOCKED 전이 등)
  • 초당 브로드캐스트 제거 (핫루프 중단)
  • 클라이언트는 openAt/lockAt으로 로컬 카운트다운 수행(remainingTime은 클라 계산)

범위(Non-goals)

  • 매칭/배정 알고리즘 변경 아님.
  • 기존 메시지 타입/DTO 대폭 변경 아님(가능한 호환 유지).

설계 개요

  1. 도메인 이벤트 발행

    • MatchQueueJoinedEvent(userId), MatchQueueCanceledEvent(userId)
    • MatchStateChangedEvent(prevState, newState, roundId) (OPEN↔LOCKED 등)
    • RoundWindowChangedEvent(prevRound, newRound)

    발행처: MatchService(join/cancel), DraftTimingService(라운드/상태 전이 결정 시)

  2. 브로드캐스터 분리

    • MatchBroadcaster 컴포넌트 신설: WebSocket 전송 전담
    • @EventListener로 위 이벤트 수신 → 필요한 세션에만 메시지 푸시
    • 메시지 조립은 DTO(ObjectMapper)로, org.json 제거
  3. 상태 스냅샷 & 변경 감지

    • StatusCache(AtomicRef)로 마지막 STATUS 스냅샷 보관

    • 새 스냅샷과 비교해 실제 변경이 있을 때만 브로드캐스트

      • 예: count, state, round.id, openAt/lockAt 중 하나라도 달라지면 전송
  4. 시간 전이 트리거(스케줄)

    • TaskScheduleropenAt, lockAt 절대시각에 단발 스케줄 등록
    • 해당 시각 도달 시 MatchStateChangedEvent 발행
    • 라운드가 바뀌면 기존 스케줄 취소 후 재등록
    • (다중 인스턴스) 분산 락: SETNX match:lock:{roundId}:{phase} + TTL
  5. 클라이언트 카운트다운

    • STATUS에는 round.openAt/lockAt만 포함(지금도 있음)
    • remainingTime클라에서 계산 (서버 1초 푸시 제거)
    • 과도기: 서버가 보낸 remainingTime이 있어도 클라 우선 계산로직로 동작(하위호환)

작업 항목(Tasks)

  • MatchBroadcaster 생성: sendStatusToAll(StatusDto), sendToUser(userId, msg)
  • StatusCache + StatusChangeDetector (equals 비교로 변경 감지)
  • ApplicationEventPublisher 도입, 이벤트 클래스 4종 정의
  • MatchService: join/cancel 후 Queue*Event 발행
  • DraftTimingService: 라운드/상태 전이 판단 시 State/Round*Event 발행
  • TimingScheduler: TaskScheduler로 openAt/lockAt 시각 예약, 라운드 변경 시 재등록
  • (멀티노드) 분산락 util: RedisLock.tryLock(key, ttl) + 중복 방지
  • MatchHandler: @Scheduled broadcastStatus() 삭제
  • 메시지 조립을 DTO + ObjectMapper로 전환
  • 프론트: 남은 시간은 openAt/lockAt 기준 클라 계산으로 변경(스펙 노트)

리스크 & 완화

  • 멀티 인스턴스 중복 전송 → Redis SETNX 락/리더십으로 단일 발행 보장
  • 스케줄 시각 표준화 → 모든 예약/전이 계산은 KST 기준으로 일원화, DB는 UTC 저장
  • 누락 방지 → 저빈도 헬스 싱크(예: 30~60초) 옵션으로 재동기화 가능(기본 OFF)

수용 기준(Acceptance)

  • JOIN/CANCEL 시에만 count 변화가 즉시 반영되고, 유휴 시 브로드캐스트 없음
  • openAt/lockAt 전이 시점 ±1s 내에 상태 변경 푸시 도착
  • 초당 스케줄 제거 후 서버 전송 QPS가 기존 대비 유의미하게 감소
  • 멀티 인스턴스 환경에서도 상태 전이 푸시 중복 0회
  • 회귀: 기존 STATUS/DRAFT_START 메시지 타입, 필드 유지

코어 스니펫(축약)

// 1) 이벤트 정의
public record MatchQueueJoinedEvent(String userId) {}
public record MatchQueueCanceledEvent(String userId) {}
public record MatchStateChangedEvent(String prev, String next, UUID roundId) {}
public record RoundWindowChangedEvent(UUID prev, UUID next) {}

// 2) 발행 (MatchService)
publisher.publishEvent(new MatchQueueJoinedEvent(userId));

// 3) 리스너 + 브로드캐스터
@Component
@RequiredArgsConstructor
class MatchEventsListener {
  private final MatchService matchService;
  private final MatchBroadcaster broadcaster;
  private final StatusCache cache;

  @EventListener
  public void onQueueChanged(Object e) {
    var status = matchService.getCurrentStatus();
    if (cache.isChanged(status)) {
      broadcaster.sendStatusToAll(status);
      cache.update(status);
    }
  }

  @EventListener
  public void onStateChanged(MatchStateChangedEvent e) {
    var status = matchService.getCurrentStatus();
    broadcaster.sendStatusToAll(status);
    cache.update(status);
    // LOCKED 진입 시 배치 트리거 등…
  }
}

사이징 메모

  • 단일 인스턴스: 이벤트/브로드캐스터/스케줄러 분리까지만 → 소~중
  • 멀티 인스턴스: Redis 락 + Pub/Sub/Stream 고려 → 중~대

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions