FEInterview Prep

Tistory

HTTP 범위 요청(Range Requests)을 통한 동영상 제공하기

<video> 를 쓰려면 서버가 Range Request 를 반드시 이해해야 한다. Safari 는 첫 2바이트만 찔러보고 206 이 안 오면 영상을 아예 렌더링하지 않는다.

2026-01-02·7분 읽기
브라우저성능
원문 보기 ↗

핵심 요약

Range 요청 처리는 네 단계다.

  1. Range 헤더 파싱: bytes=start-end 에서 start 혹은 end 가 생략될 수 있다. NaN 체크로 기본값(파일 시작/끝)으로 폴백
  2. If-Range 검증: 클라이언트가 보낸 ETag 또는 Last-Modified 가 현재 리소스와 불일치하면 200 으로 전체를 다시 보내 손상을 방지
  3. 범위가 파일 크기를 초과하면 416 Range Not Satisfiable + Content-Range: bytes */{size} 반환
  4. 성공 시 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 의 createReadStreamstart/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',
  },
});
RangeContent-RangecreateReadStream206
자주 하는 오해

Content-Length 를 파일 전체 크기로 넣거나, end 가 파일 크기보다 크면 Math.min(end, size - 1) 로 클램핑하지 않는 실수. 그러면 클라이언트가 실제 받은 바이트와 선언 크기가 달라 재생이 끊긴다.

`If-Range` 는 '부분 응답의 안전 밸브'

재개 다운로드 시 클라이언트는 If-Range: "<etag>" 또는 마지막 수정 타임스탬프를 함께 보낸다.

서버가 가진 검증자If-Range응답
일치동일206 + 요청된 범위만
불일치다름200 + 전체 리소스 재전송
없음생략206 시도

이게 없으면 파일이 갱신된 상태에서 이전 바이트 오프셋에 이어붙이는 결과 → 파일 손상.

If-RangeETagLast-Modified
자주 하는 오해

ETag 를 그냥 파일 크기로만 만드는 것. 수정 시간이 같이 들어가야 덮어쓰기 시 값이 바뀐다.

`416` 은 'start' 기준으로 판단해야 사양 준수

416 Range Not Satisfiablefirst-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
416Content-Rangefirst-possuffix-range
자주 하는 오해

end > size 만으로 416 을 내면 스펙 위반. Reddit 에서 지적받고 저자가 원문을 수정한 포인트다.

읽는 순서

  1. 1이론

    MDN Range requests 문서에서 Range, If-Range, Content-Range, Accept-Ranges 네 헤더의 역할을 한 줄씩 정리하고, 200/206/416 분기 조건을 flowchart 로 그려 본다.

  2. 2구현

    Next.js Route Handler 또는 Express 에서 로컬 mp4 를 내려주는 엔드포인트를 만든 뒤, Safari 와 Chrome 네트워크 탭을 열어 206 응답과 여러 번 반복되는 청크 요청을 눈으로 확인한다.

  3. 3실무

    현재 프로젝트에서 업로드된 영상/오디오/대용량 파일을 어떤 경로로 서빙하는지 점검. CDN 이 아닌 자체 엔드포인트라면 Accept-Ranges: bytes 가 붙어 있는지 확인한다.

  4. 4설명

    'Safari 에서만 영상이 안 나온다' 는 버그 리포트를 받았다고 가정하고, 원인 진단 → Range 구현 → 416 경계 케이스까지 5분 안에 설명해 본다.

면접 연결 질문

medium`<video>` 태그에 `/api/video/abc.mp4` 를 src 로 걸었는데 Safari 에서만 재생이 안 된다. 원인과 해결책은?
힌트

[감점 답변] 'Safari 가 원래 까다롭다' 같은 일반론. [좋은 답변] Safari 는 첫 2바이트에 대한 Range 요청을 보내고 206 Partial Content 가 아니면 다음 <source> 로 넘어간다 → 모두 실패하면 렌더링 자체를 포기. 해결: 서버에서 Range 헤더를 파싱하고 createReadStream({ start, end }) 로 부분 스트림을 만들어 206 + Content-Range + Accept-Ranges: bytes 로 응답해야 한다.

easyRange 요청 구현 시 `200`, `206`, `416` 은 언제 각각 반환하는가?
힌트

[감점 답변] '상태 코드 설명' 만 나열. [좋은 답변] Range 헤더 없음 → 200 + 전체, 단일 범위 정상 → 206 + Content-Range, start 가 파일 크기 이상416 + Content-Range: bytes */{size}. If-Range 불일치로 검증자가 맞지 않을 때도 200 으로 전체를 다시 보낸다.

hard대용량 파일 다운로드에서 네트워크가 끊겼을 때 '이어받기' 가 가능한 서버를 만들려면 어떤 헤더가 최소한 필요한가?
힌트

[감점 답변] Range 만 언급. [좋은 답변] 서버는 평상시 응답에도 Accept-Ranges: bytes, ETag 또는 Last-Modified 를 함께 내려줘야 한다. 클라이언트가 끊긴 시점의 바이트부터 Range: bytes={n}- 로 재요청할 때 If-Range 검증자로 파일이 바뀌지 않았음을 확인해야 파일 손상 없이 이어붙일 수 있다.

자기 점검

`Content-Length` 와 `Content-Range` 는 Range 응답에서 각각 무엇을 의미하는지 한 문장으로 말해보세요.
Content-LengthContent-Rangeend - start + 1total size
자주 하는 오해

Content-Length 를 전체 파일 크기로 두는 오해. Range 응답에서는 이번 청크의 바이트 수 (end - start + 1) 이고, 전체 크기는 Content-Range 의 분모 (/{size}) 에만 나타난다.

`createReadStream({ start, end })` 의 `end` 가 HTTP `Range` 헤더와 '정확히 같은' 의미라는 게 왜 편리한가?
inclusive양끝 포함변환 불필요
자주 하는 오해

end 를 exclusive 로 오해해 end + 1 로 전달하는 실수. 둘 다 inclusive 라 그대로 패스하면 된다.