Node.js 인터뷰 질문 66

질문: Node.js를 사용하여 스트리밍 서비스를 개발하는 방법과 최적화 전략에 대해 설명해주세요.

답변:

Node.js는 비동기 이벤트 기반 아키텍처와 스트림 API를 통해 효율적인 스트리밍 서비스 개발에 매우 적합합니다. 비디오, 오디오, 대용량 데이터 스트리밍 등 다양한 스트리밍 서비스를 구현하는 방법과 최적화 전략을 살펴보겠습니다.

1. 기본 스트리밍 개념

Node.js의 스트림은 데이터를 청크(chunk) 단위로 처리할 수 있게 해주는 추상 인터페이스입니다. 이를 통해 메모리 효율성을 높이고 실시간 데이터 처리가 가능합니다.

1.1 스트림 유형

Node.js에서 제공하는 4가지 기본 스트림 유형:

  1. Readable: 데이터를 읽을 수 있는 스트림 (예: fs.createReadStream())
  2. Writable: 데이터를 쓸 수 있는 스트림 (예: fs.createWriteStream())
  3. Duplex: 읽기와 쓰기가 모두 가능한 스트림 (예: net.Socket)
  4. 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를 사용한 스트리밍 서비스 개발에서 다음 사항이 중요합니다:

  1. 효율적인 스트림 처리: 내장 Stream API를 활용하여 메모리 사용을 최적화하고 백프레셔를 관리합니다.
  2. HTTP 범위 요청 지원: 미디어 재생 시 필요한 부분만 요청할 수 있게 해 네트워크 효율성을 높입니다.
  3. 적응형 비트레이트 스트리밍(ABR): HLS나 DASH와 같은 프로토콜을 구현하여 다양한 네트워크 환경에 적응합니다.
  4. 실시간 처리: WebSocket이나 Socket.IO를 활용하여 실시간 스트리밍을 구현합니다.
  5. 트랜스코딩: FFmpeg과 같은 도구를 통합하여 다양한 형식과 품질로 미디어를 변환합니다.
  6. 확장성 설계: 클러스터링, 캐싱, CDN 통합 등을 통해 대규모 트래픽을 처리합니다.
  7. 보안 구현: 토큰 기반 인증, URL 서명, 콘텐츠 암호화 등으로 콘텐츠를 보호합니다.

이러한 원칙과 기술을 적용하면 Node.js를 사용하여 효율적이고 확장 가능한 스트리밍 서비스를 구축할 수 있습니다.

results matching ""

    No results matching ""