[251122] fix: DetailPaneel->ProductAllSection Focus-4

🕐 커밋 시간: 2025. 11. 22. 16:46:50

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +184줄
  • 삭제: -1320줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/hooks/useDetailFocus/index.js
  + com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx

🗑️ 삭제된 파일:
  - com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md
  - com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md
  - com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md
  - com.twin.app.shoptime/[251116]_video_state_management_design.md

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
     Deleted: tryFocusUp()
  📄 com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md (md파일):
     Deleted: Before(), After()
  📄 com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md (md파일):
     Deleted: useCallback(), showControls(), areControlsVisible(), toggleControls(), useLayoutEffect(), useEffect(), clearTimeout(), dispatch(), forEach(), getVideoNode(), addEventListener()
  📄 com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md (md파일):
     Deleted: clearTimeout(), clearAllVideoTimers(), pause(), useEffect(), setTimeout(), useCallback(), resume(), dispatch(), stopPropagation(), preventDefault(), disconnect(), updateProportionLoaded(), setInterval(), clearInterval(), useRef()
  📄 com.twin.app.shoptime/[251116]_video_state_management_design.md (md파일):
     Deleted: curry(), dispatch(), useSelector()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • 개발 문서 및 가이드 개선

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-11-22 16:46:51 +09:00
parent ef7615a538
commit c7ac0d7460
7 changed files with 308 additions and 1319 deletions

View File

@@ -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 플래그로 제어할 수 있도록 구현완료.
이를 통해 개발/테스트 중에는 디버깅 정보를 쉽게 확인할 수 있으며,
프로덕션 환경에서는 로그 오버헤드를 제거하여 성능을 향상시킬 수 있습니다.
**작업 상태**: ✅ 완료

View File

@@ -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 미실행)

View File

@@ -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 | 메모리 누수 방지 |
---
## ✨ 결론
비디오 플레이어 관련 컴포넌트들의 타이머와 이벤트 리스너 정리를 체계적으로 개선했습니다.
이를 통해 장시간 비디오 재생 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다.
**작업 상태**: ✅ 완료

View File

@@ -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 <LoadingSpinner progress={progress} />;
}
if (playback === PLAYBACK_STATUS.LOAD_ERROR) {
return <ErrorMessage error={error} onRetry={handleRetry} />;
}
if (playback === PLAYBACK_STATUS.BUFFERING) {
return <BufferingIndicator />;
}
if (playback === PLAYBACK_STATUS.PLAYING && display === DISPLAY_STATUS.FULLSCREEN) {
return <VideoPlayer videoId={videoState.videoId} />;
}
return null;
};
```
## 구현 순서
1. [ ] playerActions.js에 상수 및 액션 함수들 추가
2. [ ] playerReducer.js에 초기 상태 및 핸들러들 추가
3. [ ] PlayerPanel.jsx에서 새로운 상태 시스템으로 전환
4. [ ] VideoPlayer.js에서 새로운 상태 시스템으로 전환
5. [ ] 테스트 및 검증
## 장점
1. **정밀한 상태 제어**: 재생 상태와 화면 상태를 별도로 관리
2. **명확한 상태 의미**: 각 상태가 명확한 의미를 가짐
3. **확장성**: 새로운 상태 추가가 용이
4. **유지보수성**: 기존 코드 영향 최소화
5. **재사용성**: 다른 컴포넌트에서도 활용 가능
## 주의사항
- 기존 videoPlayReducer와 충돌하지 않도록 주의
- PlayerPanel과 VideoPlayer.js에만 집중하여 구현
- 기존 비디오 재생 로직과 호환성 유지

View File

@@ -0,0 +1 @@
export { default } from './useDetailFocus';

View File

@@ -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, // 디버깅용
};
}

View File

@@ -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 (
<div className={css.couponContainer} key={idx}>
<div className={css.couponTitleText}>
@@ -1346,14 +1483,14 @@ export default function ProductAllSection({
</div>
</div>
<TButton
spotlightId="detail-coupon-button"
spotlightId={couponButtonId}
className={css.couponButton}
onClick={() => {
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"
>
<div className={css.couponText}>COUPON</div>