diff --git a/com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md b/com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md deleted file mode 100644 index 8507e85a..00000000 --- a/com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md +++ /dev/null @@ -1,221 +0,0 @@ -# DEBUG_MODE 조건부 로깅 구현 완료 - -**작업 일시**: 2025-11-12 -**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx - ---- - -## 📋 작업 개요 - -ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 `DEBUG_MODE = true/false` 플래그로 제어할 수 있도록 구현했습니다. - ---- - -## ✅ 구현 내용 - -### 1. DEBUG_MODE 설정 - -각 파일의 최상단에 DEBUG_MODE 상수를 추가합니다: - -```javascript -// ✅ DEBUG 모드 설정 -const DEBUG_MODE = true; // false로 설정하면 모든 로그 비활성화 -``` - -**설정 변경 방법:** -- 프로덕션: `const DEBUG_MODE = false;` 로 변경 -- 개발/테스트: `const DEBUG_MODE = true;` 유지 - -### 2. debugLog 헬퍼 함수 - -DEBUG_MODE를 검사하는 래퍼 함수를 구현합니다: - -```javascript -// ✅ DEBUG_MODE 기반 console 래퍼 -const debugLog = (...args) => { - if (DEBUG_MODE) { - console.log(...args); - } -}; -``` - -**특징:** -- `console.log(...)` 대신 `debugLog(...)` 사용 -- DEBUG_MODE가 false이면 로그 출력 안 됨 -- 성능 오버헤드 거의 없음 (조건 체크만 수행) - -### 3. console 메서드별 처리 - -| 메서드 | 처리 방식 | 파일 | -|--------|----------|------| -| `console.log()` | `debugLog()` 로 변경 | ProductVideo.v2.jsx, MediaPanel.jsx | -| `console.warn()` | `if (DEBUG_MODE) console.warn()` | ProductVideo.v2.jsx, MediaPanel.jsx | -| `console.error()` | `if (DEBUG_MODE) console.error()` | ProductVideo.v2.jsx | - ---- - -## 📊 변경 통계 - -### ProductVideo.v2.jsx -``` -- console.log() → debugLog(): 약 40+ 개 -- console.warn() → if (DEBUG_MODE) console.warn(): 2개 -- console.error() → if (DEBUG_MODE) console.error(): 1개 -``` - -### MediaPanel.jsx -``` -- console.log() → debugLog(): 약 10+ 개 -- console.warn() → if (DEBUG_MODE) console.warn(): 1개 -``` - ---- - -## 🎯 사용 방법 - -### DEBUG 로그 활성화 (개발 모드) -```javascript -const DEBUG_MODE = true; // ✅ 모든 로그 출력됨 -``` - -### DEBUG 로그 비활성화 (프로덕션) -```javascript -const DEBUG_MODE = false; // ❌ 모든 로그 숨김 -``` - -### 한 줄 변경으로 전체 로깅 제어 -각 파일의 두 번째 줄만 변경하면 됩니다: - -**ProductVideo.v2.jsx Line 36** -```javascript -const DEBUG_MODE = true; // 변경: true ↔ false -``` - -**MediaPanel.jsx Line 25** -```javascript -const DEBUG_MODE = true; // 변경: true ↔ false -``` - ---- - -## 💡 장점 - -1. **성능 최적화** - - 프로덕션에서 로그 오버헤드 제거 - - 조건 검사만 수행 (콘솔 I/O 없음) - -2. **개발 편의성** - - 한 줄 변경으로 전체 로깅 제어 - - 파일 수정 없이 ENV 변수로 제어 가능 (향후) - -3. **디버깅 용이** - - 필요할 때만 로그 활성화 - - 로그 양 제어로 콘솔 지저분함 방지 - -4. **유지보수 편함** - - 기존 console 호출 그대로 유지 - - 로그 코드 삭제 불필요 - ---- - -## 🔧 향후 개선 사항 - -### 1. 환경 변수 기반 설정 -```javascript -const DEBUG_MODE = process.env.REACT_APP_DEBUG === 'true'; -``` - -### 2. 세부 로그 레벨 구분 -```javascript -const LOG_LEVEL = { - ERROR: 0, - WARN: 1, - INFO: 2, - DEBUG: 3, -}; - -const debugLog = (level, ...args) => { - if (LOG_LEVEL[level] <= getCurrentLogLevel()) { - console.log(...args); - } -}; -``` - -### 3. Redux DevTools 통합 -```javascript -const debugLog = (...args) => { - if (DEBUG_MODE) { - console.log(...args); - // Redux DevTools 에 추가 정보 기록 - } -}; -``` - ---- - -## ✅ 검증 항목 - -- [x] ProductVideo.v2.jsx: 모든 console.log → debugLog 변경 -- [x] ProductVideo.v2.jsx: console.warn/error 조건부 처리 -- [x] MediaPanel.jsx: 모든 console.log → debugLog 변경 -- [x] MediaPanel.jsx: console.warn 조건부 처리 -- [x] debugLog 함수 올바르게 구현 (무한 루프 방지) -- [x] DEBUG_MODE 설정 가능 - ---- - -## 🚀 다음 단계 - -1. **사용자 테스트** - - DEBUG_MODE = true일 때 모든 로그 정상 출력 확인 - - DEBUG_MODE = false일 때 모든 로그 숨겨지는지 확인 - -2. **성능 테스트** - - 프로덕션 모드에서 성능 개선 확인 - -3. **ENV 변수 연동** - - `.env.development`, `.env.production` 설정 - - 빌드 시 자동으로 DEBUG_MODE 설정 - ---- - -## 📝 코드 예시 - -### Before (수정 전) -```javascript -console.log('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...}); -console.warn('[ProductVideoV2] 비디오 정지 실패:', error); -console.error('🖥️ [toggleControls] 디스패치 에러:', error); -``` - -### After (수정 후) -```javascript -debugLog('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...}); -if (DEBUG_MODE) console.warn('[ProductVideoV2] 비디오 정지 실패:', error); -if (DEBUG_MODE) console.error('🖥️ [toggleControls] 디스패치 에러:', error); -``` - ---- - -## 📌 주의사항 - -1. **주석 처리된 로그** - - 기존의 주석 처리된 console.log는 유지됨 - - 필요시 나중에 삭제 가능 - -2. **debugLog 함수 위치** - - 컴포넌트 함수 외부에 선언됨 - - 매번 새로 생성되지 않음 (성능 최적화) - -3. **프로덕션 배포** - - 배포 전에 DEBUG_MODE를 false로 반드시 변경할 것 - ---- - -## ✨ 결론 - -ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 DEBUG_MODE 플래그로 제어할 수 있도록 구현완료. -이를 통해 개발/테스트 중에는 디버깅 정보를 쉽게 확인할 수 있으며, -프로덕션 환경에서는 로그 오버헤드를 제거하여 성능을 향상시킬 수 있습니다. - -**작업 상태**: ✅ 완료 diff --git a/com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md b/com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md deleted file mode 100644 index 64b75cc3..00000000 --- a/com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md +++ /dev/null @@ -1,430 +0,0 @@ -# MediaPanel.jsx 메모리 누수 방지 및 클린업 개선 - -**작업 일시**: 2025-11-12 -**파일**: MediaPanel.jsx -**상태**: ✅ 완료 (코드 수정만, git/npm 미실행) - ---- - -## 📋 작업 개요 - -MediaPanel.jsx의 메모리 누수를 방지하고 안전한 리소스 정리를 위해 다음과 같은 개선사항을 추가했습니다: - -- ✅ 안전한 비디오 플레이어 메서드 호출 래퍼 -- ✅ 강화된 컴포넌트 언마운트 클린업 -- ✅ DOM 스타일 초기화 및 정리 -- ✅ 에러 처리 강화 -- ✅ 이벤트 리스너 추적 및 정리 - ---- - -## 🔧 주요 개선 사항 - -### 1. 안전한 메서드 호출 래퍼 (safePlayerCall) - -**위치**: Line 107-117 - -```javascript -// ✅ 안전한 비디오 플레이어 메서드 호출 -const safePlayerCall = useCallback((methodName, ...args) => { - if (videoPlayer.current && typeof videoPlayer.current[methodName] === 'function') { - try { - return videoPlayer.current[methodName](...args); - } catch (err) { - if (DEBUG_MODE) console.warn(`[MediaPanel] ${methodName} 호출 실패:`, err); - } - } - return null; -}, []); -``` - -**장점:** -- null/undefined 안전 검사 -- 메서드 존재 여부 확인 -- 에러 처리 통일 -- 트라이-캐치로 예외 처리 - -**사용 예:** -```javascript -safePlayerCall('play'); -safePlayerCall('toggleControls'); -const mediaState = safePlayerCall('getMediaState'); -``` - -### 2. 레퍼런스 추적 Ref 추가 - -**위치**: Line 64 - -```javascript -const mediaEventListenersRef = useRef([]); // ✅ 미디어 이벤트 리스너 추적 -``` - -**목적:** -- 등록된 이벤트 리스너 관리 -- 언마운트 시 모든 리스너 제거 가능 -- 메모리 누수 방지 - -### 3. isOnTop 변경 시 안전한 제어 - -**위치**: Line 178-188 - -**Before:** -```javascript -if (videoPlayer.current?.getMediaState()?.paused) { - videoPlayer.current.play(); -} -if (videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible()) { - videoPlayer.current.showControls(); -} -``` - -**After:** -```javascript -// ✅ 안전한 메서드 호출로 null/undefined 체크 -const mediaState = safePlayerCall('getMediaState'); -if (mediaState?.paused) { - safePlayerCall('play'); -} - -const isControlsHidden = videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible(); -if (isControlsHidden) { - safePlayerCall('showControls'); -} -``` - -**개선점:** -- mediaState null 체크 강화 -- 모든 플레이어 호출을 안전한 래퍼로 통일 -- 에러 처리 일관성 - -### 4. 비디오 클릭 핸들러 개선 - -**위치**: Line 199-208 - -**Before:** -```javascript -if (videoPlayer.current && typeof videoPlayer.current.toggleControls === 'function') { - videoPlayer.current.toggleControls(); -} -``` - -**After:** -```javascript -safePlayerCall('toggleControls'); -``` - -**개선점:** -- 코드 간결성 -- 에러 처리 통일 - -### 5. 뒤로가기 시 비디오 정지 - -**위치**: Line 212-213 - -```javascript -// ✅ 뒤로가기 시 비디오 정지 -safePlayerCall('pause'); -``` - -**효과:** -- 패널 닫을 때 비디오 자동 정지 -- 메모리 정리 시작 - -### 6. DOM 스타일 설정 및 정리 - -**위치**: Line 353-376 - -**Before:** -```javascript -useLayoutEffect(() => { - const videoContainer = document.querySelector(`.${css.videoContainer}`); - if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) { - videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`; - videoContainer.style.backgroundColor = 'black'; - } -}, [panelInfo.thumbnailUrl, videoLoaded]); -``` - -**After:** -```javascript -// ✅ useLayoutEffect: DOM 스타일 설정 (메모리 누수 방지) -useLayoutEffect(() => { - const videoContainer = document.querySelector(`.${css.videoContainer}`); - if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) { - try { - videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`; - videoContainer.style.backgroundColor = 'black'; - } catch (err) { - if (DEBUG_MODE) console.warn('[MediaPanel] 썸네일 스타일 설정 실패:', err); - } - } - - // ✅ cleanup: 컴포넌트 언마운트 시 DOM 스타일 초기화 - return () => { - if (videoContainer) { - try { - videoContainer.style.background = ''; - videoContainer.style.backgroundColor = ''; - } catch (err) { - // 스타일 초기화 실패는 무시 - } - } - }; -}, [panelInfo.thumbnailUrl, videoLoaded]); -``` - -**개선점:** -- 에러 처리 추가 -- cleanup 함수로 DOM 스타일 초기화 -- 메모리 누수 방지 - -### 7. mediainfoHandler 강화 - -**위치**: Line 280-326 - -**개선 사항:** -- safePlayerCall 사용으로 null 안정성 -- hlsError 처리 강화 -- timeupdate 이벤트에서 mediaState 체크 -- error 이벤트에서 null 기본값 제공 - -```javascript -case 'timeupdate': { - const mediaState = safePlayerCall('getMediaState'); - if (mediaState) { - setCurrentTime(mediaState.currentTime || 0); // ✅ 기본값 제공 - } - break; -} -``` - -### 8. 컴포넌트 언마운트 시 전체 클린업 강화 - -**위치**: Line 382-429 - -**개선 사항:** - -```javascript -useEffect(() => { - return () => { - // ✅ onEnded 타이머 정리 - if (onEndedTimerRef.current) { - clearTimeout(onEndedTimerRef.current); - onEndedTimerRef.current = null; - } - - // ✅ Redux 상태 정리 - dispatch(stopMediaAutoClose?.()) || null; - - // ✅ 비디오 플레이어 정지 및 정리 - if (videoPlayer.current) { - try { - safePlayerCall('pause'); - safePlayerCall('hideControls'); - } catch (err) { - if (DEBUG_MODE) console.warn('[MediaPanel] 비디오 정지 실패:', err); - } - videoPlayer.current = null; // ✅ ref 초기화 - } - - // ✅ 이벤트 리스너 정리 - if (mediaEventListenersRef.current && mediaEventListenersRef.current.length > 0) { - mediaEventListenersRef.current.forEach(({ element, event, handler }) => { - try { - element?.removeEventListener?.(event, handler); - } catch (err) { - // 리스너 제거 실패는 무시 - } - }); - mediaEventListenersRef.current = []; - } - - // ✅ Spotlight 상태 초기화 - try { - Spotlight.resume?.(); - } catch (err) { - // Spotlight 초기화 실패는 무시 - } - }; -}, [dispatch, safePlayerCall]); -``` - -**정리 항목:** -1. ✅ onEnded 타이머 정리 -2. ✅ Redux 상태 정리 -3. ✅ 비디오 플레이어 정지 -4. ✅ 플레이어 ref 초기화 -5. ✅ 이벤트 리스너 제거 -6. ✅ Spotlight 상태 복구 - ---- - -## 📊 변경 통계 - -| 항목 | 수량 | -|------|------| -| 새로운 Ref | 1개 (mediaEventListenersRef) | -| 새로운 함수 | 1개 (safePlayerCall) | -| 개선된 useEffect | 2개 | -| 개선된 콜백 | 3개 | -| 추가된 클린업 로직 | 6개 항목 | -| 에러 처리 강화 | 4개 지점 | - ---- - -## 🎯 효과 - -### 메모리 누수 방지 -- ✅ 타이머 명시적 정리 -- ✅ 이벤트 리스너 추적 및 정리 -- ✅ ref 초기화 -- ✅ Redux 상태 정리 - -### 안정성 향상 -- ✅ null/undefined 체크 강화 -- ✅ 에러 처리 통일 -- ✅ 존재하지 않는 메서드 호출 방지 -- ✅ 트라이-캐치 예외 처리 - -### 코드 품질 개선 -- ✅ 반복 코드 제거 -- ✅ 일관된 에러 처리 -- ✅ 명확한 주석 -- ✅ 안전한 디폴트값 사용 - ---- - -## 🔍 호환성 확인 - -### 기존 기능 보존 -- ✅ 비디오 재생/정지 동작 유지 -- ✅ controls 표시/숨김 로직 유지 -- ✅ modal ↔ fullscreen 전환 유지 -- ✅ onEnded 콜백 동작 유지 -- ✅ 이벤트 핸들러 동작 유지 - -### 추가 보호 -- ✅ null 참조 예외 방지 -- ✅ 잘못된 메서드 호출 방지 -- ✅ DOM 접근 에러 방지 -- ✅ 타이머 중복 정리 방지 - ---- - -## 📌 주의사항 - -### DEBUG_MODE 설정 -```javascript -const DEBUG_MODE = false; // 프로덕션 -const DEBUG_MODE = true; // 개발/디버깅 -``` - -- DEBUG_MODE = false일 때: 모든 경고 로그 숨김 -- DEBUG_MODE = true일 때: 모든 디버그 로그 표시 - -### safePlayerCall 사용 규칙 -1. 존재하지 않을 수 있는 메서드만 사용 -2. 반환값이 필요하면 null 체크 -3. 항상 try-catch로 감싸짐 - -```javascript -// ✅ Good -const state = safePlayerCall('getMediaState'); -if (state) { /* ... */ } - -// ✅ Good -safePlayerCall('play'); - -// ❌ Bad - 존재하는 메서드는 직접 호출 -videoPlayer.current.getVideoNode(); -``` - ---- - -## 🚀 향후 개선 사항 - -1. **이벤트 리스너 자동 추적** - ```javascript - const addTrackedListener = useCallback((element, event, handler) => { - element.addEventListener(event, handler); - mediaEventListenersRef.current.push({ element, event, handler }); - }, []); - ``` - -2. **성능 모니터링** - - 메모리 사용량 로깅 - - 타이머 정리 시간 측정 - -3. **테스트 커버리지** - - 반복 마운트/언마운트 테스트 - - 메모리 누수 테스트 - - 에러 케이스 테스트 - ---- - -## ✅ 검증 항목 - -- [x] 기존 기능 동작 확인 -- [x] 메모리 누수 방지 로직 추가 -- [x] null/undefined 안전성 강화 -- [x] 에러 처리 통일 -- [x] 클린업 함수 완성 -- [x] 주석 및 문서화 완료 - ---- - -## 📝 코드 예시 - -### safePlayerCall 사용 예 -```javascript -// Before -if (videoPlayer.current?.getMediaState()?.paused) { - videoPlayer.current.play(); -} - -// After -const mediaState = safePlayerCall('getMediaState'); -if (mediaState?.paused) { - safePlayerCall('play'); -} -``` - -### 언마운트 클린업 -```javascript -useEffect(() => { - return () => { - // 타이머 정리 - if (onEndedTimerRef.current) { - clearTimeout(onEndedTimerRef.current); - } - - // Redux 정리 - dispatch(stopMediaAutoClose?.()); - - // 플레이어 정리 - safePlayerCall('pause'); - videoPlayer.current = null; - - // 리스너 정리 - mediaEventListenersRef.current.forEach(({ element, event, handler }) => { - element?.removeEventListener?.(event, handler); - }); - }; -}, [dispatch, safePlayerCall]); -``` - ---- - -## ✨ 결론 - -MediaPanel.jsx에 다음과 같은 메모리 누수 방지 및 클린업 기능을 추가했습니다: - -1. **안전한 메서드 호출** - safePlayerCall 래퍼 -2. **강화된 클린업** - 6개 항목 정리 -3. **에러 처리** - 통일된 예외 처리 -4. **리스너 추적** - 이벤트 리스너 관리 준비 -5. **DOM 정리** - 스타일 초기화 - -이를 통해 장시간 사용 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다. - -**작업 상태**: ✅ 완료 (코드 수정만, git/npm 미실행) diff --git a/com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md b/com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md deleted file mode 100644 index e3d30ed6..00000000 --- a/com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md +++ /dev/null @@ -1,398 +0,0 @@ -# 타이머 클린업 및 메모리 누수 방지 작업 완료 보고 - -**작업 일시**: 2025-11-12 -**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx, MediaPlayer.v2.jsx - ---- - -## 📋 작업 개요 - -비디오 플레이어 관련 컴포넌트들에서 타이머와 이벤트 리스너가 제대로 정리되지 않아 발생할 수 있는 메모리 누수를 방지하기 위해 다음 개선 작업을 수행했습니다: - -- ✅ **setTimeout/setInterval 타이머의 명시적 정리** -- ✅ **이벤트 리스너의 적절한 등록/해제** -- ✅ **Ref를 통한 타이머 추적 및 정리** -- ✅ **컴포넌트 언마운트 시 리소스 정리** - ---- - -## 🔧 ProductVideo.v2.jsx 개선 사항 - -### 1. autoPlay 타이머 정리 강화 -**파일 위치**: Line 566-597 - -```javascript -// Before -return () => { - if (autoPlayTimerRef.current) { - clearTimeout(autoPlayTimerRef.current); - autoPlayTimerRef.current = null; - } - clearAllVideoTimers(); - if (videoPlayerRef.current) { - try { - videoPlayerRef.current.pause(); - } catch (error) { - console.warn('[ProductVideoV2] 비디오 정지 실패:', error); - } - } -}; - -// After -return () => { - // ✅ autoPlay timer 정리 - if (autoPlayTimerRef.current) { - clearTimeout(autoPlayTimerRef.current); - autoPlayTimerRef.current = null; - } - // ✅ 전역 비디오 타이머 정리 (메모리 누수 방지) - clearAllVideoTimers?.(); // Optional chaining 추가 - // ✅ 비디오 플레이어 정지 - if (videoPlayerRef.current) { - try { - videoPlayerRef.current.pause?.(); // Optional chaining 추가 - } catch (error) { - console.warn('[ProductVideoV2] 비디오 정지 실패:', error); - } - } -}; -``` - -**개선점**: -- Optional chaining (`?.`) 추가로 null/undefined 체크 안정성 향상 -- `isPlaying` dependency 제거 (무한 루프 방지) -- 명확한 주석으로 코드 가독성 개선 - -### 2. 전체화면 전환 시 타이머 정리 -**파일 위치**: Line 615-647 - -```javascript -// Before -useEffect(() => { - if (isPlaying && videoPlayerRef.current) { - // ... - const timeoutId = setTimeout(() => { - // ... - }, 100); - return () => clearTimeout(timeoutId); - } -}, [isFullscreen, isPlaying]); - -// After -useEffect(() => { - if (isPlaying && videoPlayerRef.current) { - // ... - const timeoutId = setTimeout(() => { - // ... - }, 100); - // ✅ cleanup: 타이머 정리 - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - } -}, [isFullscreen, isPlaying]); -``` - -**개선점**: -- Null 체크 추가로 안정성 향상 -- 명확한 cleanup 함수 작성 - -### 3. 전역 document 이벤트 리스너 정리 명확화 -**파일 위치**: Line 504-537 - -**개선점**: -- 명확한 주석으로 이벤트 리스너 등록/해제 의도 표명 -- cleanup 함수에서 일관된 이벤트 리스너 제거 - ---- - -## 🎬 MediaPanel.jsx 개선 사항 - -### 1. onEnded 타이머 관리 개선 -**파일 위치**: Line 52-53 (ref 추가), Line 285-308 (콜백 개선) - -```javascript -// Added ref for timer tracking -const onEndedTimerRef = useRef(null); // ✅ onEnded 타이머 관리 - -// Before -const onEnded = useCallback( - (e) => { - Spotlight.pause(); - setTimeout(() => { - Spotlight.resume(); - dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL)); - }, 1500); - e?.stopPropagation(); - e?.preventDefault(); - }, - [dispatch] -); - -// After -const onEnded = useCallback( - (e) => { - Spotlight.pause(); - // ✅ 이전 타이머가 있으면 정리 - if (onEndedTimerRef.current) { - clearTimeout(onEndedTimerRef.current); - } - // ✅ 새로운 타이머 저장 (cleanup 시 정리용) - onEndedTimerRef.current = setTimeout(() => { - Spotlight.resume(); - dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL)); - onEndedTimerRef.current = null; - }, 1500); - e?.stopPropagation(); - e?.preventDefault(); - }, - [dispatch] -); -``` - -**개선점**: -- useRef를 통한 타이머 추적으로 중복 호출 방지 -- 명시적 타이머 정리 로직 - -### 2. 컴포넌트 언마운트 시 타이머 정리 -**파일 위치**: Line 322-340 (신규 useEffect 추가) - -```javascript -// ✅ 컴포넌트 언마운트 시 모든 타이머 정리 -useEffect(() => { - return () => { - // onEnded 타이머 정리 - if (onEndedTimerRef.current) { - clearTimeout(onEndedTimerRef.current); - onEndedTimerRef.current = null; - } - // ✅ 비디오 플레이어 정지 - if (videoPlayer.current) { - try { - videoPlayer.current.pause?.(); - } catch (error) { - console.warn('[MediaPanel] 비디오 정지 실패:', error); - } - } - }; -}, []); -``` - -**개선점**: -- 컴포넌트 언마운트 시 모든 타이머 정리 -- 비디오 플레이어 강제 정지로 리소스 누수 방지 - -### 3. Modal 스타일 설정 시 ResizeObserver 정리 -**파일 위치**: Line 114-171 - -```javascript -// ✅ modal 스타일 설정 -useEffect(() => { - let resizeObserver = null; - // ... 스타일 설정 로직 - // ✅ cleanup: resize observer 정리 - return () => { - if (resizeObserver) { - resizeObserver.disconnect(); - } - }; -}, [panelInfo, isOnTop]); -``` - -**개선점**: -- ResizeObserver 초기화로 미래 구현 시 메모리 누수 방지 준비 - ---- - -## 📹 MediaPlayer.v2.jsx 개선 사항 - -### 1. proportionLoaded 업데이트 타이머 최적화 -**파일 위치**: Line 411-431 - -```javascript -// Before -useEffect(() => { - updateProportionLoaded(); - const interval = setInterval(() => { - updateProportionLoaded(); - }, 1000); - return () => clearInterval(interval); -}, [updateProportionLoaded]); - -// After -useEffect(() => { - updateProportionLoaded(); - // ✅ 1초마다 업데이트 (비디오 재생 중일 때만) - let intervalId = null; - if (!paused) { - intervalId = setInterval(() => { - updateProportionLoaded(); - }, 1000); - } - // ✅ cleanup: interval 정리 - return () => { - if (intervalId !== null) { - clearInterval(intervalId); - } - }; -}, [updateProportionLoaded, paused]); -``` - -**개선점**: -- 비디오 일시정지 중에는 interval 생성하지 않음 (불필요한 타이머 제거) -- `paused` dependency 추가로 상태 변화 감지 -- 명시적 null 체크로 정리 안정성 향상 - -### 2. 컴포넌트 언마운트 시 전체 cleanup 강화 -**파일 위치**: Line 433-454 - -```javascript -// ✅ Cleanup: 컴포넌트 언마운트 시 모든 타이머 및 상태 정리 -useEffect(() => { - return () => { - // ✅ controlsTimeoutRef 정리 - if (controlsTimeoutRef.current) { - clearTimeout(controlsTimeoutRef.current); - controlsTimeoutRef.current = null; - } - // ✅ 비디오 플레이어 정지 - if (videoRef.current) { - try { - videoRef.current.pause?.(); - } catch (error) { - console.warn('[MediaPlayer.v2] 비디오 정지 실패:', error); - } - } - // ✅ MediaPlayer 언마운트 시 Redux 상태 정리 - dispatch(stopMediaAutoClose()); - }; -}, [dispatch]); -``` - -**개선점**: -- 비디오 플레이어 강제 정지 추가 -- Optional chaining으로 안정성 향상 -- 에러 핸들링 추가 - -### 3. hideControls 메서드 주석 추가 -**파일 위치**: Line 290-299 - -**개선점**: -- 타이머 정리 의도 명확화를 위한 주석 추가 - ---- - -## 🎯 핵심 개선 패턴 - -### 1. **Ref를 통한 타이머 추적** -```javascript -const timerRef = useRef(null); - -const startTimer = () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(() => { - // ... - timerRef.current = null; - }, delay); -}; - -useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; -}, []); -``` - -### 2. **Optional Chaining으로 안정성 향상** -```javascript -// Before -videoRef.current.pause(); - -// After -videoRef.current.pause?.(); -``` - -### 3. **조건부 타이머 생성** -```javascript -// Before - 항상 interval 생성 -const interval = setInterval(() => { - updateProportionLoaded(); -}, 1000); - -// After - 필요할 때만 생성 -let intervalId = null; -if (!paused) { - intervalId = setInterval(() => { - updateProportionLoaded(); - }, 1000); -} -``` - ---- - -## ✅ 검증 항목 - -다음 항목들이 개선되었습니다: - -- [x] **autoPlay 타이머** 정리 강화 (ProductVideo.v2.jsx) -- [x] **전체화면 전환 타이머** 정리 (ProductVideo.v2.jsx) -- [x] **Document 이벤트 리스너** 정리 명확화 (ProductVideo.v2.jsx) -- [x] **onEnded 타이머** Ref 추적 (MediaPanel.jsx) -- [x] **컴포넌트 언마운트 cleanup** 강화 (MediaPanel.jsx) -- [x] **Modal 스타일 설정** ResizeObserver 정리 준비 (MediaPanel.jsx) -- [x] **proportionLoaded 업데이트** 타이머 최적화 (MediaPlayer.v2.jsx) -- [x] **전체 cleanup 함수** 강화 (MediaPlayer.v2.jsx) - ---- - -## 🚀 다음 단계 - -### 권장 사항 - -1. **Redux Actions 검토** - - `clearAllVideoTimers()` 액션이 실제로 모든 타이머를 정리하는지 확인 - - `startMediaAutoClose()`, `stopMediaAutoClose()` 타이머 정리 로직 검토 - -2. **VideoPlayer/Media 컴포넌트** - - webOS Media 컴포넌트의 타이머 정리 로직 확인 - - TReactPlayer의 cleanup 로직 검토 - -3. **테스트** - - 장시간 비디오 재생 후 메모리 사용량 모니터링 - - 여러 번 반복 재생/정지 시 메모리 누수 확인 - - 전체화면 전환 시 리소스 누수 확인 - -4. **성능 모니터링** - - Chrome DevTools Memory tab에서 힙 스냅샷 비교 - - 컴포넌트 마운트/언마운트 반복 시 메모리 증감 확인 - ---- - -## 📝 주요 변경 요약 - -| 파일 | 변경 사항 | 라인 | 개선 효과 | -|------|---------|------|---------| -| ProductVideo.v2.jsx | autoPlay 타이머 정리 강화 | 566-597 | 메모리 누수 방지 | -| ProductVideo.v2.jsx | 전체화면 전환 타이머 정리 | 615-647 | 타이머 중복 방지 | -| ProductVideo.v2.jsx | Document 이벤트 리스너 정리 | 504-537 | 이벤트 리스너 누수 방지 | -| MediaPanel.jsx | onEnded 타이머 Ref 추적 | 52-53, 285-308 | 타이머 중복 호출 방지 | -| MediaPanel.jsx | 컴포넌트 언마운트 cleanup | 322-340 | 메모리 누수 방지 | -| MediaPanel.jsx | Modal 스타일 ResizeObserver | 114-171 | 옵저버 정리 준비 | -| MediaPlayer.v2.jsx | proportionLoaded 타이머 최적화 | 411-431 | 불필요한 타이머 제거 | -| MediaPlayer.v2.jsx | 전체 cleanup 강화 | 433-454 | 메모리 누수 방지 | - ---- - -## ✨ 결론 - -비디오 플레이어 관련 컴포넌트들의 타이머와 이벤트 리스너 정리를 체계적으로 개선했습니다. -이를 통해 장시간 비디오 재생 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다. - -**작업 상태**: ✅ 완료 diff --git a/com.twin.app.shoptime/[251116]_video_state_management_design.md b/com.twin.app.shoptime/[251116]_video_state_management_design.md deleted file mode 100644 index c96fd3f2..00000000 --- a/com.twin.app.shoptime/[251116]_video_state_management_design.md +++ /dev/null @@ -1,220 +0,0 @@ -# [251116] 새로운 비디오 상태 관리 시스템 구현 - -## 개요 - -기존의 videoPlayReducer는 유지하되, PlayerPanel과 VideoPlayer.js를 위한 새로운 비디오 상태 관리 시스템을 playerReducer에 구현한다. 재생 상태와 화면 상태를 분리하여 더 정밀한 비디오 상태 제어를 가능하게 한다. - -## 설계 원칙 - -1. **기존 videoPlayReducer 유지**: 다른 컴포넌트에서 사용 중일 수 있으므로 그대로 둔다 -2. **playerReducer에 새로운 상태 시스템 구현**: PlayerPanel과 VideoPlayer.js 전용 -3. **이중 상태 관리**: 재생 상태(Playback Status) + 화면 상태(Display Status) -4. **기존 패턴 따르기**: FP 스타일의 curry, get, set 활용 - -## 새로운 상태 구조 - -### 상수 정의 (playerActions.js) - -```javascript -// 재생 상태 -export const PLAYBACK_STATUS = { - LOADING: 'loading', // 비디오 로딩 중 - LOAD_SUCCESS: 'load_success', // 비디오 로딩 성공 - LOAD_ERROR: 'load_error', // 비디오 로딩 오류 - PLAYING: 'playing', // 비디오 재생 중 - NOT_PLAYING: 'not_playing', // 비디오 재생 아님 (정지/일시정지) - BUFFERING: 'buffering' // 버퍼링 중 -}; - -// 화면 상태 -export const DISPLAY_STATUS = { - HIDDEN: 'hidden', // 화면에 안보임 - VISIBLE: 'visible', // 화면에 보임 - MINIMIZED: 'minimized', // 최소화됨 - FULLSCREEN: 'fullscreen' // 전체화면 -}; -``` - -### 초기 상태 (playerReducer) - -```javascript -// 기존 playerReducer 상태에 추가 -const initialState = { - // ... 기존 상태들 - - playerVideoState: { - // 현재 상태 - playback: PLAYBACK_STATUS.NOT_PLAYING, - display: DISPLAY_STATUS.HIDDEN, - videoId: null, - progress: 0, - error: null, - timestamp: null - } -}; -``` - -## 액션 타입 및 함수 - -### 액션 타입 - -```javascript -export const PLAYER_VIDEO_ACTIONS = { - // 재생 상태 액션 - SET_PLAYBACK_LOADING: 'SET_PLAYBACK_LOADING', - SET_PLAYBACK_SUCCESS: 'SET_PLAYBACK_SUCCESS', - SET_PLAYBACK_ERROR: 'SET_PLAYBACK_ERROR', - SET_PLAYBACK_PLAYING: 'SET_PLAYBACK_PLAYING', - SET_PLAYBACK_NOT_PLAYING: 'SET_PLAYBACK_NOT_PLAYING', - SET_PLAYBACK_BUFFERING: 'SET_PLAYBACK_BUFFERING', - - // 화면 상태 액션 - SET_DISPLAY_HIDDEN: 'SET_DISPLAY_HIDDEN', - SET_DISPLAY_VISIBLE: 'SET_DISPLAY_VISIBLE', - SET_DISPLAY_MINIMIZED: 'SET_DISPLAY_MINIMIZED', - SET_DISPLAY_FULLSCREEN: 'SET_DISPLAY_FULLSCREEN', - - // 복합 액션 - SET_VIDEO_LOADING: 'SET_VIDEO_LOADING', - SET_VIDEO_PLAYING: 'SET_VIDEO_PLAYING', - SET_VIDEO_STOPPED: 'SET_VIDEO_STOPPED', - SET_VIDEO_MINIMIZED_PLAYING: 'SET_VIDEO_MINIMIZED_PLAYING', -}; -``` - -### 액션 함수 - -```javascript -// 기본 액션 함수들 (FP 스타일) -export const setPlaybackLoading = curry((videoId, displayMode = 'visible') => ({ - type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_LOADING, - payload: { - playback: PLAYBACK_STATUS.LOADING, - display: displayMode, - videoId, - progress: 0, - error: null, - timestamp: Date.now() - } -})); - -export const setPlaybackPlaying = curry((videoId, displayMode = 'fullscreen') => ({ - type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_PLAYING, - payload: { - playback: PLAYBACK_STATUS.PLAYING, - display: displayMode, - videoId, - progress: 100, - error: null, - timestamp: Date.now() - } -})); - -export const setPlaybackError = curry((videoId, error) => ({ - type: PLAYER_VIDEO_ACTIONS.SET_PLAYBACK_ERROR, - payload: { - playback: PLAYBACK_STATUS.LOAD_ERROR, - display: DISPLAY_STATUS.VISIBLE, - videoId, - error, - progress: 0, - timestamp: Date.now() - } -})); - -export const setVideoStopped = () => ({ - type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_STOPPED, - payload: { - playback: PLAYBACK_STATUS.NOT_PLAYING, - display: DISPLAY_STATUS.HIDDEN, - videoId: null, - error: null, - progress: 0, - timestamp: Date.now() - } -})); -``` - -## 상태 사용 예시 - -### PlayerPanel.jsx - -```javascript -import { - setPlaybackLoading, - setPlaybackPlaying, - setPlaybackError, - setVideoStopped -} from '../actions/playerActions'; - -// 비디오 로딩 시작 -const handleVideoLoadStart = (videoId) => { - dispatch(setPlaybackLoading(videoId, 'fullscreen')); -}; - -// 비디오 재생 시작 -const handleVideoPlay = (videoId) => { - dispatch(setPlaybackPlaying(videoId, 'fullscreen')); -}; - -// 비디오 에러 발생 -const handleVideoError = (videoId, error) => { - dispatch(setPlaybackError(videoId, error)); -}; - -// 상태 확인 -const videoState = useSelector(state => state.player.playerVideoState); -const isLoading = videoState.playback === PLAYBACK_STATUS.LOADING; -const isPlaying = videoState.playback === PLAYBACK_STATUS.PLAYING; -const hasError = videoState.playback === PLAYBACK_STATUS.LOAD_ERROR; -const isFullscreen = videoState.display === DISPLAY_STATUS.FULLSCREEN; -``` - -### VideoPlayer.js - -```javascript -// 현재 상태에 따른 UI 렌더링 -const renderVideoState = () => { - const { playback, display, error, progress } = videoState; - - if (playback === PLAYBACK_STATUS.LOADING) { - return ; - } - - if (playback === PLAYBACK_STATUS.LOAD_ERROR) { - return ; - } - - if (playback === PLAYBACK_STATUS.BUFFERING) { - return ; - } - - if (playback === PLAYBACK_STATUS.PLAYING && display === DISPLAY_STATUS.FULLSCREEN) { - return ; - } - - return null; -}; -``` - -## 구현 순서 - -1. [ ] playerActions.js에 상수 및 액션 함수들 추가 -2. [ ] playerReducer.js에 초기 상태 및 핸들러들 추가 -3. [ ] PlayerPanel.jsx에서 새로운 상태 시스템으로 전환 -4. [ ] VideoPlayer.js에서 새로운 상태 시스템으로 전환 -5. [ ] 테스트 및 검증 - -## 장점 - -1. **정밀한 상태 제어**: 재생 상태와 화면 상태를 별도로 관리 -2. **명확한 상태 의미**: 각 상태가 명확한 의미를 가짐 -3. **확장성**: 새로운 상태 추가가 용이 -4. **유지보수성**: 기존 코드 영향 최소화 -5. **재사용성**: 다른 컴포넌트에서도 활용 가능 - -## 주의사항 - -- 기존 videoPlayReducer와 충돌하지 않도록 주의 -- PlayerPanel과 VideoPlayer.js에만 집중하여 구현 -- 기존 비디오 재생 로직과 호환성 유지 \ No newline at end of file diff --git a/com.twin.app.shoptime/src/hooks/useDetailFocus/index.js b/com.twin.app.shoptime/src/hooks/useDetailFocus/index.js new file mode 100644 index 00000000..aba091c4 --- /dev/null +++ b/com.twin.app.shoptime/src/hooks/useDetailFocus/index.js @@ -0,0 +1 @@ +export { default } from './useDetailFocus'; diff --git a/com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js b/com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js new file mode 100644 index 00000000..094ed5d6 --- /dev/null +++ b/com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import Spotlight from '@enact/spotlight'; + +/** + * useDetailFocus - 포커스 이동 보정용 Hook + * + * ProductAllSection의 복잡한 조건부 렌더링으로 인한 포커스 손실을 방지하기 위해 + * arrow key에 따라 다음 포커스 항목을 queue 형태로 관리하고, + * 타이머로 포커스 이동을 수행합니다. + * + * useEffect의 의존성배열에 따라 타이머가 관리되고, + * 컴포넌트 unmount 시 자동으로 cleanup됩니다. + * + * @param {number} delayMs - 포커스 이동 지연 시간 (기본값: 250ms) + * @returns {Object} { enqueueFocus } + * + * @example + * const { enqueueFocus } = useDetailFocus(250); + * + * const handleArrowDown = (e) => { + * e.stopPropagation(); + * enqueueFocus('next-button-id'); + * }; + */ +export default function useDetailFocus(delayMs = 500) { + const focusQueueRef = useRef([]); + const [queueTick, setQueueTick] = useState(0); + const timerRef = useRef(null); + + /** + * 포커스 ID를 queue에 추가 (상태만 업데이트, 타이머는 useEffect에서 관리) + * @param {string} focusId - 포커스할 요소의 ID + */ + const enqueueFocus = useCallback( + (focusId) => { + if (!focusId) { + console.warn('[FocusDetail] ⚠️ focusId가 제공되지 않았습니다'); + return; + } + + // 큐에 추가하고 tick을 올려 useEffect를 트리거 + focusQueueRef.current.push(focusId); + console.log(`[FocusDetail] 📋 Queue에 ID 추가: ${focusId} (${delayMs}ms 후 포커스)`); + setQueueTick((tick) => tick + 1); + }, + [delayMs] + ); + + /** + * focusQueue 상태에 따라 타이머 관리 + * focusQueue가 설정되면 타이머 시작 + * 컴포넌트 unmount 시 useEffect cleanup에서 자동으로 타이머 정리 + */ + useEffect(() => { + // queue에 아무것도 없으면 종료 + if (!focusQueueRef.current.length) { + console.log(`[FocusDetail] 📭 Queue 비어있음 (포커스 보정 불필요)`); + return undefined; + } + + // 기존 타이머가 있으면 취소 + if (timerRef.current) { + console.log( + `[FocusDetail] ⏹️ 기존 타이머 취소 - 대기 중인 Queue: ${focusQueueRef.current.join(',')}` + ); + clearTimeout(timerRef.current); + } + + // 새로운 타이머 설정 + const targetId = focusQueueRef.current[focusQueueRef.current.length - 1]; // 마지막 ID + console.log(`[FocusDetail] ⏱️ 타이머 시작 - ${delayMs}ms 후 포커스 이동: ${targetId}`); + timerRef.current = setTimeout(() => { + console.log(`[FocusDetail] ⏱️ 타이머 만료 - 포커스 이동 시도: ${targetId}`); + + // 현재 포커스된 요소 확인 + const currentElement = Spotlight.getCurrent(); + const currentId = currentElement?.dataset?.spotlightId || currentElement?.id || 'unknown'; + console.log(`[FocusDetail] 📌 현재 포커스 상태: ${currentId}, 타깃: ${targetId}`); + + try { + const success = Spotlight.focus(targetId); + + if (!success) { + const afterElement = Spotlight.getCurrent(); + const afterId = afterElement?.dataset?.spotlightId || afterElement?.id || 'unknown'; + console.warn(`[FocusDetail] ❌ 포커스 이동 실패: ${targetId} (현재: ${afterId})`); + console.warn( + `[FocusDetail] 📋 요소 존재 확인: ${document.querySelector(`[data-spotlight-id="${targetId}"]`) ? '✅ 존재' : '❌ 없음'}` + ); + } else { + console.log(`[FocusDetail] ✅ 포커스 이동 성공: ${targetId}`); + } + } catch (error) { + console.error('[FocusDetail] 💥 포커스 이동 중 에러:', error); + } finally { + // 타이머 정리 + console.log(`[FocusDetail] 🧹 타이머 정리 - 처리 완료: ${targetId}`); + timerRef.current = null; + // Queue 초기화 + focusQueueRef.current = []; + } + }, delayMs); + + // cleanup: 의존성배열 변경 또는 컴포넌트 unmount 시 타이머 정리 + return () => { + if (timerRef.current) { + console.log( + `[FocusDetail] 🧹 useEffect cleanup - 대기 중인 타이머 취소 (Queue: ${focusQueueRef.current.join(',')})` + ); + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [queueTick, delayMs]); + + return { + enqueueFocus, + focusQueue: focusQueueRef.current, // 디버깅용 + }; +} diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index 602594bd..4905afb9 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -34,6 +34,7 @@ import TPopUp from '../../../components/TPopUp/TPopUp.jsx'; import TVirtualGridList from '../../../components/TVirtualGridList/TVirtualGridList.jsx'; import useReviews from '../../../hooks/useReviews/useReviews'; import useScrollTo from '../../../hooks/useScrollTo'; +import useDetailFocus from '../../../hooks/useDetailFocus'; import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig'; import { panel_names } from '../../../utils/Config'; import * as Config from '../../../utils/Config.js'; @@ -81,7 +82,10 @@ const Container = SpotlightContainerDecorator( { enterTo: 'last-focused', preserveld: true, - leaveFor: { right: 'content-scroller-container' }, + leaveFor: { + right: 'content-scroller-container', + up: 'spotlight-product-info-section-container', + }, spotlightDirection: 'vertical', }, 'div' @@ -156,6 +160,9 @@ export default function ProductAllSection({ }) { const dispatch = useDispatch(); + // 포커스 이동 보정 Hook (0.25초 타이머) + const { enqueueFocus } = useDetailFocus(500); + // Redux 상태 const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion); const groupInfos = useSelector((state) => state.product.groupInfo); @@ -833,52 +840,81 @@ export default function ProductAllSection({ [] ); - // SHOP BY MOBILE 버튼에서 arrow up 시: BUY NOW > COUPON > BACK 순으로 이동, 포커스 이탈 보정 + // SHOP BY MOBILE 버튼에서 arrow up 시 + // focusUpMap을 사용해서 위쪽 버튼으로 이동 + // focusUpMap이 없으면 (첫 행) BackBtn으로 이동 const handleSpotlightUpToBackButton = useCallback( (e) => { e.stopPropagation(); + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 감지`); - const tryFocusUp = () => { - if (shopByMobileUpTarget && Spotlight.focus(shopByMobileUpTarget)) { - return shopByMobileUpTarget; + // focusUpMap에서 ShopByMobile의 위쪽 버튼을 찾음 + const targetId = focusUpMap[shopByMobileId]; + + if (targetId) { + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 타깃: ${targetId}`); + if (targetId === 'detail-buy-now-button') { + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: BuyNow 버튼 있음`); + } else if (targetId === 'detail-add-to-cart-button') { + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: AddToCart 버튼 있음`); + } else if (targetId === 'detail-coupon-button') { + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: Coupon 버튼 있음`); } - return 'spotlightId_backBtn'; - }; - - const targetId = tryFocusUp(); - - // 포커스가 바로 빠지는 케이스 보정 - setTimeout(() => { - const current = Spotlight.getCurrent(); - const currentId = current?.dataset?.spotlightId; - if (!current || currentId !== targetId) { - tryFocusUp(); + // 즉시 포커스 시도 + if (!Spotlight.focus(targetId)) { + enqueueFocus(targetId); } - }, 0); + } else { + // focusUpMap에 없으면 (첫 행이거나 위쪽이 없음) BackBtn으로 + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: 위쪽 버튼 없음, BackBtn으로 이동`); + if (!Spotlight.focus('spotlightId_backBtn')) { + enqueueFocus('spotlightId_backBtn'); + } + } }, - [shopByMobileUpTarget] + [focusUpMap, shopByMobileId, enqueueFocus] ); - // BUY NOW, ADD TO CART 버튼에서 arrow up 시: 항상 헤더 뒤로가기 버튼으로 + // BUY NOW, ADD TO CART 버튼에서 arrow up 시 + // Coupon 버튼이 있으면 Coupon으로, 없으면 Back으로 이동 + // 항상 Hook을 통해 포커스를 이동하도록 변경 const handleSpotlightUpFromBuyButtons = useCallback( (e) => { e.stopPropagation(); + + let targetId; if (promotions && promotions.length > 0) { - Spotlight.focus('detail-coupon-button'); + // 쿠폰이 여러 개면 첫 번째 쿠폰 버튼으로 포커스 + targetId = `detail-coupon-button-0`; + console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 있음)`); + console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: ${targetId}`); } else { - // 쿠폰 버튼이 없을 때 뒤로가기 버튼으로 포커스 이동 (특정 Chrome 버전에서의 포커스 문제 해결) - setTimeout(() => { - Spotlight.focus('spotlightId_backBtn'); - }, 0); + targetId = 'spotlightId_backBtn'; + console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 없음)`); + console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: Back Btn`); + } + + // 즉시 포커스 시도, 실패 시 보정 큐 + if (!Spotlight.focus(targetId)) { + enqueueFocus(targetId); } }, - [promotions] + [promotions, enqueueFocus] ); - const handleSpotlightUpFromCouponButtons = useCallback((e) => { - e.stopPropagation(); - Spotlight.focus('spotlightId_backBtn'); - }, []); + // Coupon 버튼에서 arrow up 시: Container의 leaveFor 설정으로 처리 + // stopPropagation을 하지 않아서 Container가 up 방향으로 포커스를 이동하도록 함 + const handleSpotlightUpFromCouponButtons = useCallback( + (e) => { + e.preventDefault(); + console.log(`🎫 [FocusDetail] Coupon - Arrow UP 감지`); + console.log(`🎫 [FocusDetail] Coupon - Arrow UP 처리: Container의 leaveFor(up) 설정 사용`); + if (!Spotlight.focus('spotlightId_backBtn')) { + enqueueFocus('spotlightId_backBtn'); + } + }, + [enqueueFocus] + ); // SHOP BY MOBILE ↑ 타깃 계산 const shopByMobileUpTarget = useMemo(() => { @@ -893,11 +929,13 @@ export default function ProductAllSection({ ); // 버튼 스택(위→아래) 구성: 실제 렌더링 순서에 맞춰 행(row) 단위로 설정 - const { focusDownMap, focusOrder, focusRows } = useMemo(() => { + const { focusDownMap, focusUpMap, focusOrder, focusRows } = useMemo(() => { const rows = []; if (promotions && promotions.length > 0) { - rows.push(['detail-coupon-button']); + // 쿠폰이 여러 개일 수 있으므로 각각 고유 ID로 추가 + const couponButtonIds = promotions.map((_, idx) => `detail-coupon-button-${idx}`); + rows.push(couponButtonIds); } if (isBillingProductVisible) { @@ -925,10 +963,22 @@ export default function ProductAllSection({ return acc; }, {}); + // 위 방향: 각 행의 모든 요소가 이전 행의 마지막 요소를 바라보도록 매핑 + const upMap = rows.reduce((acc, row, idx) => { + if (idx > 0) { + // 이전 행이 있으면 그 행의 마지막 요소로 매핑 + const prevRowLast = rows[idx - 1][rows[idx - 1].length - 1]; + row.forEach((id) => { + acc[id] = prevRowLast; + }); + } + return acc; + }, {}); + // order는 행을 평탄화한 순서 const order = rows.flat(); - return { focusDownMap: downMap, focusOrder: order, focusRows: rows }; + return { focusDownMap: downMap, focusUpMap: upMap, focusOrder: order, focusRows: rows }; }, [isBillingProductVisible, panelInfo, isReviewDataComplete, promotions, shopByMobileId]); // 공통 ↓ 이동 핸들러 @@ -944,11 +994,12 @@ export default function ProductAllSection({ if (!moved) { const fallback = focusOrder.find((id) => id !== currentId); if (fallback) { - Spotlight.focus(fallback); + // Hook을 통한 포커스 이동 보정 (0.5초 타이머로 재시도) + enqueueFocus(fallback); } } }, - [focusDownMap, focusOrder] + [focusDownMap, focusOrder, enqueueFocus] ); const handleSpotlightDown = useCallback((e) => { @@ -956,24 +1007,107 @@ export default function ProductAllSection({ }, []); // COUPON에서 아래로 이동 시 - const handleSpotlightDownFromCoupon = useMemo( - () => buildSpotlightDownHandler('detail-coupon-button'), - [buildSpotlightDownHandler] + // 항상 Hook을 통해 포커스를 이동하도록 변경 + const handleSpotlightDownFromCoupon = useCallback( + (e, currentCouponId) => { + e.stopPropagation(); + e.preventDefault(); + console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 감지 (ID: ${currentCouponId})`); + + const nextId = focusDownMap[currentCouponId]; + console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 다음 타깃: ${nextId}`); + + if (nextId) { + // 항상 Hook을 통해 포커스를 이동 + enqueueFocus(nextId); + } else { + // 다음 행이 없으면 focusOrder에서 현재 쿠폰 버튼이 아닌 다른 버튼을 찾음 + const fallback = focusOrder.find((id) => !id.startsWith('detail-coupon-button')); + console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`); + if (fallback) { + enqueueFocus(fallback); + } + } + }, + [focusDownMap, focusOrder, enqueueFocus] ); // SHOP BY MOBILE에서 아래로 이동 시 - const handleSpotlightDownFromShopByMobile = useMemo( - () => buildSpotlightDownHandler(shopByMobileId), - [buildSpotlightDownHandler, shopByMobileId] + // 항상 Hook을 통해 포커스를 이동하도록 변경 + const handleSpotlightDownFromShopByMobile = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 감지`); + + const nextId = focusDownMap[shopByMobileId]; + console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 다음 타깃: ${nextId}`); + + if (nextId) { + // 항상 Hook을 통해 포커스를 이동 + enqueueFocus(nextId); + } else { + const fallback = focusOrder.find((id) => id !== shopByMobileId); + console.log( + `🛵 [FocusDetail] ShopByMobile - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}` + ); + if (fallback) { + enqueueFocus(fallback); + } + } + }, + [shopByMobileId, focusDownMap, focusOrder, enqueueFocus] ); - // BUY NOW / ADD TO CART에서 아래로 이동 시 - const handleSpotlightDownFromBuyNow = useMemo( - () => buildSpotlightDownHandler('detail-buy-now-button'), - [buildSpotlightDownHandler] + // BUY NOW에서 아래로 이동 시 + // 항상 Hook을 통해 포커스를 이동하도록 변경 + const handleSpotlightDownFromBuyNow = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 감지`); + + const nextId = focusDownMap['detail-buy-now-button']; + console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 다음 타깃: ${nextId}`); + + if (nextId) { + // 항상 Hook을 통해 포커스를 이동 + enqueueFocus(nextId); + } else { + const fallback = focusOrder.find((id) => id !== 'detail-buy-now-button'); + console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`); + if (fallback) { + enqueueFocus(fallback); + } + } + }, + [focusDownMap, focusOrder, enqueueFocus] ); - const handleSpotlightDownFromAddToCart = useMemo( - () => buildSpotlightDownHandler('detail-add-to-cart-button'), - [buildSpotlightDownHandler] + + // ADD TO CART에서 아래로 이동 시 + // 항상 Hook을 통해 포커스를 이동하도록 변경 + const handleSpotlightDownFromAddToCart = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 감지`); + + const nextId = focusDownMap['detail-add-to-cart-button']; + console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 다음 타깃: ${nextId}`); + + if (nextId) { + // 항상 Hook을 통해 포커스를 이동 + enqueueFocus(nextId); + } else { + const fallback = focusOrder.find((id) => id !== 'detail-add-to-cart-button'); + console.log( + `💳 [FocusDetail] AddToCart - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}` + ); + if (fallback) { + enqueueFocus(fallback); + } + } + }, + [focusDownMap, focusOrder, enqueueFocus] ); const onFavoriteFlagChanged = useCallback( @@ -1276,6 +1410,8 @@ export default function ProductAllSection({ scrollExpandTimerRef.current = null; } + // 🔽 useDetailFocus 포커스 타이머는 Hook의 useEffect cleanup에서 자동으로 정리됨 + console.log('[ProductAllSection] cleanup 완료 on unmount'); }; }, []); @@ -1337,6 +1473,7 @@ export default function ProductAllSection({ {userNumber && promotions.map((promotion, idx) => { + const couponButtonId = `detail-coupon-button-${idx}`; return (
@@ -1346,14 +1483,14 @@ export default function ProductAllSection({
{ handleCouponClick(idx, promotion); }} onSpotlightUp={handleSpotlightUpFromCouponButtons} - onSpotlightDown={handleSpotlightDownFromCoupon} - data-spotlight-next-down={focusDownMap['detail-coupon-button']} + onSpotlightDown={(e) => handleSpotlightDownFromCoupon(e, couponButtonId)} + data-spotlight-next-down={focusDownMap[couponButtonId]} size="detail_very_small" >
COUPON