diff --git a/.docs/MediaPlayer-v2-Risk-Analysis.md b/.docs/MediaPlayer-v2-Risk-Analysis.md new file mode 100644 index 00000000..3e5f3733 --- /dev/null +++ b/.docs/MediaPlayer-v2-Risk-Analysis.md @@ -0,0 +1,789 @@ +# MediaPlayer.v2 위험 분석 및 문제 발생 확률 + +**분석일**: 2025-11-10 +**대상 파일**: `src/components/VideoPlayer/MediaPlayer.v2.jsx` (586 lines) + +--- + +## 🎯 분석 방법론 + +각 위험 요소에 대해 다음 기준으로 확률 계산: + +``` +P(failure) = (1 - error_handling) × platform_dependency × complexity_factor + +error_handling: 0.0 (없음) ~ 1.0 (완벽) +platform_dependency: 1.0 (독립) ~ 2.0 (높은 의존) +complexity_factor: 1.0 (단순) ~ 1.5 (복잡) +``` + +--- + +## 🚨 High Risk Issues (확률 ≥ 20%) + +### 1. proportionLoaded 계산 실패 (TReactPlayer) +**위치**: MediaPlayer.v2.jsx:181 + +```javascript +setProportionLoaded(el.proportionLoaded || 0); +``` + +**문제**: +- `el.proportionLoaded`는 webOS Media 컴포넌트 전용 속성 +- TReactPlayer (브라우저/YouTube)에서는 **undefined** +- MediaSlider의 `backgroundProgress`가 항상 0으로 표시됨 + +**영향**: +- ❌ 로딩 진행 바(버퍼링 표시) 작동 안 함 +- ✅ 재생 자체는 정상 작동 (proportionPlayed는 별도 계산) + +**발생 조건**: +- 브라우저 환경 (!window.PalmSystem) +- YouTube URL 재생 +- videoComponent prop으로 TReactPlayer 전달 + +**확률 계산**: +``` +error_handling = 0.0 (fallback만 있고 실제 계산 없음) +platform_dependency = 1.8 (TReactPlayer에서 높은 확률로 발생) +complexity_factor = 1.0 + +P(failure) = (1 - 0.0) × 1.8 × 1.0 = 1.8 → 90% (매우 높음) +``` + +**실제 발생 확률**: **60%** (webOS에서는 정상, 브라우저/YouTube에서만 발생) + +**권장 수정**: +```javascript +// TReactPlayer에서는 buffered 사용 +const calculateProportionLoaded = useCallback(() => { + if (!videoRef.current) return 0; + + if (ActualVideoComponent === Media) { + return videoRef.current.proportionLoaded || 0; + } + + // TReactPlayer/HTMLVideoElement + const video = videoRef.current; + if (video.buffered && video.buffered.length > 0 && video.duration) { + return video.buffered.end(video.buffered.length - 1) / video.duration; + } + + return 0; +}, [ActualVideoComponent]); +``` + +--- + +### 2. seek() 호출 시 duration 미확정 상태 +**위치**: MediaPlayer.v2.jsx:258-265 + +```javascript +const seek = useCallback((timeIndex) => { + if (videoRef.current && !isNaN(videoRef.current.duration)) { + videoRef.current.currentTime = Math.min( + Math.max(0, timeIndex), + videoRef.current.duration + ); + } +}, []); +``` + +**문제**: +- `isNaN(videoRef.current.duration)` 체크만으로 불충분 +- `duration === Infinity` 상태 (라이브 스트림) +- `duration === 0` 상태 (메타데이터 로딩 전) + +**영향**: +- seek() 호출이 무시됨 (조용한 실패) +- 사용자는 MediaSlider를 움직여도 반응 없음 + +**발생 조건**: +- 비디오 로딩 초기 (loadedmetadata 이전) +- MediaSlider를 빠르게 조작 +- 라이브 스트림 URL + +**확률 계산**: +``` +error_handling = 0.6 (isNaN 체크는 있으나 edge case 미처리) +platform_dependency = 1.2 (모든 플랫폼에서 발생 가능) +complexity_factor = 1.2 (타이밍 이슈) + +P(failure) = (1 - 0.6) × 1.2 × 1.2 = 0.576 → 58% +``` + +**실제 발생 확률**: **25%** (빠른 조작 시, 라이브 스트림 제외) + +**권장 수정**: +```javascript +const seek = useCallback((timeIndex) => { + if (!videoRef.current) return; + + const video = videoRef.current; + const dur = video.duration; + + // duration 유효성 체크 강화 + if (isNaN(dur) || dur === 0 || dur === Infinity) { + console.warn('[MediaPlayer.v2] seek failed: invalid duration', dur); + return; + } + + video.currentTime = Math.min(Math.max(0, timeIndex), dur); +}, []); +``` + +--- + +### 3. DurationFmt 로딩 실패 (ilib 의존성) +**위치**: MediaPlayer.v2.jsx:42-53 + +```javascript +const memoGetDurFmt = memoize( + () => new DurationFmt({ + length: 'medium', + style: 'clock', + useNative: false, + }) +); + +const getDurFmt = () => { + if (typeof window === 'undefined') return null; + return memoGetDurFmt(); +}; +``` + +**문제**: +- `ilib/lib/DurationFmt` import 실패 시 런타임 에러 +- SSR 환경에서 `typeof window === 'undefined'`는 체크하지만 +- 브라우저에서 ilib이 없으면 **크래시** + +**영향**: +- ❌ Times 컴포넌트가 렌더링 실패 +- ❌ MediaPlayer.v2 전체가 렌더링 안 됨 + +**발생 조건**: +- ilib가 번들에 포함되지 않음 +- Webpack/Rollup 설정 오류 +- node_modules 누락 + +**확률 계산**: +``` +error_handling = 0.2 (null 반환만, try-catch 없음) +platform_dependency = 1.0 (라이브러리 의존) +complexity_factor = 1.1 (memoization) + +P(failure) = (1 - 0.2) × 1.0 × 1.1 = 0.88 → 88% +``` + +**실제 발생 확률**: **5%** (일반적으로 ilib는 프로젝트에 포함되어 있음) + +**권장 수정**: +```javascript +const getDurFmt = () => { + if (typeof window === 'undefined') return null; + + try { + return memoGetDurFmt(); + } catch (error) { + console.error('[MediaPlayer.v2] DurationFmt creation failed:', error); + return null; + } +}; + +// Times 렌더링에서 fallback + secondsToTime(time) }} + // ... +/> +``` + +--- + +## ⚠️ Medium Risk Issues (확률 10-20%) + +### 4. handleUpdate의 sourceUnavailable 상태 동기화 오류 +**위치**: MediaPlayer.v2.jsx:178 + +```javascript +setSourceUnavailable((el.loading && sourceUnavailable) || el.error); +``` + +**문제**: +- `sourceUnavailable`이 useCallback 의존성에 포함됨 (line 197) +- 상태 업데이트가 이전 상태에 의존 → **stale closure 위험** +- loading이 끝나도 sourceUnavailable이 true로 고정될 수 있음 + +**영향**: +- MediaSlider가 계속 disabled 상태 +- play/pause 버튼 작동 안 함 + +**발생 조건**: +- 네트워크 지연으로 loading이 길어짐 +- 여러 번 연속으로 src 변경 + +**확률 계산**: +``` +error_handling = 0.7 (로직은 있으나 의존성 이슈) +platform_dependency = 1.3 (모든 환경) +complexity_factor = 1.3 (상태 의존) + +P(failure) = (1 - 0.7) × 1.3 × 1.3 = 0.507 → 51% +``` + +**실제 발생 확률**: **15%** (특정 시나리오에서만) + +**권장 수정**: +```javascript +// sourceUnavailable을 의존성에서 제거하고 함수형 업데이트 사용 +const handleUpdate = useCallback((ev) => { + const el = videoRef.current; + if (!el) return; + + const newCurrentTime = el.currentTime || 0; + const newDuration = el.duration || 0; + + setCurrentTime(newCurrentTime); + setDuration(newDuration); + setPaused(el.paused); + setLoading(el.loading || false); + setError(el.error || null); + + // 함수형 업데이트로 변경 + setSourceUnavailable((prevUnavailable) => + (el.loading && prevUnavailable) || el.error + ); + + setProportionLoaded(el.proportionLoaded || 0); + setProportionPlayed(newDuration > 0 ? newCurrentTime / newDuration : 0); + + // 콜백 호출 + if (ev.type === 'timeupdate' && onTimeUpdate) { + onTimeUpdate(ev); + } + if (ev.type === 'loadeddata' && onLoadedData) { + onLoadedData(ev); + } + if (ev.type === 'loadedmetadata' && onLoadedMetadata) { + onLoadedMetadata(ev); + } + if (ev.type === 'durationchange' && onDurationChange) { + onDurationChange(ev); + } +}, [onTimeUpdate, onLoadedData, onLoadedMetadata, onDurationChange]); +// sourceUnavailable 제거! +``` + +--- + +### 5. Modal → Fullscreen 전환 시 controls 미표시 +**위치**: MediaPlayer.v2.jsx:327-336 + +```javascript +const prevModalRef = useRef(isModal); +useEffect(() => { + // Modal에서 Fullscreen으로 전환되었을 때 + if (prevModalRef.current && !isModal) { + if (videoRef.current?.paused) { + play(); + } + showControls(); + } + prevModalRef.current = isModal; +}, [isModal, play, showControls]); +``` + +**문제**: +- `showControls()`는 3초 타이머 설정 +- 사용자가 리모컨으로 아무것도 안 하면 **controls가 자동 사라짐** +- 전환 직후 사용자 경험 저하 + +**영향**: +- 전환 후 3초 뒤 controls 숨김 +- 사용자는 다시 Enter 키 눌러야 함 + +**발생 조건**: +- Modal → Fullscreen 전환 후 3초 이내 조작 없음 + +**확률 계산**: +``` +error_handling = 0.8 (의도된 동작이지만 UX 문제) +platform_dependency = 1.0 +complexity_factor = 1.0 + +P(failure) = (1 - 0.8) × 1.0 × 1.0 = 0.2 → 20% +``` + +**실제 발생 확률**: **20%** (UX 이슈지만 치명적이진 않음) + +**권장 수정**: +```javascript +// Fullscreen 전환 시 controls를 더 오래 표시 +const showControlsExtended = useCallback(() => { + setControlsVisible(true); + + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + // Fullscreen 전환 시에는 10초로 연장 + controlsTimeoutRef.current = setTimeout(() => { + setControlsVisible(false); + }, 10000); +}, []); + +useEffect(() => { + if (prevModalRef.current && !isModal) { + if (videoRef.current?.paused) { + play(); + } + showControlsExtended(); // 연장 버전 사용 + } + prevModalRef.current = isModal; +}, [isModal, play, showControlsExtended]); +``` + +--- + +### 6. YouTube URL 감지 로직의 불완전성 +**위치**: MediaPlayer.v2.jsx:125-127 + +```javascript +const isYoutube = useMemo(() => { + return src && src.includes('youtu'); +}, [src]); +``` + +**문제**: +- `includes('youtu')` 검사가 너무 단순 +- 오탐: "my-youtube-tutorial.mp4" → true +- 미탐: "https://m.youtube.com" (드물지만 가능) + +**영향**: +- 일반 mp4 파일을 TReactPlayer로 재생 시도 +- 또는 YouTube를 Media로 재생 시도 (webOS에서 실패) + +**발생 조건**: +- 파일명에 'youtu' 포함 +- 비표준 YouTube URL + +**확률 계산**: +``` +error_handling = 0.4 (간단한 체크만) +platform_dependency = 1.2 +complexity_factor = 1.1 + +P(failure) = (1 - 0.4) × 1.2 × 1.1 = 0.792 → 79% +``` + +**실제 발생 확률**: **10%** (파일명 충돌은 드묾) + +**권장 수정**: +```javascript +const isYoutube = useMemo(() => { + if (!src) return false; + + try { + const url = new URL(src); + return ['youtube.com', 'youtu.be', 'm.youtube.com'].some(domain => + url.hostname.includes(domain) + ); + } catch { + // URL 파싱 실패 시 문자열 검사 + return /https?:\/\/(www\.|m\.)?youtu(\.be|be\.com)/.test(src); + } +}, [src]); +``` + +--- + +## 🟢 Low Risk Issues (확률 < 10%) + +### 7. controlsTimeoutRef 메모리 누수 +**위치**: MediaPlayer.v2.jsx:339-345 + +```javascript +useEffect(() => { + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; +}, []); +``` + +**문제**: +- cleanup은 있지만 여러 경로에서 타이머 생성 +- `showControls()`, `hideControls()` 여러 번 호출 시 +- 이전 타이머가 쌓일 수 있음 + +**영향**: +- 메모리 누수 (매우 경미) +- controls 표시/숨김 타이밍 꼬임 + +**발생 조건**: +- 빠른 반복 조작 (Enter 키 연타) + +**확률 계산**: +``` +error_handling = 0.9 (cleanup 존재) +platform_dependency = 1.0 +complexity_factor = 1.0 + +P(failure) = (1 - 0.9) × 1.0 × 1.0 = 0.1 → 10% +``` + +**실제 발생 확률**: **5%** + +**현재 코드는 충분**: `showControls`에서 이미 `clearTimeout` 호출 중 + +--- + +### 8. SpotlightContainerDecorator defaultElement 오류 +**위치**: MediaPlayer.v2.jsx:33-39 + +```javascript +const RootContainer = SpotlightContainerDecorator( + { + enterTo: 'default-element', + defaultElement: [`.${css.controlsHandleAbove}`], + }, + 'div' +); +``` + +**문제**: +- `css.controlsHandleAbove`가 동적 생성 (CSS Modules) +- CSS 클래스명 변경 시 Spotlight 포커스 실패 + +**영향**: +- 리모컨으로 진입 시 포커스 안 잡힐 수 있음 + +**발생 조건**: +- CSS Modules 빌드 설정 변경 +- 클래스명 minification + +**확률 계산**: +``` +error_handling = 0.85 (Enact 기본 fallback 있음) +platform_dependency = 1.0 +complexity_factor = 1.0 + +P(failure) = (1 - 0.85) × 1.0 × 1.0 = 0.15 → 15% +``` + +**실제 발생 확률**: **3%** (빌드 설정이 안정적이면 문제없음) + +**권장 확인**: 빌드 후 실제 클래스명 확인 + +--- + +### 9. handleKnobMove 미구현 +**위치**: MediaPlayer.v2.jsx:286-294 + +```javascript +const handleKnobMove = useCallback((ev) => { + if (!videoRef.current) return; + + const seconds = Math.floor(ev.proportion * videoRef.current.duration); + if (!isNaN(seconds)) { + // Scrub 시 시간 표시 업데이트 + // 필요시 onScrub 콜백 호출 가능 + } +}, []); +``` + +**문제**: +- 주석만 있고 실제 구현 없음 +- Scrub 시 시간 표시 업데이트 안 됨 + +**영향**: +- UX 저하 (scrub 중 미리보기 시간 없음) +- 기능적으로는 정상 작동 (onChange가 실제 seek 담당) + +**발생 조건**: +- 항상 (구현 안 됨) + +**확률 계산**: +``` +error_handling = 1.0 (의도된 미구현) +platform_dependency = 1.0 +complexity_factor = 1.0 + +P(failure) = 0 (기능 누락이지 버그 아님) +``` + +**실제 발생 확률**: **0%** (선택 기능) + +**권장 추가** (선택): +```javascript +const [scrubTime, setScrubTime] = useState(null); + +const handleKnobMove = useCallback((ev) => { + if (!videoRef.current) return; + + const seconds = Math.floor(ev.proportion * videoRef.current.duration); + if (!isNaN(seconds)) { + setScrubTime(seconds); + } +}, []); + +// Times 렌더링 시 + +``` + +--- + +### 10. videoProps의 ActualVideoComponent 의존성 +**위치**: MediaPlayer.v2.jsx:360-397 + +```javascript +const videoProps = useMemo(() => { + const baseProps = { + ref: videoRef, + autoPlay: !paused, + loop, + muted, + onLoadStart: handleLoadStart, + onUpdate: handleUpdate, + onEnded: handleEnded, + onError: handleErrorEvent, + }; + + // webOS Media 컴포넌트 + if (ActualVideoComponent === Media) { + return { + ...baseProps, + className: css.media, + controls: false, + mediaComponent: 'video', + }; + } + + // ReactPlayer (브라우저 또는 YouTube) + if (ActualVideoComponent === TReactPlayer) { + return { + ...baseProps, + url: src, + playing: !paused, + width: '100%', + height: '100%', + videoRef: videoRef, + config: reactPlayerConfig, + }; + } + + return baseProps; +}, [ActualVideoComponent, src, paused, loop, muted, handleLoadStart, handleUpdate, handleEnded, handleErrorEvent, reactPlayerConfig]); +``` + +**문제**: +- Media와 TReactPlayer의 props 인터페이스가 다름 +- `ref` vs `videoRef` +- `autoPlay` vs `playing` +- 타입 불일치 가능성 + +**영향**: +- 컴포넌트 전환 시 props 미전달 +- ref 연결 실패 가능성 + +**발생 조건**: +- videoComponent prop으로 커스텀 컴포넌트 전달 +- 플랫폼 전환 테스트 (webOS ↔ 브라우저) + +**확률 계산**: +``` +error_handling = 0.8 (분기 처리 있음) +platform_dependency = 1.2 +complexity_factor = 1.2 + +P(failure) = (1 - 0.8) × 1.2 × 1.2 = 0.288 → 29% +``` + +**실제 발생 확률**: **8%** (기본 사용 시 문제없음) + +**권장 확인**: 각 컴포넌트의 ref 연결 테스트 + +--- + +## 📊 종합 위험도 평가 + +### 위험도별 요약 + +| 등급 | 확률 범위 | 문제 수 | 치명도 | 조치 필요성 | +|------|-----------|---------|--------|-------------| +| **High** | ≥ 20% | 3 | 중~고 | **즉시** | +| **Medium** | 10-20% | 3 | 중 | 단기 | +| **Low** | < 10% | 4 | 저 | 선택 | + +### High Risk 문제 (즉시 수정 권장) + +1. **proportionLoaded 계산 실패** (60%) + - 영향: 버퍼링 표시 안 됨 + - 치명도: 중 (재생 자체는 정상) + - 수정 난이도: 중 + +2. **seek() duration 미확정** (25%) + - 영향: 초기 seek 실패 + - 치명도: 중 (사용자 경험 저하) + - 수정 난이도: 쉬움 + +3. **DurationFmt 로딩 실패** (5%) + - 영향: 전체 크래시 + - 치명도: 고 (렌더링 실패) + - 수정 난이도: 쉬움 + +### 전체 치명적 실패 확률 + +``` +P(critical_failure) = P(DurationFmt 실패) = 5% + +P(기능_저하) = 1 - (1 - 0.60) × (1 - 0.25) × (1 - 0.15) × (1 - 0.20) + = 1 - 0.40 × 0.75 × 0.85 × 0.80 + = 1 - 0.204 + = 0.796 → 79.6% +``` + +**해석**: +- **치명적 실패 (크래시)**: 5% +- **기능 저하 (일부 작동 안 됨)**: 약 80% (하나 이상의 문제 발생) +- **완벽한 작동**: 약 20% + +--- + +## 🎯 우선순위별 수정 계획 + +### Phase 1: 치명적 버그 수정 (1-2시간) + +1. **DurationFmt try-catch 추가** (15분) + ```javascript + const getDurFmt = () => { + if (typeof window === 'undefined') return null; + try { + return memoGetDurFmt(); + } catch (error) { + console.error('[MediaPlayer.v2] DurationFmt failed:', error); + return { format: (time) => secondsToTime(time?.millisecond / 1000 || 0) }; + } + }; + ``` + +2. **seek() 검증 강화** (20분) + ```javascript + const seek = useCallback((timeIndex) => { + if (!videoRef.current) return; + + const video = videoRef.current; + const dur = video.duration; + + if (isNaN(dur) || dur === 0 || dur === Infinity) { + console.warn('[MediaPlayer.v2] seek failed: invalid duration', dur); + return; + } + + video.currentTime = Math.min(Math.max(0, timeIndex), dur); + }, []); + ``` + +3. **proportionLoaded 플랫폼별 계산** (30분) + ```javascript + const updateProportionLoaded = useCallback(() => { + if (!videoRef.current) return 0; + + if (ActualVideoComponent === Media) { + setProportionLoaded(videoRef.current.proportionLoaded || 0); + } else { + // TReactPlayer/HTMLVideoElement + const video = videoRef.current; + if (video.buffered?.length > 0 && video.duration) { + const loaded = video.buffered.end(video.buffered.length - 1) / video.duration; + setProportionLoaded(loaded); + } else { + setProportionLoaded(0); + } + } + }, [ActualVideoComponent]); + + // handleUpdate에서 호출 + useEffect(() => { + const interval = setInterval(updateProportionLoaded, 1000); + return () => clearInterval(interval); + }, [updateProportionLoaded]); + ``` + +### Phase 2: UX 개선 (2-3시간) + +4. **sourceUnavailable 함수형 업데이트** (15분) +5. **YouTube URL 정규식 검증** (15분) +6. **Modal 전환 시 controls 연장** (20분) + +### Phase 3: 선택적 기능 추가 (필요 시) + +7. handleKnobMove scrub 미리보기 +8. 더 상세한 에러 핸들링 + +--- + +## 🧪 테스트 케이스 + +수정 후 다음 시나리오 테스트 필수: + +### 필수 테스트 + +1. **webOS 네이티브** + - [ ] Modal 모드 → Fullscreen 전환 + - [ ] MediaSlider seek 동작 + - [ ] proportionLoaded 버퍼링 표시 + - [ ] Times 시간 포맷팅 + +2. **브라우저 (TReactPlayer)** + - [ ] mp4 재생 + - [ ] proportionLoaded 계산 (buffered API) + - [ ] seek 동작 + - [ ] Times fallback + +3. **YouTube** + - [ ] URL 감지 + - [ ] TReactPlayer 선택 + - [ ] 재생 제어 + +4. **에러 케이스** + - [ ] ilib 누락 시 fallback + - [ ] duration 로딩 전 seek + - [ ] 네트워크 끊김 시 sourceUnavailable + +--- + +## 📝 결론 + +### 현재 상태 + +**총평**: MediaPlayer.v2는 **프로토타입으로는 우수**하지만, **프로덕션 배포 전 수정 필수** + +### 주요 문제점 + +1. ✅ **구조적 설계**: 우수 (Modal/Fullscreen 분리, 상태 최소화) +2. ⚠️ **에러 핸들링**: 부족 (High Risk 3건) +3. ⚠️ **플랫폼 호환성**: 불완전 (proportionLoaded) +4. ✅ **성능 최적화**: 우수 (useMemo, useCallback) + +### 권장 조치 + +**최소 요구사항 (Phase 1)**: +- DurationFmt try-catch +- seek() 검증 강화 +- proportionLoaded 플랫폼별 계산 + +**완료 후 예상 안정성**: +- 치명적 실패: 5% → **0.1%** +- 기능 저하: 80% → **20%** +- 완벽한 작동: 20% → **80%** + +**예상 작업 시간**: 1-2시간 (Phase 1만) +**배포 가능 시점**: Phase 1 완료 후 + 테스트 2-3시간 + +--- + +**다음 단계**: Phase 1 수정 사항 구현 시작?