YouTube Shorts 자동 재생 트러블슈팅: Android WebView 소리 안 남, 백그라운드/포그라운드 감지로 재생 멈춤 해결기
YouTube Shorts 자동 재생 트러블슈팅: Android WebView 소리 안 남, 백그라운드/포그라운드 감지로 재생 멈춤 해결기
🧩 요구사항
사내 유튜브 채널에 올라온 Shorts(쇼츠) 콘텐츠를 홈페이지에 자동으로 노출해달라는 요청을 받았다.
🎯 목적
- 마케팅팀에서 쇼츠를 별도 등록 없이 자동으로 노출되게 하기
- 유튜브 채널 운영과 홈페이지 콘텐츠 노출의 일원화
🔧 시스템 구조
✅ 백엔드 처리
- YouTube Data API는 호출 횟수 제한이 있기 때문에, 프론트에서 직접 호출하지 않음.
- 백엔드에서 주기적으로 Shorts 리스트(영상 ID)를 프론트로 전달하는 API 제공.
🙋 프론트엔드 요구사항
- 페이지 진입 시 리스트의 첫 번째 영상 자동 재생
- 영상이 끝나면 다음 영상으로 자동 전환 및 재생
- 영상은 Swiper로 슬라이딩 가능
- 현재 재생 중인 영상은 중앙 정렬
- 기본은 음소거, 영상 클릭 시 소리 ON/OFF 토글
🐞 문제 상황 및 트러블슈팅
🧨 문제 1. 🎞️ Iframe은 드래그할 수 없다.
- 유튜브 Iframe은 기본적으로 **드래그(슬라이드)**가 되지 않는다.
- 해결 방법: Iframe 위에 오버레이 div를 얹고, 슬라이드와 클릭 이벤트는 오버레이에서 처리.
🎨 오버레이 처리 UI 설명 오버레이는 유튜브 썸네일 이미지를 배경으로 사용합니다. 영상이 아직 재생되지 않았다면 이 오버레이가 보이며, 사용자가 클릭하면 실제 Iframe이 재생됩니다. 오버레이는 슬라이드 가능한 요소 위에 위치하여, 유튜브 Iframe의 슬라이드 불가능 문제를 우회하는 역할을 합니다. 재생이 시작되면 오버레이의 투명도를 0으로 낮춰 자연스럽게 Iframe이 드러나도록 처리합니다. 즉, 유저는 영상 위를 클릭하거나 슬라이드할 수 있지만, 실질적인 상호작용은 오버레이에서 발생합니다.
<div className="l-grid l-grid--max bg-black"> <div className="l-grid__row" data-ui-type="Main_YouTube_Shorts"> <Swiper spaceBetween={10} slidesPerView={2.5} modules={[FreeMode]} freeMode={true} slidesOffsetAfter={16} slidesOffsetBefore={16} breakpoints={{ 1024: { slidesPerView: 4.5, spaceBetween: 12, slidesOffsetAfter: 0, slidesOffsetBefore: 0 }, }} onSwiper={(swiper)=> (swiperRef.current= swiper)} > {data.map((item: any, index: number) => ( <SwiperSlide key={item.videoId}> <YouTubePlayerWithOverlay videoId={item.videoId} title={item.title} autoPlay={index= 0} onTogglePlay={handleTogglePlay} onToggleMute={handleToggleMute} playersRef={playersRef} isPlayed={!!playedOnce[item.videoId]} isCurrent={currentPlaying= item.videoId} isYTReady={isYTReady} onEnded={()=> handleEnded(item.videoId)} /> </SwiperSlide> ))} </Swiper> </div> </div>
🧨 문제 2. Android WebView에서 유튜브 소리가 안 남
🔍 현상
player.unMute()호출해도 Android에서는 소리 재생되지 않음.- 하지만 iOS나 PC 환경에서는 정상 작동.
✅ 해결
웹 서치 결과 관련 설정을 찾아 해결 가능성을 확인했고, Android 네이티브 개발자에게 WebView 설정 변경을 요청해 해결 완료
// Android WebView 설정에서 사용자 제스처 없이 미디어 재생 허용 webView.getSettings().setMediaPlaybackRequiresUserGesture(false)
🔧 Android 설정 문제는 프론트에서 직접 수정할 수 없기 때문에, setMediaPlaybackRequiresUserGesture(false) 적용을 Android 네이티브 개발자에게 요청하여 해결했습니다.
🧨 문제 3. 영상 재생 중 백그라운드 → 포그라운드 복귀 시 재생 멈춤
"앱을 백그라운드에 두었다가 다시 돌아오면 영상이 멈춘 상태로 남는다."
🔍 현상
- 앱을 백그라운드에 뒀다가 돌아오면 영상은 멈춘 상태로 남음.
- 유튜브 iframe이 자동으로 pause되지만 resume은 자동으로 되지 않음.
✅ 해결
- visibilitychange 이벤트를 감지하여 영상 상태를 수동으로 제어.
- 백그라운드 진입 시 현재 재생 시간 저장 → 포그라운드 진입 시 저장된 시간부터 재생.
useEffect(() => { const handleVisibilityChange = () => { const id = currentPlayingRef.current; const player = playersRef.current[id]; if (!player) return; if (document.visibilityState === 'hidden') { const time = player.getCurrentTime?.(); if (Number.isFinite(time)) { lastPlaybackTimeRef.current[id] = time; } player.pauseVideo(); } if (document.visibilityState === 'visible') { const savedTime = lastPlaybackTimeRef.current[id] || 0; player.seekTo(savedTime, true); player.playVideo(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []);
✅ 백그라운드 진입 시 getCurrentTime() 으로 시점 저장, 복귀 시 seekTo() → playVideo() 호출하여 자연스럽게 이어서 재생.
✅ 마무리
이번 경험은 단순한 기능 구현을 넘어서, 실제 하이브리드 앱 환경에서 발생할 수 있는 플랫폼 별 이슈를 디버깅하고 해결한 과정이었다.
-
Android WebView 특성 이해
-
유튜브 iframe Player의 상태 관리
-
visibilitychange 활용 등
웹과 앱이 맞물린 환경에서는 기능 구현 이후의 사용성 검증이 매우 중요함을 다시금 느낀 사례였다.
📦 실제 구현 코드
전체 코드는 Swiper 기반으로 자동재생 + 음소거 토글 + 포커싱 기능을 구현하며, 다음과 같은 컴포넌트로 구성됨:
-
HomeYouTubeShorts: 유튜브 리스트, 슬라이드 처리, 재생 제어 담당
-
YouTubePlayerWithOverlay: iframe 생성 및 클릭 오버레이 처리 ()
👉 전체 코드는 아래 코드 블럭에서 확인
// 구성 컴포넌트 요약 - HomeYouTubeShorts: - Shorts 목록 렌더링 - 자동 재생 제어 - Swiper 중앙 포커싱 - YouTubePlayerWithOverlay: - 유튜브 Iframe 생성 - 오버레이 클릭 UI 처리 export default function HomeYouTubeShorts() { // 📦 유튜브 쇼츠 리스트 데이터 fetch const { data, isPending, isError } = useQuery({ queryKey: ['shorts'], queryFn: getSorts, }); // 현재 재생 중인 영상 ID const [currentPlaying, setCurrentPlaying] = useState<string | null>(null); // 한 번이라도 재생된 영상 기록 const [playedOnce, setPlayedOnce] = useState<Record<string, boolean>>({}); // 영상 음소거 상태 기록 const [isMutedMap, setIsMutedMap] = useState<Record<string, boolean>>({}); // 유튜브 Iframe API 로드 여부 const [isYTReady, setIsYTReady] = useState(false); // 각 영상의 player 인스턴스 저장 const playersRef = useRef<Record<string, any | null>>({}); const swiperRef = useRef<any>(null); const currentPlayingRef = useRef<string | null>(null); // 백그라운드 시 재생 시간 기록용 Ref const lastPlaybackTimeRef = useRef<Record<string, number>>({}); // ✅ 유튜브 Iframe API 로드 처리 useEffect(() => { if (window.YT && window.YT.Player) { setIsYTReady(true); } else { (window as any).onYouTubeIframeAPIReady = () => { setIsYTReady(true); }; const tag = document.createElement('script'); tag.src = 'https://www.youtube.com/iframe_api'; document.body.appendChild(tag); } }, []); // 현재 재생 중인 영상 ID를 Ref에도 동기화 useEffect(() => { currentPlayingRef.current = currentPlaying; }, [currentPlaying]); // 📱 백그라운드/포그라운드 감지 및 영상 일시정지/재생 처리 useEffect(() => { const getPlayer = () => { const id = currentPlayingRef.current ?? ''; return playersRef.current[id]; }; const handlePause = () => { const player = getPlayer(); if (!player) return; try { const currentTime = player.getCurrentTime(); const state = player.getPlayerState(); if (Number.isFinite(currentTime)) { lastPlaybackTimeRef.current[currentPlayingRef.current!] = currentTime; } if (state === 1) player.pauseVideo(); } catch (e) { console.warn('Error pausing video:', e); } }; const handleResume = () => { const player = getPlayer(); if (!player) return; try { const savedTime = lastPlaybackTimeRef.current[currentPlayingRef.current!] || 0; player.seekTo(savedTime, true); player.playVideo(); } catch (e) { console.warn('Error resuming video:', e); } }; const handleVisibilityChange = () => { if (!currentPlayingRef.current) return; if (document.visibilityState === 'hidden') handlePause(); if (document.visibilityState === 'visible') handleResume(); }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); // 🔁 슬라이드 중앙 정렬 함수 const centerSlideToIndex = (index: number) => { const swiper = swiperRef.current; if (!swiper || !swiper.slides || !swiper.slides.length) return; const slide = swiper.slides[index]; if (!slide) return; const slideWidth = slide.offsetWidth; const containerWidth = swiper.width; const offsetLeft = slide.offsetLeft; const centeredTranslate = -offsetLeft + (containerWidth - slideWidth) / 2; const maxTranslate = swiper.maxTranslate(); const minTranslate = swiper.minTranslate(); const clampedTranslate = Math.max(Math.min(centeredTranslate, minTranslate), maxTranslate); swiper.wrapperEl.style.transition = 'transform 0.3s ease-out'; swiper.setTranslate(clampedTranslate); swiper.updateActiveIndex(index); swiper.updateSlidesClasses(); setTimeout(() => { swiper.wrapperEl.style.transition = ''; }, 300); }; // ▶️ 영상 재생 요청 핸들러 const handleTogglePlay = (videoId: string) => { const player = playersRef.current[videoId]; if (!player || currentPlaying === videoId) return; // 기존 영상 정지 처리 if (currentPlaying && playersRef.current[currentPlaying]) { const prev = playersRef.current[currentPlaying]; prev?.stopVideo(); setPlayedOnce((prev) => ({ ...prev, [currentPlaying]: false })); setIsMutedMap((prev) => ({ ...prev, [currentPlaying]: true })); } // 새 영상 재생 및 음소거 player.mute(); player.playVideo(); setCurrentPlaying(videoId); setPlayedOnce((prev) => ({ ...prev, [videoId]: true })); setIsMutedMap((prev) => ({ ...prev, [videoId]: true })); // 재생 중인 영상 슬라이드를 중앙 정렬 const swiper = swiperRef.current; const index = swiper.slides.findIndex((slide: HTMLElement) => slide.querySelector(`#player-${videoId}`)); if (index !== -1) { centerSlideToIndex(index); } }; // 🔈 음소거 토글 핸들러 const handleToggleMute = (videoId: string) => { const player = playersRef.current[videoId]; if (!player) return; setIsMutedMap((prev) => { const nextMuted = !prev[videoId]; if (nextMuted) { player.mute(); } else { player.unMute(); } return { ...prev, [videoId]: nextMuted }; }); }; // ⏭️ 영상 종료 시 다음 영상 재생 처리 const handleEnded = (videoId: string) => { if (!data) return; const currentIndex = data.findIndex((item: any) => item.videoId === videoId); const nextItem = data[currentIndex + 1]; // 현재 영상 상태 초기화 setPlayedOnce((prev) => ({ ...prev, [videoId]: false })); setIsMutedMap((prev) => ({ ...prev, [videoId]: true })); if (nextItem) { handleTogglePlay(nextItem.videoId); } else { const player = playersRef.current[videoId]; player?.stopVideo(); setCurrentPlaying(null); } }; if (isPending) return <ShortsSkeleton />; if (isError || !data) return; return ( <div className="l-grid l-grid--max bg-black " style={{ marginTop: '0' }}> <div className="l-grid__row" data-ui-type="Main_YouTube_Shorts"> <Swiper modules={[FreeMode]} freeMode={true} onSwiper={(swiper)=> (swiperRef.current= swiper)} > {data.map((item: any, index: number) => ( <SwiperSlide key={item.videoId}> <YouTubePlayerWithOverlay videoId={item.videoId} title={item.title} autoPlay={index= 0} onTogglePlay={handleTogglePlay} onToggleMute={handleToggleMute} playersRef={playersRef} isPlayed={!!playedOnce[item.videoId]} isCurrent={currentPlaying= item.videoId} isYTReady={isYTReady} onEnded={()=> handleEnded(item.videoId)} /> </SwiperSlide> ))} </Swiper> </div> </div> ); } interface PlayerProps { videoId: string; // 유튜브 영상 ID title: string; // 영상 제목 autoPlay: boolean; // 자동 재생 여부 onTogglePlay: (id: string) => void; // 재생 토글 핸들러 onToggleMute: (id: string) => void; // 음소거 토글 핸들러 playersRef: React.MutableRefObject<Record<string, any | null>>; // 유튜브 플레이어 레퍼런스 isPlayed: boolean; // 재생 여부 isCurrent: boolean; // 현재 재생 중인지 여부 isYTReady: boolean; // 유튜브 API 로드 여부 onEnded: () => void; // 영상 종료 핸들러 } export default function YouTubePlayerWithOverlay({ videoId, title, autoPlay, onTogglePlay, onToggleMute, playersRef, isPlayed, isCurrent, isYTReady, onEnded, }: PlayerProps) { const [loaded, setLoaded] = useState(false); // 플레이어 로딩 여부 const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; // 썸네일 URL // 유튜브 웹으로 이동 (앱에서 사용 시 외부 브라우저로) const moveYoutube = (videoId: string) => { window.shoePrizeAppCmd.openExternalBrowser(`https://www.youtube.com/watch?v=${videoId}`); }; // ✅ 유튜브 플레이어 생성 (최초 1회) useEffect(() => { if (!loaded && isYTReady && !playersRef.current[videoId]) { const player = new window.YT.Player(`player-${videoId}`, { videoId, events: { onReady: () => setLoaded(true), // 플레이어 준비 완료 시 loaded true onStateChange: (event: any) => { if (event.data === window.YT.PlayerState.ENDED) { onEnded(); // 영상 종료 시 콜백 호출 } }, }, playerVars: { controls: 0, modestbranding: 1, rel: 0, fs: 0, mute: 1, playsinline: 1, enablejsapi: 1, origin: window.location.origin, }, }); playersRef.current[videoId] = player; // ref에 저장 } }, [loaded, isYTReady, videoId, playersRef]); // ✅ 자동 재생 처리 useEffect(() => { if (autoPlay && loaded) { onTogglePlay(videoId); } }, [loaded]); return ( <> <div className="c-shortplayer"> {/* 유튜브 플레이어 영역 */} <div id={`player-${videoId}`} className="c-shortplayer__iframe" /> {/* 썸네일 오버레이 (처음 재생 전까지 표시) */} <div className="c-shortplayer__overlay" onClick={()=> { if (isCurrent) { onToggleMute(videoId); // 현재 재생 중이면 음소거 토글 } else if (!isPlayed) { onTogglePlay(videoId); // 아직 재생되지 않은 경우 재생 } }} style={{ backgroundImage: `url(${thumbnailUrl})`, opacity: isPlayed ? 0 : 1, // 재생되면 썸네일 숨김 }} /> </div> {/* 영상 정보 영역 */} <dl className="c-shortslider__meta"> <div className="c-shortslider__title" onClick={()=> moveYoutube(videoId)}> <dt>영상 제목</dt> <dd>{title}</dd> </div> </dl> </> ); }