모바일에서만 영상이 안 나와? 내 삽질기 (ft. Range 헤더)
Node.js로 구성된 환경에서 나는 영상을 가져오는 기능을 담당하는 api를 개발했다.
정말 그냥 http://localhost/file/test.mp4하면 아래처럼 영상을 보여주는 api다..
- PC에선 잘 보이는 영상이, 모바일에선 깨져서 나오거나 재생이 안 되는 경우가 있다.
- 영상 스트리밍에 필요한 HTTP Range 요청과 206 Partial Content 응답을 서버가 제대로 지원하지 않으면 모바일 브라우저에서 재생 실패한다.
- 이 글은 내가 직접 겪은 삽질 과정을 정리한 글이다. 혹시 같은 문제로 고생 중이라면 도움이 되기를,,.(과연?)
Node.js 기반의 API에서 아래처럼 단순히 동영상을 반환하는 기능을 만들었다.
GET http://localhost:8080/file/test.mp4
- <video src="..." controls> 태그 사용
- 모바일에서는 00:00~00:00으로 시간 표시만 되고 영상은 재생되지 않음
왜 데스크톱에선 잘 불러오는데? 왜 모바일에서만???!!?
디버깅 : curl로 API 응답 분석
정말 이런 생각으로 약 2시간동안 서칭했다. 먼저 내가 계속 붙잡고 있던 api를 curl 날려본 결과
- Content-Type
- Accept-Ranges
- Content-Length
음… 괜찮아 보이는데?
그래서 다른 서버도 테스트해봤다
http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4
http면서 해당 위 url을 모바일에서 여니까 너무 영상이 잘 나오는 것이었다ㅎ
그래서 당장 curl 요청을 날려봤다
열어보니 뭔가 내가 개발한 api의 응답값보다 항목이 많다. x-goog 항목은 저 영상 url이 googleApi를 통해 가져오므로 관련된 항목같았다. 그러므로 prefix goog는 패스! 이렇게 비교해보니
응답 결과에는 다음과 같은 추가적인 헤더들이 있었다:
Cache-Control : 캐시 메커니즘을 지정하기 위해 사용되는 헤더
ex:) Cache-Control : public, max-age=3600 : 초 단위로 캐시가 유효한 시간 제공
Expires
Last-Modified
ETag : Entity-Tag의 약자, 리소스를 식별하는 식별자
Date
그중에서도 가장 큰 차이는 바로 응답 코드와 Range 처리였다.
스트리밍/영상 콘텐츠에서 중요하게 봐야 할 헤더
- Accept-Ranges: bytes
- 이게 있어야 **동영상 스트리밍 시 점프(Seeking)**가 가능합니다.
- 예: <video controls src="...">에서 10분 뒤로 이동할 수 있는 기능.
- Content-Type: video/mp4
- 브라우저나 클라이언트가 어떤 형식인지 파악하는 데 필요.
- Access-Control-Allow-Origin: *
- CORS 허용됨 → 다른 출처의 웹 페이지에서도 이 리소스를 사용할 수 있음.
- Cache-Control / Etag
- 브라우저 캐시 정책 / 리소스 변경 여부 판단
용도 헤더
스트리밍 지원 | Accept-Ranges, Content-Length, Content-Type |
캐싱 정책 | Cache-Control, Last-Modified, Etag |
CORS 허용 여부 | Access-Control-Allow-Origin, Access-Control-Expose-Headers |
서버 메타정보 | x-goog-*, Server |
핵심 문제: Range 요청 미처리
모바일 브라우저는 영상 스트리밍 시 반드시 Range 요청을 보낸다
Range: bytes=0
이에 대해 올바른 응답은 206 Partial Content로 부분 응답을 보내야 된다.
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/29119704
Content-Type: video/mp4
Content-Length: 1048576
하지만 내가 만든 API는 항상 200 OK + 전체 파일을 응답하고 있었다.
모바일 브라우저는 이를 "스트리밍 불가능한 응답"으로 인식하고 재생을 포기한다.
해결 방법: Range 요청을 처리하자
app.get('/file/:filename', (req, res) => {
const filePath = path.join(__dirname, 'files', req.params.filename);
const stat = fs.statSync(filePath);
const total = stat.size;
const range = req.headers.range;
if (range) {
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : total - 1;
const chunkSize = end - start + 1;
const file = fs.createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${total}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': 'video/mp4',
});
file.pipe(res);
} else {
res.writeHead(200, {
'Content-Length': total,
'Content-Type': 'video/mp4',
});
fs.createReadStream(filePath).pipe(res);
}
});
추가로 다음 헤더도 설정하면 캐싱 및 성능에 도움된다:
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('ETag', 'some-value');
res.setHeader('Access-Control-Allow-Origin', '*');