API를 호출했더니 응답은 수십 바이트인데 영상은 수백 MB가 들어오는 이유가 뭘까. 개발 환경과 운영 환경에서 영상 로딩 방식은 어떻게 다를까.
VOD 기능을 개발하다 보면 이 구조를 정확히 이해해야 할 순간이 온다. 이 글에서는 그 구조를 처음부터 정리한다.
📌 영상 서비스의 실제 구조
실제 운영 환경에서 VOD를 서비스하는 방식은 단순하다. DB에 영상 파일 경로가 저장되어 있고, 백엔드 API가 그 경로를 조합해 CDN URL을 반환한다. 실제 영상 데이터는 브라우저가 CDN에서 직접 받아온다.
단계 역할 전송량
| ① Backend API | 콘텐츠 ID로 DB 조회 → CDN URL 반환. 영상 데이터와 무관 | JSON 수십 바이트 |
| ② Playlist (.m3u8) | HLS 방식일 경우 세그먼트 목록 텍스트 반환 | 텍스트 수 KB |
| ③ CDN Edge | 실제 영상 데이터를 브라우저에 직접 전달 | 세그먼트당 ~3–5 MB |
[ 운영 환경 전체 흐름 ]
Browser → Backend API 콘텐츠 ID 전달
Backend → DB 영상 파일 경로 조회
Backend → Browser { video_url: "https://cdn.example.com/..." } 반환
Browser → CDN 영상 데이터 직접 요청 (Backend 무관)
CDN → Browser 영상 전달
핵심은 백엔드는 URL만 알려주고 빠진다는 점이다. 백엔드가 영상을 직접 중계하면 서버 트래픽이 동시 접속자 수 × 영상 크기로 폭증한다. CDN을 쓰는 이유가 바로 이것이다.
CDN(Content Delivery Network)이란? 원본 파일(Origin)을 전 세계 여러 서버에 복제해두고, 요청이 들어오면 사용자와 물리적으로 가장 가까운 서버(엣지)가 응답하는 구조다. 국내 사용자가 국내 CDN 엣지에서 받으면 미국 Origin에서 받는 것보다 수십 ms 이상 빠르다.
📌 백엔드가 반드시 필요한 이유 — 프론트가 CDN을 직접 알 수 없다
"어차피 CDN에서 받아오는 거라면, 프론트에서 바로 CDN URL을 만들어서 요청하면 되지 않나?" 하는 의문이 생길 수 있다. 하지만 CDN URL 안에는 DB에만 존재하는 파일 경로 정보가 포함되어 있다.
CDN URL 예시:
https://cdn.example.com/vod/A20240305_JB20240305_PF20200825.mp4/playlist.m3u8
↑
이 파일명은 DB에만 있는 정보.
콘텐츠 ID만으로는 프론트가 절대 알 수 없음.
프론트가 직접 하려면?
콘텐츠 ID: 12345 → 파일명: ??? → 알 방법 없음 (DB 접근 불가)
DB를 프론트에 직접 노출하면 안 되는 이유 DB를 프론트에서 직접 쿼리할 수 있게 열어두면 영상 경로뿐 아니라 다른 모든 데이터도 노출된다. 또한 영상 URL 변환 API에는 인증이 걸려 있어서 프론트에서 직접 호출하면 401 오류가 발생한다. 백엔드가 인증 후 URL만 전달하는 구조가 보안상으로도 올바른 설계다.
📌 MP4 vs HLS — 영상을 가져오는 두 가지 방식
CDN에서 영상을 받아오는 방식도 하나가 아니다. 단순히 파일 포맷의 차이가 아니라, 네트워크를 얼마나 효율적으로 쓰느냐의 차이다.
MP4 — Range Request 방식
브라우저의 <video> 태그는 MP4를 HTTP Range Request로 처리한다. 파일 전체를 한번에 받는 게 아니라 필요한 바이트 범위만 요청하는 방식이다.
GET /video.mp4 Range: bytes=0-65535
← moov atom 수신 (메타데이터, 수십 KB) → 즉시 재생 시작
GET /video.mp4 Range: bytes=65536-...
← 재생하면서 필요한 구간 추가 요청
MP4 파일 앞부분의 moov atom에는 전체 영상의 타임라인과 재생 메타데이터가 담겨 있다. 이 부분만 받으면 바로 재생을 시작할 수 있어서 빠르다는 인상을 준다.
⚠️ 주의: moov atom 위치 moov atom이 파일 끝에 있으면 재생 전에 파일 전체를 받아야 한다. 인코딩 시 -movflags faststart 옵션으로 moov atom을 앞으로 옮겨야 스트리밍이 정상 동작한다.
HLS — 세그먼트 방식
HLS(HTTP Live Streaming)는 영상을 미리 짧은 조각(.ts 파일)으로 분할해두고, 플레이리스트(.m3u8)에 그 목록을 기록한다. 플레이어는 재생 위치에 맞는 세그먼트만 순차적으로 요청한다.
GET /playlist.m3u8
← #EXTM3U
#EXT-X-TARGETDURATION:10
media_0.ts ← 0~10초
media_1.ts ← 10~20초
...
media_359.ts ← 3590~3600초 (60분)
GET /media_10.ts ← 현재 재생 위치
GET /media_11.ts ← 선행 버퍼 (maxBufferLength 설정값만큼)
1시간짜리 영상이라도 재생 위치 근처 세그먼트 몇 개만 받으면 된다. HLS.js의 기본 maxBufferLength는 60초로, 방송 VOD처럼 콘텐츠가 긴 경우 10~15로 낮추면 불필요한 선행 다운로드를 줄일 수 있다.
MP4 vs HLS 비교
항목 MP4 (Range Request) HLS (세그먼트)
| 초기 재생 | ✅ 빠름 — moov atom만 받으면 시작 | 약간의 지연 — .m3u8 파싱 필요 |
| 네트워크 사용량 | 브라우저 재량 | ✅ 세그먼트 단위 제어 |
| 60분 영상 전체 | ❌ 수 GB 요청 가능 | ✅ 수 MB씩 분할 |
| 임의 탐색 (seek) | 이미 받은 범위만 | ✅ 어디든 즉시 가능 |
| 화질 적응 (ABR) | ❌ 불가 | ✅ 네트워크 상태 따라 자동 전환 |
| 장시간 콘텐츠 | ❌ 30분 이상 비권장 | ✅ 적합 |
30분 이상 VOD라면 HLS가 사실상 필수다. MP4가 빠르게 느껴지는 이유는 moov atom 덕분이지, 네트워크 효율이 높아서가 아니다.
📌 로컬 개발 환경 — MSW로 영상 기능 구현하기
MSW를 쓰는 이유는 백엔드 없이도 실제처럼 동작하는 환경을 만들기 위해서다. 영상 기능도 마찬가지로, MSW 핸들러가 실제 CDN URL을 반환하면 브라우저가 그 URL로 영상을 실제로 받아온다.
환경 동작 방식
| 로컬 + MSW | 핸들러가 CDN URL을 하드코딩해서 반환. 브라우저는 실제 CDN에 직접 영상 요청. CDN passthrough 설정 필요 |
| 로컬 + 실 백엔드 | 백엔드가 DB에서 실제 URL 조회 후 반환. MSW 핸들러 제거하면 자동 전환. 운영 환경과 동일한 흐름 |
MSW 핸들러 — CDN URL 하드코딩으로 실제 재생 구현
// MSW 핸들러: 검증용 CDN URL을 하드코딩해서 반환
http.get('/api/videos/:id', () => {
return HttpResponse.json({
title: '샘플 콘텐츠',
hls_url: 'https://cdn.example.com/vod/sample/playlist.m3u8',
thumbnail: '/mock/thumbnail.jpg',
});
});
// 백엔드 연동 완료 후에는 이 핸들러만 제거하면 끝
CDN 요청 passthrough 설정
MSW는 Service Worker 기반으로 동작하기 때문에 CDN 요청도 기본적으로 가로챈다. CDN 도메인은 MSW가 처리하지 않고 실제 네트워크로 통과시켜야 한다.
이때 한 가지 함정이 있다. MSW의 문자열 패턴 파서는 URL 안의 콜론을 파라미터 구분자로 해석한다. mp4:20240101 같은 경로가 포함된 CDN URL은 문자열 패턴으로 매칭이 안 된다. 반드시 정규표현식을 써야 한다.
⚠️ passthrough 설정을 빠뜨리면 어떻게 되나 MSW가 CDN 요청을 가로채서 직접 fetch()로 재요청할 때, 브라우저가 붙인 Range: bytes=0-65535 헤더가 제거된다. 서버는 Range 없는 요청으로 받아 파일 전체(200 OK)를 응답하고, MSW가 수백 MB를 Service Worker 메모리에 버퍼링한 뒤 브라우저에 전달한다. 수십 KB만 받아야 할 요청이 수백 MB로 폭증하는 원인이 여기 있다.
// ❌ 문자열 패턴: URL 내 콜론을 파라미터로 오인해 매칭 실패
// http.get('https://cdn.example.com/:path*', passthrough)
// ✅ RegExp 패턴으로 CDN 도메인 전체를 passthrough 처리
http.get(/^https:\/\/cdn\.example\.com\//, () => {
return passthrough();
});
// HLS 플레이리스트 서버도 동일하게 처리
http.get(/^https:\/\/vod\.example\.com\//, () => {
return passthrough();
});
MSW → 실 백엔드 전환은 한 줄이면 끝 핸들러에서 CDN URL을 하드코딩했기 때문에, 백엔드 연동이 완료되면 해당 MSW 핸들러만 제거하면 된다. 컴포넌트 코드는 전혀 바꾸지 않아도 백엔드가 반환하는 실제 URL로 자동 전환된다.
📌 HLS.js 기본 설정
import Hls from 'hls.js';
function initVideo(videoEl: HTMLVideoElement, hlsUrl: string | null) {
if (!hlsUrl) return; // null이면 초기화하지 않음
if (Hls.isSupported()) {
const hls = new Hls({
// 기본값 60s. 장시간 VOD는 10~15s로 낮추면 트래픽 절감
maxBufferLength: 10,
});
hls.loadSource(hlsUrl);
hls.attachMedia(videoEl);
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
// Safari는 HLS 네이티브 지원
videoEl.src = hlsUrl;
}
}
📋 정리
항목 내용
| CDN의 역할 | 백엔드는 URL만 반환. 영상 데이터는 브라우저↔CDN 직접 통신. 백엔드가 중계하면 트래픽이 접속자 수 × 영상 크기로 폭증 |
| 백엔드가 필요한 이유 | CDN URL 안의 파일 경로는 DB에만 있는 정보. 프론트는 콘텐츠 ID만으로 알 수 없고, 직접 API 호출 시 인증 오류 발생 |
| MP4 vs HLS | MP4는 초기 재생이 빠르지만 장시간 콘텐츠에 비효율. 30분 이상 VOD라면 HLS가 사실상 필수 |
| 로컬 + MSW | 핸들러에서 CDN URL을 하드코딩해 반환. CDN 도메인은 RegExp passthrough로 MSW 우회 필요 |
| passthrough 실패 시 | Range 헤더 제거 → 파일 전체 버퍼링 → 수백 MB 폭증. 반드시 RegExp 패턴으로 처리 |
| 로컬 + 실 백엔드 | 운영 환경과 동일한 흐름. MSW 핸들러만 제거하면 자동 전환 |
| maxBufferLength | HLS.js 기본값 60s. 장시간 콘텐츠는 10~15s로 낮추면 트래픽 절감 |