Tistory
HTTP 범위 요청(Range Requests)을 통한 동영상 제공하기
<video> 를 쓰려면 서버가 Range Request 를 반드시 이해해야 한다. Safari 는 첫 2바이트만 찔러보고 206 이 안 오면 영상을 아예 렌더링하지 않는다.
핵심 요약
Range 요청 처리는 네 단계다.
Range헤더 파싱:bytes=start-end에서start혹은end가 생략될 수 있다.NaN체크로 기본값(파일 시작/끝)으로 폴백If-Range검증: 클라이언트가 보낸ETag또는Last-Modified가 현재 리소스와 불일치하면200으로 전체를 다시 보내 손상을 방지- 범위가 파일 크기를 초과하면
416 Range Not Satisfiable+Content-Range: bytes */{size}반환 - 성공 시
206+Content-Range: bytes {start}-{end}/{size}+Accept-Ranges: bytes+Content-Length: {end - start + 1}을 함께 내려 준다
Accept-Ranges: bytes 는 비-Range 응답에도 붙여야 이후 재개 다운로드가 가능해진다.
동영상은 '파일 다운로드' 가 아니라 바이트 구간 단위 랜덤 액세스 리소스 다. 브라우저는 Range: bytes=start-end 로 필요한 구간만 요청하고, 서버는 206 Partial Content + Content-Range 로 응답한다. Seek · 일시정지 후 재개 · 네트워크 중단 복구 모두 이 한 쌍의 헤더 위에 올라가 있다.
200 OK 로 전체를 스트리밍해도 Chrome/Firefox 는 재생한다. 하지만 Safari 는 206 이 아니면 다음 <source> 로 넘어가고, 모든 소스가 실패하면 비디오를 아예 숨긴다. CDN 을 쓰지 않고 /api/video/[id] 같은 커스텀 라우트로 자체 호스팅하는 순간 이 문제를 직접 처리해야 한다. Next.js public/ 폴더는 자동 지원하지만, 업로드된 사용자 파일을 라우트에서 내려줄 때는 지원이 없다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
Node.js `createReadStream({ start, end })` 는 그대로 Range 의미론
Node 의 createReadStream 은 start/end 를 양끝 포함(inclusive) 로 해석한다. HTTP Range 헤더와 의미가 정확히 같다.
const range = req.headers.get('Range'); // 'bytes=0-554057'
const [s, e] = range.replace('bytes=', '').split('-');
const start = Number.isNaN(+s) ? 0 : +s;
const end = Number.isNaN(+e) ? stats.size - 1 : +e;
const body = file.createReadStream({ start, end });
return new Response(body, {
status: 206,
headers: {
'Content-Type': 'video/mp4',
'Content-Length': `${end - start + 1}`,
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Accept-Ranges': 'bytes',
},
});
Content-Length 를 파일 전체 크기로 넣거나, end 가 파일 크기보다 크면 Math.min(end, size - 1) 로 클램핑하지 않는 실수. 그러면 클라이언트가 실제 받은 바이트와 선언 크기가 달라 재생이 끊긴다.
`If-Range` 는 '부분 응답의 안전 밸브'
재개 다운로드 시 클라이언트는 If-Range: "<etag>" 또는 마지막 수정 타임스탬프를 함께 보낸다.
| 서버가 가진 검증자 | If-Range 값 | 응답 |
|---|---|---|
| 일치 | 동일 | 206 + 요청된 범위만 |
| 불일치 | 다름 | 200 + 전체 리소스 재전송 |
| 없음 | 생략 | 206 시도 |
이게 없으면 파일이 갱신된 상태에서 이전 바이트 오프셋에 이어붙이는 결과 → 파일 손상.
ETag 를 그냥 파일 크기로만 만드는 것. 수정 시간이 같이 들어가야 덮어쓰기 시 값이 바뀐다.
`416` 은 'start' 기준으로 판단해야 사양 준수
416 Range Not Satisfiable 은 first-pos >= size 일 때만 내야 한다. end-pos > size 는 '초과 클램핑' 으로 처리해도 정상 범위다.
if (start > stats.size - 1) {
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${stats.size}` },
});
}
// end > size - 1 인 경우: end = stats.size - 1 로 클램핑 후 정상 206
end > size 만으로 416 을 내면 스펙 위반. Reddit 에서 지적받고 저자가 원문을 수정한 포인트다.
읽는 순서
- 1이론
MDN
Range requests문서에서Range,If-Range,Content-Range,Accept-Ranges네 헤더의 역할을 한 줄씩 정리하고,200/206/416분기 조건을 flowchart 로 그려 본다. - 2구현
Next.js Route Handler 또는 Express 에서 로컬 mp4 를 내려주는 엔드포인트를 만든 뒤, Safari 와 Chrome 네트워크 탭을 열어
206응답과 여러 번 반복되는 청크 요청을 눈으로 확인한다. - 3실무
현재 프로젝트에서 업로드된 영상/오디오/대용량 파일을 어떤 경로로 서빙하는지 점검. CDN 이 아닌 자체 엔드포인트라면
Accept-Ranges: bytes가 붙어 있는지 확인한다. - 4설명
'Safari 에서만 영상이 안 나온다' 는 버그 리포트를 받았다고 가정하고, 원인 진단 → Range 구현 →
416경계 케이스까지 5분 안에 설명해 본다.
면접 연결 질문
[감점 답변] 'Safari 가 원래 까다롭다' 같은 일반론. [좋은 답변] Safari 는 첫 2바이트에 대한 Range 요청을 보내고 206 Partial Content 가 아니면 다음 <source> 로 넘어간다 → 모두 실패하면 렌더링 자체를 포기. 해결: 서버에서 Range 헤더를 파싱하고 createReadStream({ start, end }) 로 부분 스트림을 만들어 206 + Content-Range + Accept-Ranges: bytes 로 응답해야 한다.
[감점 답변] '상태 코드 설명' 만 나열. [좋은 답변] Range 헤더 없음 → 200 + 전체, 단일 범위 정상 → 206 + Content-Range, start 가 파일 크기 이상 → 416 + Content-Range: bytes */{size}. If-Range 불일치로 검증자가 맞지 않을 때도 200 으로 전체를 다시 보낸다.
[감점 답변] Range 만 언급. [좋은 답변] 서버는 평상시 응답에도 Accept-Ranges: bytes, ETag 또는 Last-Modified 를 함께 내려줘야 한다. 클라이언트가 끊긴 시점의 바이트부터 Range: bytes={n}- 로 재요청할 때 If-Range 검증자로 파일이 바뀌지 않았음을 확인해야 파일 손상 없이 이어붙일 수 있다.
자기 점검
Content-Length 를 전체 파일 크기로 두는 오해. Range 응답에서는 이번 청크의 바이트 수 (end - start + 1) 이고, 전체 크기는 Content-Range 의 분모 (/{size}) 에만 나타난다.
end 를 exclusive 로 오해해 end + 1 로 전달하는 실수. 둘 다 inclusive 라 그대로 패스하면 된다.