일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- nodejs
- multiparty
- 프라우드넷
- S3
- 게임서버
- 아날로그영상
- Bucket
- 스트리밍서버
- Thumbnail
- 좌표
- 지하철역
- Sharp
- AWS
- 버킷
- ProudNet
- 이미지서버
- 노선
- 시작하기
- 디지털영상
- Node
- resize
- 이미지
- streaming
- 데이터
- 이미지프로세싱
- 샘플링
- 화소
- 튜토리얼
- 리사이즈
- lightsail
- Today
- Total
Deep Studying
Node.js로 음악, 동영상 스트리밍 서버 만들기 본문
이번 포스트에서는 express의 router를 사용하여 음악이나 동영상을 업로드, 로드할 수 있는 서버를 만들어보겠습니다.
음악을 스트리밍하는 것과 동영상을 스트리밍하는 것은 코드 진행이 거의 똑같기 때문에 먼저 음악을 기준으로 설명하고 뒤에 동영상을 사용하는 예시를 첨부하겠습니다.
1. 프로젝트 생성
npm init
npm install --save express multiparty
touch index.js mediaRouter.js
mkdir resource
이번 프로젝트에서 작성할 코드는 index.js와 mediaRouter.js 두 개 입니다. 우선 아래 코드를 넣어주세요
index.js
const mediaeRouter = require('./mediaRouter.js')
const express = require('express')
const app = express()
const PORT = 3000
app.use('/media',mediaRouter)
app.get('/upload',function(req, res){
const body = `
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<form action="/media" enctype="multipart/form-data" method="post">
<input type="file" name="file1" multiple="multiple">
<input type="submit" value="Upload file" />
</form>
</body>
</html>
`
res.writeHead(200, {"Content-Type": "text/html"});
res.write(body);
res.end();
})
app.listen(PORT, function(){
console.log(`running media server on ${PORT}`)
})
express로 서버를 실행, mediaRouter에 작성한 라우터를 /media 도메인에 등록합니다.
중간의 app.get('/upload' ... ) 는 이미지 업로드의 테스트에 사용할 페이지입니다.
필요에 따라서 변형해서 사용하셔도 되며 테스트 후에는 지워도 무방합니다.
mediaRouter.js
const router = require('express').Router()
const url = require('url')
router.get('/*', function(req, res){
res.end()
})
router.post('/', function(req, res){
res.end()
})
module.exports = router
2. 음악 업로드하기
로컬 storage에 저장하는 것을 기본으로 진행하겠습니다.
위에 작성한 router.post를 다음과 같이 바꿔주세요
router.post('/', (req,res)=>{
const form = new multiparty.Form()
form.on('error', err => res.status(500).end())
form.on('part', part => {
// file이 아닌 경우 skip
if(!part.filename)
return part.resume()
const filestream = fs.createWriteStream(`./resource/${part.filename}`)
part.pipe(filestream)
})
form.on('close', ()=>res.end())
form.parse(req)
})
multiparty form을 사용하여 업로드를 받겠습니다. 파일이 저장될 위치는 "resource/" 로 사용하였으며 원하시는 이름으로 바꾸어도 무방합니다. multipart를 처리하는 부분은 아래 글을 참고해주세요. 3-1. multipart form 처리하기 부분만 보셔도 됩니다.
Node.js와 AWS로 이미지 서버 만들기(2) 이미지 로드와 업로드
3. 음악 스트리밍하기
fs.createReadStream( ) 함수와 pipe( )를 사용하여 파일 스트림 그대로 전송해보겠습니다.
위에 작성한 router.get 부분을 다음과 같이 바꿔주세요
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const readStream = fs.createReadStream(`./resource${pathname}`)
readStream.pipe(res);
})
인터넷 브라우저에 http://localhost:3000/media/<file이름>을 입력해보면 /resource 디렉토리에 저장된 음악 파일을 스트리밍 할 수 있습니다.
음악이 재생되긴 하지만 어딘가 이상합니다. 음악은 재생되지만 프로그레스 바가 차오르지도 않고 음악의 구간을 스킵하려고 해도 클릭되지 않습니다. 음악의 전체 길이도 표시되지 않습니다.
또한 음악을 끝까지 재생하고 나서야 전체 음악의 길이를 볼 수 있습니다.
이 이유와 함께 해결 방법을 알아보도록 하겠습니다.
3-1. 음악의 전체 길이. Content-Length
위에서 음악의 전체 길이를 알 수 없던 문제가 있었습니다. 결론을 먼저 얘기하자면 클라이언트로 응답을 보낼 때는 "Content-Length"를 헤더에 포함해야합니다. 이는 서버에서 전달해줄 데이터가 몇 byte인지 클라이언트에 미리 알려주는 역할로 Transfer-Encoding이 정해져있지 않거나 chunked일 경우 넣어줄 것을 강력히 권고하고있습니다.
자세한 내용은 아래 문서들을 참고하실 수 있습니다.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding
https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
그러면 이 헤더를 넣어봅시다. router.get을 다음과 같이 바꿔주세요
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
// 이하 추가된 내용입니다.
const stat = fs.statSync(filepath)
const fileSize = stat.size
const header = {
'Content-Type' : 'audio/mpeg',
'Content-Length': fileSize,
}
res.writeHead(200, header);
/////////////////////////////////////
const readStream = fs.createReadStream(filepath)
readStream.pipe(res);
})
fs.statSync( ) 함수는 파일의 여러 정보를 가져오는 역할을 합니다. 파일의 크기, 생성한 날짜, 수정한 날짜 등의 정보를 가져옵니다. 우리가 필요한 정보는 size 즉, 파일의 크기입니다. 이를 이용해 header 객체를 위와 같이 만들어주고 res.writeHead( ) 함수로 이를 등록해줍니다.
이제 다시 음악파일을 스트리밍해보면 전체 길이도, 프로그레스 바도 확인이 가능합니다.
3-2. 음악 구간스킵. Range
음악이나 동영상 등의 미디어 스트리밍은 Range에 대한 옵션을 같이 요청합니다. 미디어 파일을 구간별로 쪼개어 "어디부터 어디까지" 요청하겠다는 의미입니다. 만약 서버에서 이를 수용할 수 있다면 response 헤더에 "Accept-Ranges"를 추가해주어야합니다. 그러면 그 때부터 클라이언트에서 '구간을 나누어 요청할 수 있겠구나' 라고 알게됩니다.
router.get 안 코드에서 다음과 같이 헤더를 추가해줍니다.
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const header = {
'Accept-Ranges': 'bytes', // 위 코드에서 이 부분만 달라졌습니다.
'Content-Type' : 'audio/mpeg',
'Content-Length': fileSize,
}
res.writeHead(200, header);
const readStream = fs.createReadStream(filepath)
readStream.pipe(res);
})
다시 음악을 재생해보면 프로그레스 바를 클릭하여 구간을 스킵해도 정상 동작합니다.
사실 헤더에 이 옵션을 추가했다고해서 서버에서 주는 데이터가 달라진 것은 아닙니다. 구간별로 데이터를 쪼개서 전송해주는 것도 아닐 뿐더러, 똑같이 파일 전체를 스트림으로 전송해주고, 파일의 크기가 몇인지 알려주는 것이 전부이죠. 하지만 브라우저에 내장된 기본 오디오 플레이어는 response의 헤더에 "Accept-Ranges" 필드가 유효한지 따져보고 구간 스킵을 가능하게 할 것인지 여부를 정하는 것 같습니다.
3-3. 파일 쪼개기. Content-Range
router.get 안 코드 중간에 다음과 같이 두 줄을 추가해봅시다.
router.get('/*',(req,res)=>{
...
const range = req.headers.range;
console.log(range)
...
})
다시 음악을 재생시켜보면 총 두 번의 요청이 연달아 오며 다음과 같은 값을 가지고 있습니다.
undefined
bytes=0-
첫 번째 요청은 해당 url에 정상적으로 접근할 수 있는지를 묻는 역할입니다. 만약 여기서 에러status가 온다면 해당 url에 접근할 수 없는 것으로 판단하고 재생을 하지 않습니다.
두 번째 요청은 실제 미디어 파일에 대한 요청입니다. range는 bytes=<시작 점>-<끝 점>과 같은 형태로 구성되어 있으며 <끝 점>은 생략할 수 있습니다. 파일을 구간별로 쪼개어 요청하고 싶을 때, "어디부터 어디까지"에 해당하는 정보입니다.
예시 1: "bytes=2000-5000"
2,000바이트 부터 5,000바이트까지 나눈 구간의 데이터를 요청하는 것입니다.
예시 2: "bytes=5000-"
5,000바이트부터 파일의 끝까지 나눈 구간의 데이터를 요청하는 것입니다.
range가 request 헤더에 있는지 여부와, 있다면 어떤 형태로 있는지 이를 적절하게 케이스를 나누어 처리해야 합니다. Content-Range에 대한 추가적인 내용은 아래 링크를 참고하실 수 있습니다.
https://httpwg.org/specs/rfc7233.html#status.206
router.get( ) 안의 코드를 다음과 같이 바꿔줍니다.
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const range = req.headers.range;
console.log(range)
if(!range){
const header = { 'Content-Type':'audio/mpeg' }
res.writeHead(200, header);
res.end()
}
else{
// ranage헤더 파싱
const parts = range.replace(/bytes=/, "").split("-");
// 재생 구간 설정
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1
const header = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Type' : 'audio/mpeg',
'Content-Length': fileSize-1,
}
res.writeHead(200, header);
const readStream = fs.createReadStream(filepath,{start,end} )
readStream.pipe(res);
}
})
if문은 range가 없을 때 실행됩니다. 접근 가능 여부와 오디오 파일을 전송해줄 것이란 정보만 전달해주면 되기 때문에 위와 같이 간단한 헤더 정보만 전송해주어도 됩니다.
else문은 range가 있을 때 실행됩니다. "bytes=<시작 점>-<끝 점>" 이란 string에서 "bytes="이란 문자를 제거하고 -를 기준으로 split하여 결국 parts[0]에는 시작 점이, parts[1]에는 빈 string 또는 끝 점이 오게됩니다.
이 정보들을 이용해서 "Content-Range" 헤더를 추가합니다.
Content-Range는 "bytes <시작 점>-<끝 점>/<파일 전체 크기>" 와 같은 포맷을 사용합니다.
이제 다시 재생을 하면 일반적인 경우 잘 되는 것을 확인해보실 수 있습니다. 하지만 기능적으로 봤을 때, 몇 가지 헤더가 추가되었을 뿐 한 번의 요청에 전체 파일을 다 보내준다는 점에서 달라진 것이 없습니다. <끝 점> 의 정보를 파일의 끝으로 지정해놓았기 때문이죠.
또한 "일반적인 경우"라고 한 이유는 아마 <끝 점>의 정보를 입력했을 경우 그 뒤의 데이터를 요청하지 않아 재생이 멈추는 등의 오류가 발생할 수 있습니다. 그 이유를 알아보도록 하겠습니다.
3-4. 청크 사이즈 제한하기
음악 파일 같은 경우에는 일반적으로 파일의 크기가 크지 않기 때문에 전체 파일을 바로바로 전송해도 큰 문제가 없을지도 모릅니다. 하지만 동영상 파일을 전송한다거나 하면 발생하는 트래픽은 GB단위 이상으로 크게 뛸 수 있기 때문에 전송할 수 있는 최대 사이즈를 제한할 필요가 있습니다. 미디어 파일의 전체를 다 보내주는 것이 아니라 일부만 보내주도록 수정해보겠습니다.
router.get( )을 아래와 같이 수정해주세요
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const range = req.headers.range;
console.log(range)
if(!range){
const header = { 'Content-Type':'audio/mpeg' }
res.writeHead(200, header);
res.end()
}
else{
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
// 위 코드에서 이 부분이 달라졌습니다.
const MAX_CHUNK_SIZE = 1000 * 1000
const _end = parts[1] ? parseInt(parts[1], 10) : fileSize-1
const end = Math.min(_end, start + MAX_CHUNK_SIZE - 1)
//////////////////////////////////////
const header = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Type' : 'audio/mpeg',
'Content-Length': fileSize-1,
}
res.writeHead(206, header);
const readStream = fs.createReadStream(filepath,{start,end} )
readStream.pipe(res);
}
})
내용은 간단합니다 end 변수의 값을 바로 fileSize-1로 지정하지 않고, start부터 MAX_CHUNK_SIZE 만큼 떨어진 값으로 지정하도록 하였습니다.
( 물론 fileSize-1 이 더 작은경우 이 값으로 합니다. )
그리고 res.writeHead 에서 200으로 지정했던 status값을 206으로 변경하였습니다.
206은 Partial Content를 의미하는 status로 데이터가 여럿으로 쪼개졌을 때 다음 데이터가 존재한다고 알려주는 역할을 합니다. 이 경우 위에서 추가했던 "Content-Range" 헤더가 필수적입니다. 자세한 내용은 아래 링크를 참고하실 수 있습니다.
https://httpwg.org/specs/rfc7233.html#header.accept-ranges
이제 다시 스트리밍 해보면
그 전과는 다르게 위와 같이 버퍼링하는 시간이 발생합니다.
이 때, console.log( range )로 출력한 값을 살펴보면
bytes=0-
bytes=1000000-
bytes=2000000-
계속 부분적인 데이터에 대한 요청은 오고있습니다. 다만 브라우저에서 이정도로는 안정적으로 음악을 재생할 수 없다고 판단하고 추가적인 데이터를 계속 요청하는 것으로 보입니다.
약간의 시간이 더 흐르면 위와 같이 정상적으로 파일 재생이 가능해집니다.
4. 동영상 스트리밍
먼저 말씀드릴 것은 웹 표준으로 인코딩된 "mp4파일"을 재생하는 것을 기본으로 합니다. 다른 코덱으로 인코딩된 파일들은 추가적인 라이브러리나 동영상을 컨버팅해서 올리는 등의 추가적인 작업을 필요로합니다.
위의 음악 스트리밍 예제를 모두 따라하셨다면 아주 간단하게 바꿀 수 있습니다.
헤더에 적었던 "audio/mpeg"라는 값을 "video/mp4"라는 값으로 변경만 해주시면 됩니다.
아 그리고 동영상은 음악파일과 다르게 용량이 크니 MAX_CHUNK_SIZE도 변경해주시면 좋습니다.
router.get( )의 모습은 아래와 같습니다.
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const range = req.headers.range;
console.log(range)
if(!range){
const header = { 'Content-Type':'video/mp4' }
res.writeHead(200, header);
res.end()
}
else{
const MAX_CHUNK_SIZE = 1000 * 1000 * 50
// ranage헤더 파싱
const parts = range.replace(/bytes=/, "").split("-");
// 재생 구간 설정
const start = parseInt(parts[0], 10);
const _end = parts[1] ? parseInt(parts[1], 10) : fileSize-1
const end = Math.min(_end, start + MAX_CHUNK_SIZE - 1)
const header = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Type' : 'video/mp4',
'Content-Length': fileSize-1,
}
res.writeHead(206, header);
const readStream = fs.createReadStream(filepath,{start,end} )
readStream.pipe(res);
}
})
5. 최종 정리
index.js
const mediaeRouter = require('./mediaRouter.js')
const express = require('express')
const app = express()
const PORT = 3000
app.use('/media',mediaRouter)
app.get('/upload',function(req, res){
const body = `
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<form action="/media" enctype="multipart/form-data" method="post">
<input type="file" name="file1" multiple="multiple">
<input type="submit" value="Upload file" />
</form>
</body>
</html>
`
res.writeHead(200, {"Content-Type": "text/html"});
res.write(body);
res.end();
})
app.listen(PORT, function(){
console.log(`running media server on ${PORT}`)
})
mediaRouter.js ( 음악 파일 )
const router = require('express').Router()
const multiparty = require('multiparty')
const url = require('url')
const fs = require('fs')
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const range = req.headers.range;
console.log(range)
if(!range){
const header = { 'Content-Type':'video/mp4' }
res.writeHead(200, header);
res.end()
}
else{
const MAX_CHUNK_SIZE = 1000 * 1000 * 50
// ranage헤더 파싱
const parts = range.replace(/bytes=/, "").split("-");
// 재생 구간 설정
const start = parseInt(parts[0], 10);
const _end = parts[1] ? parseInt(parts[1], 10) : fileSize-1
const end = Math.min(_end, start + MAX_CHUNK_SIZE - 1)
const header = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Type' : 'video/mp4',
'Content-Length': fileSize-1,
}
res.writeHead(206, header);
const readStream = fs.createReadStream(filepath,{start,end} )
readStream.pipe(res);
}
})
router.post('/', (req,res)=>{
const form = new multiparty.Form()
form.on('error', err => res.status(500).end())
form.on('part', part => {
// file이 아닌 경우 skip
if(!part.filename)
return part.resume()
const filestream = fs.createWriteStream(`./resource/${part.filename}`)
part.pipe(filestream)
})
form.on('close', ()=>res.end())
form.parse(req)
})
module.exports = router
mediaRouter.js ( mp4 동영상 파일 )
const router = require('express').Router()
const multiparty = require('multiparty')
const url = require('url')
const fs = require('fs')
router.get('/*',(req,res)=>{
const {pathname} = url.parse(req.url, true)
const filepath = `./resource${pathname}`
const stat = fs.statSync(filepath)
const fileSize = stat.size
const range = req.headers.range;
console.log(range)
if(!range){
const header = { 'Content-Type':'video/mp4' }
res.writeHead(200, header);
res.end()
}
else{
const MAX_CHUNK_SIZE = 1000 * 1000 * 50
// ranage헤더 파싱
const parts = range.replace(/bytes=/, "").split("-");
// 재생 구간 설정
const start = parseInt(parts[0], 10);
const _end = parts[1] ? parseInt(parts[1], 10) : fileSize-1
const end = Math.min(_end, start + MAX_CHUNK_SIZE - 1)
const header = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Type' : 'video/mp4',
'Content-Length': fileSize-1,
}
res.writeHead(206, header);
const readStream = fs.createReadStream(filepath,{start,end} )
readStream.pipe(res);
}
})
router.post('/', (req,res)=>{
const form = new multiparty.Form()
form.on('error', err => res.status(500).end())
form.on('part', part => {
// file이 아닌 경우 skip
if(!part.filename)
return part.resume()
const filestream = fs.createWriteStream(`./resource/${part.filename}`)
part.pipe(filestream)
})
form.on('close', ()=>res.end())
form.parse(req)
})
module.exports = router
'웹서버' 카테고리의 다른 글
Node js와 AWS로 이미지 서버 만들기 (3) 이미지 리사이즈, 썸네일 생성 (0) | 2021.08.28 |
---|---|
Node js와 AWS로 이미지 서버 만들기 (2) 이미지 로드와 업로드 (0) | 2021.08.28 |
Node js와 AWS로 이미지 서버 만들기 (1) AWS 버킷 세팅하기 (0) | 2021.08.19 |