본문 바로가기

React

React에서 Intersection Observer로 비디오 자동 재생 제어하기

스크롤에 따라 화면에 보이는 비디오만 자동 재생하고, 화면을 벗어나면 일시 정지시키는 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는 좋아지고 리소스 낭비는 줄어듭니다. 위 코드를 바로 적용해 피드·갤러리·콘텐츠 목록에서 안정적인 비디오 경험을 제공하세요.