Node.js 인터뷰 질문 66
질문: Node.js를 사용하여 스트리밍 서비스를 개발하는 방법과 최적화 전략에 대해 설명해주세요.
답변:
Node.js는 비동기 이벤트 기반 아키텍처와 스트림 API를 통해 효율적인 스트리밍 서비스 개발에 매우 적합합니다. 비디오, 오디오, 대용량 데이터 스트리밍 등 다양한 스트리밍 서비스를 구현하는 방법과 최적화 전략을 살펴보겠습니다.
1. 기본 스트리밍 개념
Node.js의 스트림은 데이터를 청크(chunk) 단위로 처리할 수 있게 해주는 추상 인터페이스입니다. 이를 통해 메모리 효율성을 높이고 실시간 데이터 처리가 가능합니다.
1.1 스트림 유형
Node.js에서 제공하는 4가지 기본 스트림 유형:
- Readable: 데이터를 읽을 수 있는 스트림 (예:
fs.createReadStream()
) - Writable: 데이터를 쓸 수 있는 스트림 (예:
fs.createWriteStream()
) - Duplex: 읽기와 쓰기가 모두 가능한 스트림 (예:
net.Socket
) - Transform: 데이터를 읽고 쓰는 과정에서 변환하는 스트림 (예:
zlib.createGzip()
)
const fs = require("fs");
// 읽기 스트림 생성
const readableStream = fs.createReadStream("video.mp4");
// 쓰기 스트림 생성
const writableStream = fs.createWriteStream("copy.mp4");
// 파이프를 사용하여 스트림 연결
readableStream.pipe(writableStream);
// 이벤트 처리
readableStream.on("data", (chunk) => {
console.log(`${chunk.length} 바이트 수신`);
});
readableStream.on("end", () => {
console.log("파일 읽기 완료");
});
writableStream.on("finish", () => {
console.log("파일 쓰기 완료");
});
readableStream.on("error", (err) => {
console.error("읽기 오류:", err);
});
writableStream.on("error", (err) => {
console.error("쓰기 오류:", err);
});
2. HTTP를 통한 비디오 스트리밍
2.1 기본 비디오 스트리밍 서버
const http = require("http");
const fs = require("fs");
const path = require("path");
const server = http.createServer((req, res) => {
// 비디오 파일 경로
const videoPath = path.join(__dirname, "videos", "sample.mp4");
// 비디오 파일 정보 가져오기
fs.stat(videoPath, (err, stats) => {
if (err) {
console.error("파일 정보를 가져올 수 없음:", err);
res.statusCode = 404;
res.end("File not found");
return;
}
// HTTP 헤더 설정
res.writeHead(200, {
"Content-Type": "video/mp4",
"Content-Length": stats.size,
});
// 비디오 파일 스트리밍
const videoStream = fs.createReadStream(videoPath);
videoStream.pipe(res);
// 에러 처리
videoStream.on("error", (err) => {
console.error("스트림 오류:", err);
res.statusCode = 500;
res.end("Server Error");
});
});
});
server.listen(3000, () => {
console.log("비디오 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
2.2 범위 요청(Range Requests) 처리
대용량 미디어 파일을 효율적으로 스트리밍하려면 HTTP 범위 요청을 지원해야 합니다. 이를 통해 클라이언트는 파일의 일부분만 요청할 수 있습니다.
const http = require("http");
const fs = require("fs");
const path = require("path");
const server = http.createServer((req, res) => {
const videoPath = path.join(__dirname, "videos", "sample.mp4");
fs.stat(videoPath, (err, stats) => {
if (err) {
res.statusCode = 404;
res.end("File not found");
return;
}
const fileSize = stats.size;
const range = req.headers.range;
if (range) {
// 범위 파싱 (예: bytes=32324-55435)
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
// 스트림 생성
const stream = fs.createReadStream(videoPath, { start, end });
// 헤더 설정
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "video/mp4",
});
// 스트림 전송
stream.pipe(res);
stream.on("error", (err) => {
console.error("스트림 오류:", err);
res.statusCode = 500;
res.end("Server Error");
});
} else {
// 범위 요청이 없는 경우 전체 파일 전송
res.writeHead(200, {
"Content-Length": fileSize,
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
});
fs.createReadStream(videoPath).pipe(res);
}
});
});
server.listen(3000, () => {
console.log("고급 비디오 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
3. 오디오 스트리밍
3.1 오디오 파일 스트리밍
const http = require("http");
const fs = require("fs");
const path = require("path");
const server = http.createServer((req, res) => {
const audioPath = path.join(__dirname, "audio", "music.mp3");
fs.stat(audioPath, (err, stats) => {
if (err) {
res.statusCode = 404;
res.end("File not found");
return;
}
// 범위 요청 처리 (비디오 스트리밍과 유사)
const range = req.headers.range;
const fileSize = stats.size;
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(audioPath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "audio/mpeg",
});
stream.pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize,
"Content-Type": "audio/mpeg",
"Accept-Ranges": "bytes",
});
fs.createReadStream(audioPath).pipe(res);
}
});
});
server.listen(3000, () => {
console.log("오디오 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
4. 실시간 스트리밍
4.1 WebSocket을 사용한 실시간 스트리밍
const WebSocket = require("ws");
const fs = require("fs");
const path = require("path");
// WebSocket 서버 생성
const wss = new WebSocket.Server({ port: 8080 });
wss.on("connection", (ws) => {
console.log("클라이언트가 연결되었습니다");
// 실시간 스트리밍 데이터 소스 (예: 카메라, 마이크 등)
// 여기서는 예시로 파일 스트림 사용
const streamSource = fs.createReadStream(
path.join(__dirname, "live", "stream.webm")
);
streamSource.on("data", (chunk) => {
// 연결된 모든 클라이언트에게 데이터 전송
if (ws.readyState === WebSocket.OPEN) {
ws.send(chunk);
}
});
streamSource.on("end", () => {
console.log("스트림 종료");
ws.close();
});
streamSource.on("error", (err) => {
console.error("스트림 오류:", err);
ws.close();
});
// 클라이언트 연결 종료 처리
ws.on("close", () => {
console.log("클라이언트 연결이 종료되었습니다");
streamSource.destroy(); // 스트림 정리
});
});
console.log("WebSocket 스트리밍 서버가 포트 8080에서 실행 중입니다");
4.2 Socket.IO를 사용한 실시간 스트리밍
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const fs = require("fs");
const path = require("path");
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static("public"));
io.on("connection", (socket) => {
console.log("클라이언트가 연결되었습니다:", socket.id);
// 스트림 소스 설정
const videoStream = fs.createReadStream(
path.join(__dirname, "live", "stream.webm"),
{
highWaterMark: 1024 * 64, // 64KB 청크
}
);
// 데이터 청크를 클라이언트에 전송
videoStream.on("data", (chunk) => {
socket.emit("stream-data", chunk.toString("base64"));
});
videoStream.on("end", () => {
socket.emit("stream-end");
});
// 클라이언트 연결 종료
socket.on("disconnect", () => {
console.log("클라이언트 연결이 종료되었습니다:", socket.id);
videoStream.destroy();
});
});
server.listen(3000, () => {
console.log("Socket.IO 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
5. 미디어 트랜스코딩
FFmpeg과 같은 도구를 사용하여 실시간 트랜스코딩 구현:
const express = require("express");
const http = require("http");
const fs = require("fs");
const { spawn } = require("child_process");
const path = require("path");
const app = express();
const server = http.createServer(app);
app.get("/stream/:quality", (req, res) => {
const quality = req.params.quality;
const videoPath = path.join(__dirname, "videos", "input.mp4");
// 품질에 따른 FFmpeg 옵션 설정
let ffmpegArgs = ["-i", videoPath, "-f", "mp4"];
switch (quality) {
case "low":
ffmpegArgs = [...ffmpegArgs, "-b:v", "500k", "-vf", "scale=640:360"];
break;
case "medium":
ffmpegArgs = [...ffmpegArgs, "-b:v", "1000k", "-vf", "scale=1280:720"];
break;
case "high":
ffmpegArgs = [...ffmpegArgs, "-b:v", "2000k", "-vf", "scale=1920:1080"];
break;
default:
ffmpegArgs = [...ffmpegArgs, "-b:v", "1000k", "-vf", "scale=1280:720"];
}
ffmpegArgs.push("pipe:1"); // 출력을 stdout으로 전송
// FFmpeg 프로세스 시작
const ffmpeg = spawn("ffmpeg", ffmpegArgs);
// HTTP 헤더 설정
res.writeHead(200, {
"Content-Type": "video/mp4",
});
// FFmpeg 출력을 응답으로 파이프
ffmpeg.stdout.pipe(res);
// 에러 처리
ffmpeg.stderr.on("data", (data) => {
console.log(`ffmpeg: ${data}`);
});
// 클라이언트가 연결을 종료하면 FFmpeg 프로세스 종료
req.on("close", () => {
ffmpeg.kill();
});
});
server.listen(3000, () => {
console.log("트랜스코딩 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
6. 적응형 비트레이트 스트리밍 (ABR)
HLS(HTTP Live Streaming) 구현:
const express = require("express");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const app = express();
// 정적 파일 제공
app.use("/hls", express.static(path.join(__dirname, "hls")));
// HLS 스트림 생성 함수
function createHLSStream(inputFile, outputDir) {
// 출력 디렉토리 생성
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// FFmpeg 명령어 설정
const ffmpeg = spawn("ffmpeg", [
"-i",
inputFile,
"-profile:v",
"baseline",
"-level",
"3.0",
"-start_number",
"0",
"-hls_time",
"10",
"-hls_list_size",
"0",
"-f",
"hls",
path.join(outputDir, "playlist.m3u8"),
]);
ffmpeg.stderr.on("data", (data) => {
console.log(`ffmpeg: ${data}`);
});
ffmpeg.on("close", (code) => {
console.log(`FFmpeg 프로세스 종료, 코드: ${code}`);
});
return ffmpeg;
}
// HLS 스트림 초기화
app.get("/init-stream", (req, res) => {
const inputFile = path.join(__dirname, "videos", "input.mp4");
const outputDir = path.join(__dirname, "hls");
const ffmpegProcess = createHLSStream(inputFile, outputDir);
res.json({ success: true, message: "HLS 스트림 초기화 시작" });
});
// 서버 시작
app.listen(3000, () => {
console.log("HLS 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
7. 스트리밍 서비스 최적화 전략
7.1 캐싱 최적화
const express = require("express");
const NodeCache = require("node-cache");
const fs = require("fs");
const path = require("path");
const app = express();
const cache = new NodeCache({ stdTTL: 600 }); // 10분 TTL
app.get("/thumbnail/:videoId", (req, res) => {
const videoId = req.params.videoId;
const cacheKey = `thumbnail:${videoId}`;
// 캐시에서 썸네일 확인
const cachedThumbnail = cache.get(cacheKey);
if (cachedThumbnail) {
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Content-Length": cachedThumbnail.length,
"Cache-Control": "max-age=3600", // 브라우저 캐싱 지시
});
res.end(cachedThumbnail);
return;
}
// 캐시에 없으면 파일에서 읽기
const thumbnailPath = path.join(__dirname, "thumbnails", `${videoId}.jpg`);
fs.readFile(thumbnailPath, (err, data) => {
if (err) {
res.statusCode = 404;
res.end("Thumbnail not found");
return;
}
// 캐시에 저장
cache.set(cacheKey, data);
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Content-Length": data.length,
"Cache-Control": "max-age=3600",
});
res.end(data);
});
});
app.listen(3000, () => {
console.log("썸네일 서버가 포트 3000에서 실행 중입니다");
});
7.2 CDN 통합
const express = require("express");
const app = express();
// CDN 설정
const CDN_URL = process.env.CDN_URL || "https://cdn.example.com";
app.get("/stream/:videoId", (req, res) => {
const videoId = req.params.videoId;
// 인증 및 권한 확인
if (!isAuthorized(req)) {
res.status(403).json({ error: "권한이 없습니다" });
return;
}
// 서명된 URL 생성
const expiryTime = Math.floor(Date.now() / 1000) + 3600; // 1시간 유효
const signature = generateSignature(videoId, expiryTime);
const signedUrl = `${CDN_URL}/videos/${videoId}/playlist.m3u8?expires=${expiryTime}&signature=${signature}`;
// 클라이언트를 CDN으로 리디렉션
res.redirect(signedUrl);
});
function isAuthorized(req) {
// 사용자 인증 및 권한 확인 로직
return true; // 예시
}
function generateSignature(videoId, expires) {
// 서명 생성 로직 (예: HMAC)
return "signature"; // 예시
}
app.listen(3000, () => {
console.log("CDN 통합 서버가 포트 3000에서 실행 중입니다");
});
7.3 스케일링 및 로드 밸런싱
PM2 클러스터 모드를 사용한 스케일링:
// ecosystem.config.js
module.exports = {
apps: [
{
name: "streaming-server",
script: "server.js",
instances: "max", // CPU 코어 수만큼 인스턴스 생성
exec_mode: "cluster",
autorestart: true,
watch: false,
max_memory_restart: "1G",
env: {
NODE_ENV: "production",
PORT: 3000,
},
},
],
};
7.4 메모리 및 CPU 최적화
// 스트림 메모리 사용량 최적화
const fs = require("fs");
// highWaterMark 설정으로 메모리 사용량 제어
const readableStream = fs.createReadStream("large-video.mp4", {
highWaterMark: 1024 * 64, // 64KB 청크
});
// 백프레셔 처리
const writableStream = fs.createWriteStream("output.mp4", {
highWaterMark: 1024 * 64,
});
readableStream.on("data", (chunk) => {
// 쓰기 스트림이 처리할 수 있는지 확인
const canContinue = writableStream.write(chunk);
if (!canContinue) {
// 쓰기 스트림이 병목될 경우 일시 중지
readableStream.pause();
console.log("스트림 일시 중지: 백프레셔 처리");
// 쓰기 스트림이 더 처리할 준비가 되면 재개
writableStream.once("drain", () => {
console.log("스트림 재개: 드레인 이벤트");
readableStream.resume();
});
}
});
// 종료 및 오류 처리
readableStream.on("end", () => {
writableStream.end();
});
readableStream.on("error", (err) => {
console.error("읽기 오류:", err);
writableStream.end();
});
writableStream.on("error", (err) => {
console.error("쓰기 오류:", err);
readableStream.destroy();
});
8. 보안 및 콘텐츠 보호
8.1 토큰 기반 인증
const express = require("express");
const jwt = require("jsonwebtoken");
const fs = require("fs");
const path = require("path");
const app = express();
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
// 인증 미들웨어
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "인증 토큰이 필요합니다" });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "유효하지 않은 토큰입니다" });
}
req.user = user;
next();
});
}
// 비디오 스트리밍 엔드포인트
app.get("/stream/:videoId", authenticateToken, (req, res) => {
const videoId = req.params.videoId;
const videoPath = path.join(__dirname, "videos", `${videoId}.mp4`);
// 사용자 권한 확인
if (!req.user.allowedVideos.includes(videoId)) {
return res
.status(403)
.json({ error: "이 비디오에 접근할 권한이 없습니다" });
}
fs.stat(videoPath, (err, stats) => {
if (err) {
res.status(404).json({ error: "비디오를 찾을 수 없습니다" });
return;
}
// 범위 요청 처리
const range = req.headers.range;
const fileSize = stats.size;
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "video/mp4",
});
fs.createReadStream(videoPath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize,
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
});
fs.createReadStream(videoPath).pipe(res);
}
});
});
// 토큰 발급 엔드포인트
app.post("/auth/token", express.json(), (req, res) => {
// 사용자 인증 (예: 사용자 이름과 비밀번호 확인)
const { username, password } = req.body;
// 사용자 확인 로직
if (username === "user" && password === "password") {
// 사용자 정보와 권한 설정
const user = {
id: 123,
username: username,
allowedVideos: ["video1", "video2"],
};
// JWT 토큰 생성
const token = jwt.sign(user, JWT_SECRET, { expiresIn: "1h" });
res.json({ token });
} else {
res.status(401).json({ error: "인증 실패" });
}
});
app.listen(3000, () => {
console.log("보안 스트리밍 서버가 포트 3000에서 실행 중입니다");
});
요약
Node.js를 사용한 스트리밍 서비스 개발에서 다음 사항이 중요합니다:
- 효율적인 스트림 처리: 내장 Stream API를 활용하여 메모리 사용을 최적화하고 백프레셔를 관리합니다.
- HTTP 범위 요청 지원: 미디어 재생 시 필요한 부분만 요청할 수 있게 해 네트워크 효율성을 높입니다.
- 적응형 비트레이트 스트리밍(ABR): HLS나 DASH와 같은 프로토콜을 구현하여 다양한 네트워크 환경에 적응합니다.
- 실시간 처리: WebSocket이나 Socket.IO를 활용하여 실시간 스트리밍을 구현합니다.
- 트랜스코딩: FFmpeg과 같은 도구를 통합하여 다양한 형식과 품질로 미디어를 변환합니다.
- 확장성 설계: 클러스터링, 캐싱, CDN 통합 등을 통해 대규모 트래픽을 처리합니다.
- 보안 구현: 토큰 기반 인증, URL 서명, 콘텐츠 암호화 등으로 콘텐츠를 보호합니다.
이러한 원칙과 기술을 적용하면 Node.js를 사용하여 효율적이고 확장 가능한 스트리밍 서비스를 구축할 수 있습니다.