제대로된 HTTP 프로토콜 동영상 스트리밍 서비스에 대해서는 후속 포스트에서 다룰 예정입니다.
여기서는 이를 위한 사전 준비 겸 개념 이해를 위해 작성되었습니다.
prerequisite
* 싱글 스레드인 이벤트 루프(쉬운 말로 여러분들이 코드를 작성하는 바로 그 곳) blocking 하지 마세요.
readFileSync로 용량 큰 비디오를 서빙한다? 바로 node 식물인간 됩니다. 아래 문서도 읽으세요.
nodejs.org/ko/docs/guides/dont-block-the-event-loop/#don-t-block-the-event-loop
* 브라우저와 서버가 http 통신을 할 때 헤더의 Content-type에서 지정한 MIME type에 의해 브라우저가 적절한 동작을 처리할 수 있으므로 알아두어야 한다.
* Content-Disposition 헤더도 알아두면 좋습니다.
* 오디오, 비디오 결국 모두 binary인 거 아시죠?
* 현재 LTS 버전인 14.15.4 기준으로 진행합니다.
fs 내장 모듈
파일 시스템 관련된 내용들입니다.
node fs 모듈 : nodejs.org/dist/latest-v14.x/docs/api/fs.html
좀 더 친절한 설명 : nodejs.dev/learn/the-nodejs-fs-module
사용 가능한 메서드들은 아래와 같은 것들이 있습니다.
당연히 외울 필요는 없습니다만 createReadStream 같은 메서드들은 유용할 것으로 보이네요.
- fs.chmod(): change the permissions of a file specified by the filename passed. Related: fs.lchmod(), fs.fchmod()
- fs.chown(): change the owner and group of a file specified by the filename passed. Related: fs.fchown(), fs.lchown()
- fs.createReadStream(): create a readable file stream
- fs.createWriteStream(): create a writable file stream
- 이 외에 더 많은 메서드들이 있으나 생략
* 각 메서드에 Sync를 붙이면 동기적으로 동작하는데 서버 사이드로 node를 사용한다면 가급적 사용하지 맙시다.
왜인지는 이미 prerequisite에서 언급했습니다.
const fs = require("fs");
// 동기적
const data = fs.readFileSync("./data.txt", "utf-8");
console.log(data);
// 비동기적
const data2 = fs.readFile("./data.txt", "utf-8", (err, data) => {
if (err) {
console.log(err);
}
console.log(data);
});
Binary 파일(이미지, 오디오, 비디오 등)을 서빙하기
1) 서빙하고자 하는 파일의 MIME 타입 알아내기
특정 경로로 접근하면 binary 파일인 이미지, 음성, 비디오를 서비스해보도록하겠습니다.
asset들은 서버 내에 저장되어 있는 것이라고 가정합니다.
당연히 asset의 용량 자체가 크면 한 번에 서빙하면 안되겠죠. 쪼개서 보내야 합니다. 나중에 해보고, 여기서는 단순히 서빙하는 내용만 다루겠습니다.
우선 content-type에 적절한 미디어 타입을 설정해주기 위해 mime 패키지를 이용하겠습니다.
패키지에 의존하지 않고 node.js에서 native하게 파일의 mime type을 알아내는 방법은 없는 듯합니다.
파일 시스템 관련 처리는 OS의 도움을 받아야하므로 child_process를 통해서 별도의 프로세스를 생성하여 shell 명령어로 해당 파일의 mime 타입을 알아내고 이를 읽어오는 방법이 있으나 이럴거면 그냥 패키지를 쓰는 것이 좋습니다.
function getMimeFromPath(filePath) {
const execSync = require('child_process').execSync;
const mimeType = execSync('file --mime-type -b "' + filePath + '"').toString();
return mimeType.trim();
}
어쨌거나 아래 패키지를 설치합니다.
* 추가적인 팁으로 mime/lite를 사용하면 잘 쓰지 않는 메서드를 몇 개 없앤 lite한 버전을 사용할 수 있습니다. 용량이 1/3 토막 납니다. 저는 이걸 사용합니다. 사용하는 메서드가 getType외엔 잘 없어서 ㅎ.
const mime = require("mime"); // 35.7k gzipped: 10.2k
const mime = require("mime/lite"); // 10.6k gzippe: 3.4k
2) MIME type과 함께 서빙하기
사실 브라우저에서 일반적인 파일들은 MIME type을 알아서 잘 해석해서 보여줍니다. 헤더를 꼭 넣어주지 않아도 정상적으로 동작하긴 하지만, 명시적으로 header를 지정해주는 것을 습관 들이면 좋습니다.
* 텍스트가 아니라 binary 입니다. 인코딩값 없이 readFile 메서드를 사용합니다.
* 이미지, 텍스트, 비디오, 오디오 다 됩니다. srt 자막 파일도 읽혀봤는데 잘 나오더라구요
globalRouter.get("/image", function (req, res) {
const imgPath = path.resolve("./exam", "img-1.jpg");
const imgMime = mime.getType(imgPath); // jpg라고 확장자가 붙었지만 까보니 image/jpeg다.
fs.readFile(imgPath, (err, data) => {
if (err) return res.json({ err: err });
console.log(data); // Buffer ff d8 ff e0 00....
// 해당 경로로 접속한 유저에게 img를 보여 준다
return res.set({ "Content-Type": imgMime }).send(data);
});
});
* 난 보이지 않고 다운로드 받아 지는데? || 나는 다운로드가 아니라 보여지는데?
Content-Disposition 헤더를 세팅하자. 그런데 이 헤더는 아직 표준이 아니라서 브라우저마다 다른 동작을 할 때가 있어서 그렇다.
다운로드가 아니라 단순히 보여주고 싶으면 express에선 static으로 정적 파일 제공하는 경로를 세팅하고
Content-Disposition를 inline으로 설정하면 된다.
express 프레임웤 한정으로 res.type과 res.download 등의 메서드를이용하여 컨텐츠 타입을 설정하고, 경로에 접근하면 다운로드를 받을 수 있도록 만들 수 있다.
Buffer는 또 무엇인가, Buffer 내장 모듈
Binary 파일을 readFile하니 Buffer를 반환했다. Buffer란 또 무엇인가?
데이터를 조각(청크, chunk)내어 buffer에 채운 후 다 차면 buffer를 통째로 옮기고 새 buffer에 아직 옮기지 못한 데이터 조각을 다시 채운다. 데이터 조각을 buffer에 채우는 일을 버퍼링(buffering)이라고 부른다. 영상이 버퍼링 중이라며 재생되지 않는 경우를 종종 경험했을텐데 buffer에 데이터를 채울 때까지 기다리는 버퍼링 작업을 말하는 것이다.
한편 buffer가 다 차면 이를 전송하고 다시 buffer를 채우는 버퍼링 작업을 연속하는 것이 스트림(stream)이다. 단발성 single buffer도 존재하지만 지속적으로 buffer가 나오는 것을 stream buffer라고 한다. 버퍼를 이용해 데이터를 전송하는 '흐름'이 스트림이라고 이해하자.
더 좋은 설명이 있어 가져와 봤다.
Buffer란 chunk 낸 메모리 조각일 따름이다.
binary를 전송하는 좋은 방식이자 stream 프로세서가 buffer를 받아 지속적으로 서비스하는 방식으로 많이 구현된다.
A buffer is an area of memory. It represents a fixed-size chunk of memory (can't be resized) allocated outside of the V8 JavaScript engine.
Buffers were introduced to help developers deal with binary data, in an ecosystem that traditionally only dealt with strings rather than binaries. Buffers are deeply linked with streams. When a stream processor receives data faster than it can digest, it puts the data in a buffer.
A simple visualization of a buffer is when you are watching a YouTube video and the red line goes beyond your visualization point: you are downloading data faster than you're viewing it, and your browser buffers it.
출처 : nodejs.dev/learn/nodejs-buffers
개념적으로는 알겠는데 Buffer 내장 모듈을 사용해봐서 코드로 익혀보자.
Buffer를 만들 수 있는 메서드는 3개가 있다.
Buffer.from은 가장 간단히 Buffer를 만들 수 있는 방법이다.
간단히 문자열을 Buffer로 만들어 보았다.
const buf = Buffer.from("This is String"); // buffer 생성
console.log(buf); // <Buffer 54 68 69 73 20 69 73 20 53 74 72 69 6e 67>
console.log(buf.toString());
alloc과 allocUnsafe는 특정 크기만큼의 Buffer를 생성하게 할 수 있습니다.
alloc은 00으로 찬 buffer를 만들고 (초기화 됨)
allocUnsafe는 초기화되지 않은 쓰레기 값을 가진 buffer를 만듭니다.
따라서 allocUnsafe가 좀 빠르긴하지만 잘못하면 쓰레기값을 그대로 들고 있는 buffer를 사용하게 될 수도 있습니다.
const bufAlloc = Buffer.alloc(1024); // 1kb buffer
console.log(bufAlloc); // Buffer 00 00 00 00 ...
const bufUnsafeAlloc = Buffer.allocUnsafe(1024); // 1kb buffer
console.log(bufUnsafeAlloc); // Buffer 11 69 bf b3 56 ...
Stream 활용 전 사전 지식
nodejs.org/dist/latest-v14.x/docs/api/stream.html
stream은 데이터 전체를 다 읽거나 쓰지 않고도 중간에 어떠한 동작을 해줄 수 있게 해줍니다.
노드의 장점 중 하나인 stream 처리입니다. 노드는 이벤트 기반의 비동기 처리를 지원하기 때문에 대용량 파일을 구간별로 작게 나누어서 처리 하는 작업에 강점을 가지고 있다. stream을 편하게 사용하기 위한 여러 다른 패키지가 많지만 여기서는 node의 native한 stream api만 다루기로 하자.
- nodejs의 fs 모듈
- HTTP 206 응답(Partial Content) => Content-Range 응답 헤더가 요구됨.
- Content-Range 응답 헤더 => 전체 바디 메시지에 속한 부분 메시지의 위치를 알려줍니다.
- Accept-Ranges 응답 헤더 => range로 받으므로 byte로 설정. 어차피, 이 헤더의 속성 byte 하나 밖에 없...
- Range 요청 헤더 => 어느 정도로 끊어서 보내줘야 하는지 요청에서 처리합니다.
twitch 스트리밍에서 오고간 header를 살펴보면 위에서 언급한 것들을 살펴볼 수 있습니다.
HTTP 206은 요청 헤더에 데이터 범위를 지정한 헤더가 있을 경우 그 범위에 대한 요청이 성공적으로 응답이 되어 바디에 그 데이터가 있다는 것을 알려주는 번호다.
MDN에서 206 response는 다음과 같이 생겼다고 알려준다.
Content-Type이야 스트리밍하는 대상의 미디어 타입이고, Content-Range는 전체 바디 메시지에 속한 부분 메시지의 위치를 알려준다.
HTTP/1.1 206 Partial Content
Date: Wed, 15 Nov 2015 06:25:24 GMT
Last-Modified: Wed, 15 Nov 2015 04:58:08 GMT
Content-Length: 1741
Content-Type: multipart/byteranges; boundary=String_separator
--String_separator
Content-Type: application/pdf
Content-Range: bytes 234-639/8000
...the first range...
--String_separator
Content-Type: application/pdf
Content-Range: bytes 4590-7999/8000
...the second range
--String_separator--
Content-Range: bytes 234-639/8000
Content-Range: bytes 4590-7999/8000
과 같은 헤더가 의미하는 바는 아래와 같습니다.
Content-Range: <unit> <start>-<end>/<size>
<unit>단위는 범위를 지정합니다. 보통 bytes를 사용합니다.
<range-start>범위 요청의 시작을 알려주는 정수 단위.
<range-end>범위 요청의 끝을 알려주는 정수 단위.
<size>문서의 총 크기(또는 모른다면 '*')
이제 Stream을 해보자
2GB 정도 되는 비디오 파일을 readFile로 읽어보려고 하니 10분이 지나도 비디오가 재생되지 않는 것을 확인했습니다.
globalRouter.get("/:fileName", function (req, res) {
const { fileName } = req.params;
const videoPath = path.resolve("./exam", `${fileName}.mp4`);
const Mime = mime.getType(videoPath);
try {
fs.readFile(videoPath, (err, data) => {
if (err) return res.json({ err: err });
return res
.set({
"Content-Type": Mime,
})
.send(data);
});
} catch (err) {
res.json({ err: err });
}
});
기본적으로 ReadStream은 data, end, error 이벤트를 가진 EventEmitter이다.
stream을 시작할 때 data, 에러 났을 때 error, 스트림 전송을 마치면 end로 이벤트를 캐치하면 된다.
* ReadmStream이 있다는 말은, WriteStream이 있다는 말이겠죠? 여기서는 생략합니다
globalRouter.get("/", function (req, res, next) {
res.send("/:filename to get video");
});
globalRouter.get("/:fileName", function (req, res) {
const { fileName } = req.params;
const videoPath = path.resolve("./exam", `${fileName}.mp4`);
const stream = fs.createReadStream(videoPath); // ReadStream 생성
let count = 0;
// stream을 보내자.
stream.on("data", function (data) {
count = count + 1;
console.log("data count=" + count);
// res.send, res.json은 res.end의 의미를 포함하고 있어 1회성 통신만 가능하다.
// 여기서는 res.end하기 전까지 지속적으로 data를 보내줘야 하므로 res.write를 이용하자
res.write(data);
});
// stream을 전부 다 보냈을 경우 종료
stream.on("end", function () {
console.log("end streaming");
res.end();
});
// 에러
stream.on("error", function (err) {
console.log(err);
res.end("500 Internal Server " + err);
});
});
stream으로 구현은 했으니 이제 제대로 서비스를 하기 위해 헤더도 붙여보고 pipe도 붙여볼 수가 있다.
보통 프론트에서 요청 헤더 Range를 넣어줍니다. 없을수도 있구요. 있다면 정규식이든, splice건 적당히 잘라서 활용하면 됩니다. Range가 있건 없건 적당한 byte로 잘라서 서빙하는 경우도 많습니다.
Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
구현한 스트리밍은 다음과 같습니다.
fs.stat 메서드는 파일의 크기 등 정보를 추출하는게 사용되는 메서드입니다. 당연히 statSync는 사용 금지입니다.
globalRouter.get("/:fileName", function (req, res) {
const { fileName } = req.params;
const videoPath = path.resolve("./exam", `${fileName}.mp4`);
fs.stat(videoPath, (err, stat) => {
if (err) return res.json({ err });
const { size } = stat;
console.log("file size", size);
const start = 0;
const end = size - 1;
const chunk = end - start + 1;
// ReadStream 생성
const stream = fs.createReadStream(videoPath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunk,
"Content-Type": "video/mp4",
});
// data, end 이벤트 핸들러대신 pipe 사용
stream.pipe(res);
});
});
코드를 실행해보니, 10분이 걸려도 재생되지 않던 비디오가 재생되기 시작합니다!
Network 탭을 확인해보면 지속적으로 비디오 정보를 받아오는 것을 확인할 수 있습니다.
'Node, Nest, Deno > 🚀 Node.js (+ Express)' 카테고리의 다른 글
더 나은 node.js 기반 server를 위한 아키텍쳐 + 팁들! (0) | 2021.02.15 |
---|---|
Stream 사용하기 (0) | 2021.01.29 |
node.js에서 child_process로 다른 프로세스 생성하기 (0) | 2021.01.27 |
node.js 서비스 로그 관리 : pm2 log management (0) | 2021.01.11 |
nginx(reverse-proxy), node에서 이미지 업로드 중 request entity too large 에러 (0) | 2020.12.08 |