스크롤에 따라 화면에 보이는 비디오만 자동 재생하고, 화면을 벗어나면 일시 정지시키는 UX는 피드·기사·제품 리스트에서 필수입니다. React에서는 Intersection Observer를 이용하면 성능 부담 없이 이를 안정적으로 구현할 수 있습니다.
1. 핵심 아이디어와 브라우저 정책
Intersection Observer는 특정 DOM이 뷰포트에 얼마나 노출되는지 비동기적으로 알려줍니다. 이 값을 기준으로 비디오를 play/pause 하면 됩니다. 단, 자동 재생은 브라우저 정책상 소리가 꺼진 상태(mutated)와 인라인 재생(playsInline)이 중요합니다. iOS Safari 등 일부 환경에서는 muted + playsInline 없이는 자동 재생이 거부됩니다.
2. 단일 비디오 컴포넌트 구현
가장 단순한 형태로 하나의 비디오 요소를 관찰해 가시성이 임계치(threshold)를 넘으면 재생하고, 떨어지면 일시 정지합니다.
import React, { useEffect, useRef } from 'react';
function VideoAutoPlay({
src,
poster,
threshold = 0.6,
rootMargin = '0px',
...props
}) {
const videoRef = useRef(null);
useEffect(() => {
const node = videoRef.current;
if (!node || typeof window === 'undefined') return;
const onIntersect = (entries) => {
for (const entry of entries) {
const visible = entry.isIntersecting && entry.intersectionRatio >= threshold;
if (visible) {
node.play().catch(() => {
// 자동 재생이 거부된 경우 무음/인라인 설정을 확인하거나, UI로 안내합니다.
});
} else {
node.pause();
}
}
};
const observer = new IntersectionObserver(onIntersect, {
threshold: [threshold],
rootMargin,
});
observer.observe(node);
const onVisibility = () => {
if (document.hidden) node.pause(); // 탭이 숨겨지면 정지
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
};
}, [threshold, rootMargin]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
muted
playsInline
preload="metadata"
controls={false}
{...props}
/>
);
}
export default VideoAutoPlay;실무 팁: threshold를 0.6~0.8로 두면 절반만 보이는 상황에서의 불필요한 깜빡임을 줄일 수 있습니다. rootMargin을 "200px 0px"처럼 설정하면 사용자 스크롤 도달 전 미리 재생 준비를 할 수 있습니다.
3. 여러 비디오를 위한 재사용 훅
피드처럼 비디오가 여러 개라면 Observer를 하나만 만들고 모든 비디오를 등록하는 패턴이 효율적입니다.
import React, { useCallback, useEffect, useRef } from 'react';
function useVideoAutoPlay({ threshold = 0.6, rootMargin = '0px' } = {}) {
const nodesRef = useRef(new Set());
const observerRef = useRef(null);
const handleIntersect = useCallback((entries) => {
entries.forEach(({ isIntersecting, intersectionRatio, target }) => {
const visible = isIntersecting && intersectionRatio >= threshold;
if (visible) {
target.play?.().catch(() => {});
} else {
target.pause?.();
}
});
}, [threshold]);
useEffect(() => {
if (typeof window === 'undefined') return;
observerRef.current = new IntersectionObserver(handleIntersect, {
threshold: [threshold],
rootMargin,
});
// 이미 등록된 노드 다시 관찰
nodesRef.current.forEach((node) => observerRef.current.observe(node));
const onVisibility = () => {
if (document.hidden) nodesRef.current.forEach((n) => n.pause?.());
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
observerRef.current?.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
};
}, [handleIntersect, rootMargin, threshold]);
// 콜백 ref로 비디오 등록
const refCallback = useCallback((node) => {
if (!node) return;
nodesRef.current.add(node);
observerRef.current?.observe(node);
}, []);
return refCallback;
}
// 사용 예시
function VideoFeed({ items }) {
const autoPlayRef = useVideoAutoPlay({ threshold: 0.6, rootMargin: '100px 0px' });
return (
<div>
{items.map((item) => (
<video
key={item.id}
ref={autoPlayRef}
src={item.src}
muted
playsInline
preload="metadata"
controls={false}
style={{ width: '100%', display: 'block' }}
/>
))}
</div>
);
}
export { useVideoAutoPlay };4. 모바일·브라우저 고려사항
- iOS Safari: playsInline와 muted가 필수입니다. controls를 숨기면 사용자가 소리 활성화를 할 수 있는 UI를 별도로 제공하세요.
- 사용자 제스처 필요: 소리 재생은 사용자 입력 후에만 허용됩니다. 클릭 시 음소거 해제 및 재생을 제공합니다.
function SoundToggle({ video }) {
const onClick = () => {
if (!video) return;
video.muted = false;
video.play().catch(() => {
// 실패 시 안내 메시지나 controls 노출
});
};
return <button onClick={onClick}>소리 켜기</button>;
}- 탭 전환: visibilitychange로 백그라운드에서 자동 정지합니다.
5. 성능 최적화 팁
- Observer 공유: 비디오별로 Observer를 생성하지 말고 하나만 공유하세요.
- threshold 배열: [0, 0.25, 0.5, 0.75, 1]처럼 세밀한 단계가 필요하면 배열을 사용하되, 이벤트 빈도 증가에 유의합니다.
- rootMargin 프리롤: "200px" 등으로 프리롤 버퍼를 두면 부드러운 재생 준비가 가능합니다.
- preload=metadata: 초기 네트워크 비용을 줄이고, 실제 노출 시에만 데이터 로드를 트리거하세요.
- cleanup 철저: 컴포넌트 언마운트 시 disconnect로 메모리 누수를 막습니다.
6. SSR/Next.js 대응
SSR 환경에서는 window가 없으므로 Observer 초기화를 가드하거나, 해당 컴포넌트를 동적 로딩하세요.
// 가드 예시
useEffect(() => {
if (typeof window === 'undefined') return;
// IntersectionObserver 초기화...
}, []);
// Next.js에서 동적 로딩
// import dynamic from 'next/dynamic';
// const VideoAutoPlay = dynamic(() => import('./VideoAutoPlay'), { ssr: false });7. 테스트·디버깅 체크리스트
- 다양한 화면 크기에서 threshold가 의도대로 동작하는지 확인합니다.
- 탭 전환, 빠른 스크롤, 저전력 모드에서 재생/정지 안정성을 점검합니다.
- iOS/Android 크로스브라우징에서 muted/playsInline 정책을 검증합니다.
- 네트워크 느린 환경에서 preload 전략이 과도하지 않은지 확인합니다.
Intersection Observer로 가시성 기반 자동 재생을 구현하면 UX는 좋아지고 리소스 낭비는 줄어듭니다. 위 코드를 바로 적용해 피드·갤러리·콘텐츠 목록에서 안정적인 비디오 경험을 제공하세요.
'React' 카테고리의 다른 글
| React 앱에서 브라우저 Speech Recognition API 사용하기 (0) | 2026.05.28 |
|---|---|
| React에서 모션 센서 API 활용하여 인터랙션 강화하기 (0) | 2026.05.28 |
| React 앱에서 로컬 개발 환경과 프로덕션 환경 분리하기 (0) | 2026.05.27 |
| React에서 CSS-in-JS 라이브러리 비교 및 선택 가이드 (1) | 2026.05.27 |
| React로 PDF 뷰어 및 다운로드 기능 구현하기 (0) | 2026.05.26 |