PPI-955 수정 내용 정리

📅 2026-05-19 🌿 브랜치: PPI-955 📁 변경 파일: apps/web/shared/ui/meet-video.tsx

🔍 이슈 배경 및 원인 분석

발생 현상

모니터 측에서 영상 재생이 실제 종료 시점보다 약 1초 일찍 종료 처리되는 현상. 게스트 측에서 onEnd 이벤트가 발화되면 소켓을 통해 호스트/모니터로 종료 알림이 전달되는데, 이 알림이 너무 이르게 발생했음.

근본 원인 ①: near-end 폴백 임계값 과도

timeupdate 핸들러에서 currentTime >= duration - 0.75 조건으로 onEnd를 조기 발화함. 영상이 실제로 끝나기 0.75초 전에 종료 처리가 시작되었음.

근본 원인 ②: videoSrc null 경쟁 조건 (Race Condition)

게스트 onEnd 소켓 이벤트가 호스트로 약 74ms 내에 도달하여 호스트의 videoSrc를 null로 설정. 그러나 호스트 측 video 엘리먼트는 계속 재생 중이고, timeupdate 이벤트가 발화될 때 stale state로 onEnd가 이중 발화됨.

근본 원인 ③: ended 이벤트 미발화 예외 처리 부재

iOS Safari 등 일부 환경에서 ended 이벤트가 발화되지 않을 수 있으나, 기존 durationTimeout 로직은 ended 이벤트 발화 여부와 무관하게 항상 onEnd를 발화시키는 구조였음.

📋 변경 사항 요약

apps/web/shared/ui/meet-video.tsx
항목 변경 전 변경 후
near-end 임계값 duration - 0.75 duration - 0.1
ended 이벤트 추적 없음 endedEventFiredRef 추가
videoSrc 클로저 안전성 props.videoSrc 직접 참조 videoSrcRef.current 경유
durationTimeout 조건 항상 onEnd 발화 ended 미발화시에만 발화 + 재시도 로직(최대 5회)
near-end → durationTimeout 미해제 near-end 발화 시 durationTimeout clearTimeout
handleEnded null 가드 순서 null 체크 → clearDurationTimeout clearDurationTimeout → null 체크

🛠 상세 변경 내용

1. endedEventFiredRef 추가

ended 이벤트 발화 여부를 추적하는 ref. durationTimeoutended 미발화 시에만 onEnd를 호출하도록 제어하는 핵심 플래그.

const endedEventFiredRef = useRef(false);

// videoUrl 변경 시, loadstart 시 리셋
endedEventFiredRef.current = false;

2. videoSrcRef 추가

setTimeout 클로저에서 props.videoSrc를 직접 참조하면 closure 생성 시점의 값이 고정되어 stale closure 문제가 발생함. ref를 통해 항상 최신 값을 참조.

const videoSrcRef = useRef(props.videoSrc);

useEffect(() => {
  videoSrcRef.current = props.videoSrc;
}, [props.videoSrc]);

3. durationTimeout — ended 미발화시에만 onEnd 발화

영상 길이만큼 대기 후 ended 이벤트가 발화되지 않았을 경우에만 폴백으로 onEnd를 호출. 아직 재생이 완료되지 않았다면(remaining > 0.3) 최대 5회까지 재스케줄.

const scheduleCheck = (delayMs: number, attempt = 0) => {
  durationTimeoutRef.current = setTimeout(() => {
    durationTimeoutRef.current = null;

    // ended 이벤트 발화됨 / 이미 onEnd 호출됨 / 재생 중 아님 / videoSrc 없음 → skip
    if (endedEventFiredRef.current || onEndCalledRef.current
        || !isPlayingRef.current || !videoSrcRef.current) return;

    const remaining = video.duration - video.currentTime;
    if (remaining > 0.3 && attempt < 5) {
      scheduleCheck(remaining * 1000, attempt + 1);
      return;
    }

    logger.warn("Video duration timeout: ended event not fired, triggering onEnd fallback", {
      videoSrc: props.videoSrc, duration: video.duration, currentTime: video.currentTime,
    });
    onEndCalledRef.current = true;
    props.onEnd?.();
  }, delayMs);
};

scheduleCheck(remainingSec * 1000);

4. handleEnded — clearDurationTimeout 순서 수정

기존에는 videoSrc null 체크가 clearDurationTimeout보다 먼저 실행되어, videoSrc가 null이면 타임아웃이 해제되지 않는 버그가 있었음. 순서를 변경하여 항상 타임아웃을 먼저 해제.

const handleEnded = () => {
  endedEventFiredRef.current = true;
  // ...로깅...
  clearDurationTimeout(); // null 체크 전에 반드시 실행
  if (!videoSrcRef.current) return;
  if (!onEndCalledRef.current) {
    onEndCalledRef.current = true;
    props.onEnd?.();
  }
};

5. handleTimeUpdate — near-end 임계값 0.75 → 0.1, videoSrcRef 가드, clearDurationTimeout

near-end 폴백의 역할은 ended 이벤트가 실제 종료 시점보다 늦게 오거나 안 오는 경우의 안전망. 0.75초는 너무 이르기 때문에 0.1초로 줄임. near-end 발화 시 durationTimeout도 함께 해제.

if (
  videoSrcRef.current &&         // videoSrc null 가드 (race condition 방어)
  !nearEndLoggedRef.current &&
  Number.isFinite(video.duration) &&
  video.duration > 0 &&
  video.currentTime >= Math.max(0, video.duration - 0.1)  // 0.75 → 0.1
) {
  nearEndLoggedRef.current = true;
  if (!onEndCalledRef.current) {
    onEndCalledRef.current = true;
    clearDurationTimeout();       // durationTimeout 해제
    props.onEnd?.();
  }
}

🔀 onEnd 발화 경로 (우선순위)

① ended 이벤트 (Primary)

HTML5 video 엘리먼트가 재생 완료 시 자동 발화. endedEventFiredRef를 true로 설정하여 다른 경로가 중복 발화하지 않도록 차단.

② near-end 폴백 (currentTime ≥ duration - 0.1초)

timeupdate 이벤트에서 재생 위치가 종료 0.1초 전에 도달하면 발화. ended가 늦게 오는 경우 대비. 발화 시 durationTimeout 해제.

③ durationTimeout (Fallback)

iOS Safari 등 ended 이벤트가 아예 발화되지 않는 환경 대비. endedEventFiredRef가 false인 경우에만 onEnd 호출. 재생이 아직 완료되지 않았다면 최대 5회 재스케줄.

각 경로는 onEndCalledRef를 통해 중복 발화를 방지하며, 먼저 발화한 경로가 다른 경로를 차단함.

⚡ 수정 전후 흐름 비교

수정 전 (버그 상황)

  1. 게스트: currentTime >= duration - 0.75 조건 충족 → onEnd 조기 발화 (약 0.75초 이른 시점)
  2. 게스트 → 소켓 → 호스트: videoSrc = null 설정 (~74ms)
  3. 호스트: video가 계속 재생 중 + durationTimeout도 onEnd 발화 → 이중 발화 가능성

수정 후 (정상 흐름)

  1. 게스트: ended 이벤트 발화 → onEnd 발화
  2. ended 미발화 시: currentTime >= duration - 0.1 조건에서 near-end 폴백 발화
  3. ended도 near-end도 발화 안 될 경우: durationTimeout이 최후 폴백으로 발화
  4. 호스트: videoSrc = nullvideoSrcRef 가드로 중복 onEnd 차단

🔗 Pull Request

변경 내용은 아래 PR로 제출되었습니다.

↗ #631 [PPI-955] fix: 비디오 onEnd 조기 발화 및 중복 발화 수정