---
title: "동영상 썸네일을 canvas 캡처 없이 video 태그의 media fragment 로 만들기"
description: "S3 presigned URL 로 redirect 되는 구조에서는 canvas 로 동영상 프레임을 캡처할 수 없습니다. video 의 src 끝에 #t=0.1 을 붙여 첫 프레임을 그대로 보여 주는 방법입니다."
date: 2026-10-22
updated: 2026-10-22T04:58:45.413Z
tags: [frontend, video, html5, cors]
canonical: https://blog.wooncloud.com/posts/video-thumbnail-media-fragment
---

파일 관리 웹앱의 그리드 카드에 동영상 첫 프레임 썸네일을 띄워야 했습니다. 정석으로 알려진 방법은 숨긴 `<video>` 에서 프레임을 `canvas.drawImage()` 로 캡처해 dataURL 로 캐싱하는 것입니다.

그런데 이 방법이 우리 구조에서는 막혔습니다.

![cross-origin 영상은 canvas 캡처가 SecurityError 로 막히지만, media fragment 는 video 요소가 첫 프레임을 직접 그려 준다.](/images/posts/video-thumbnail-media-fragment/182ce18d-4f82-47ad-8d2b-6df11c435142.webp)

## canvas 캡처가 막히는 이유

파일 서빙이 "API 서버가 S3 presigned URL 로 307 redirect" 하는 구조였습니다. `<video>` 가 redirect 를 따라가 S3 에서 콘텐츠를 받는 순간 cross-origin 미디어가 되고, `drawImage()` 뒤에 `toDataURL()` 을 호출하면 canvas taint 로 `SecurityError` 가 납니다.

그럼 `crossOrigin="anonymous"` 를 붙이면 될까요? taint 는 풀리지만, 이번에는 S3 버킷에 CORS 설정이 없으면 로드 자체가 실패합니다. 버킷 CORS 는 인프라에 달린 문제라 코드만으로 보장할 수 없습니다.

결국 canvas 방식은 동일 출처에서만 동작하고, 운영 환경(S3, CDN)에서는 폴백으로만 떨어지는 코드가 됩니다.

## 캡처를 포기하고 media fragment 쓰기

`<video>` 요소 자체를 썸네일로 렌더하기로 했습니다. 방법은 media fragment(`#t=0.1`) 하나였습니다.

![redirect 된 cross-origin 영상은 canvas 캡처가 SecurityError 로 막히지만, src 끝에 #t=0.1 을 붙이면 브라우저가 첫 프레임을 직접 그려 준다.](/images/posts/video-thumbnail-media-fragment/36a93e20-9422-4022-a0cb-b51dfb634915.svg)

`src` 끝에 `#t=0.1` 을 붙이면 브라우저가 0.1초 지점으로 시킹해서 첫 프레임을 페인트합니다. `preload="metadata"` 와 같이 쓰면 포스터 이미지 없이도 정지 화면이 보입니다.

fragment 는 클라이언트에서만 해석되니 307 redirect 를 거쳐도 유지됩니다. presigned URL 이든 CDN 이든 똑같이 동작하고, canvas 를 안 쓰니 taint 와 CORS 문제도 처음부터 없습니다.

```jsx
function VideoThumb({ src, onFallback }) {
  const ref = useRef(null);
  const setRef = useCallback((node) => {
    if (!node && ref.current) {
      ref.current.removeAttribute("src"); // 디코더/네트워크 해제
      ref.current.load();
    }
    ref.current = node;
  }, []);

  return (
    <video
      ref={setRef}
      src={`${src}#t=0.1`}     // 첫 프레임 페인트, redirect 후에도 유지
      muted
      playsInline
      preload="metadata"
      tabIndex={-1}
      onError={onFallback}
      style={{ width: "100%", height: "100%", objectFit: "cover", pointerEvents: "none" }}
    />
  );
}
```

## 빼먹으면 티 나는 디테일

- `muted playsInline preload="metadata"` 로 모바일 자동재생 정책을 피하고, 메타데이터와 첫 프레임 정도만 받습니다. 전체 다운로드가 아닙니다.
- `pointer-events: none` 과 `tabIndex={-1}` 로 썸네일이 카드의 클릭, 더블클릭, 드래그 이벤트를 가로채지 않게 합니다.
- `onError` 에서 카테고리 아이콘으로 폴백합니다. avi, mkv 처럼 브라우저가 디코딩 못 하는 코덱은 자연스럽게 아이콘이 됩니다.

리소스 해제는 `useEffect` cleanup 대신 callback ref 에서 합니다. deps 기반 cleanup 은 파일 prop 이 바뀔 때 새로 커밋된 `src` 를 지워 버리는 타이밍 버그가 있습니다. ref 가 detach 되는 순간(`node === null`)에 `removeAttribute('src')` 후 `load()` 로 디코더를 내려놓는 쪽이 정확합니다.

## 트레이드오프

카드마다 `<video>` 요소가 살아 있으니 수백 개짜리 그리드에서는 브라우저의 미디어 플레이어 인스턴스 한도(Chrome 데스크톱 기준 대략 75개)에 닿을 수 있습니다. 대형 그리드라면 IntersectionObserver 로 lazy-mount 를 붙이는 게 다음 단계입니다.

캡처만이 답은 아니라는 걸 알게 된 김에, 다음 목록 화면부터는 이 방식을 먼저 써 보려고 합니다.

참고

- [MDN, Media fragments](https://developer.mozilla.org/en-US/docs/Web/Media/Audio_and_video_delivery/Adding_captions_and_subtitles_to_HTML5_video#media_fragments)
- [MDN, Tainted canvases](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image#security_and_tainted_canvases)
- [W3C, Media Fragments URI](https://www.w3.org/TR/media-frags/)
