본문 바로가기

개발

Node.js 용량이 큰 데이터 스트리밍으로 처리하기

Node.js는 비동기 I/O를 지원하기 때문에, 웹 서버 개발에 적합합니다. Node.js는 하나의 스레드에서 동작하지만, I/O 작업을 이벤트 루프와 콜백 함수를 통해 비동기적으로 처리하기 때문에, CPU 연산이 많지 않은 작업에 높은 성능을 보입니다. I/O 작업이란 파일 읽기/쓰기, 네트워크 통신, 데이터베이스 접근 등으로, 데이터를 입력하고 출력하는 작업을 말합니다.

 

대용량 데이터 처리는 I/O 작업 중 하나로, 많은 양의 데이터를 읽고 쓰는 작업을 말합니다. 예를 들어, 로그 파일 분석, 비디오 인코딩/디코딩, 이미지 변환 등이 있습니다. 대용량 데이터 처리를 할 때는, 메모리에 모든 데이터를 한꺼번에 올리면 메모리 부족이나 성능 저하가 발생할 수 있습니다. 따라서, 데이터를 작은 조각으로 나누어 순차적으로 처리하는 것이 좋습니다. 이렇게 데이터를 작은 조각으로 나누어 처리하는 방식을 스트리밍이라고 합니다.

 

스트리밍은 Node.js에서 기본적으로 제공하는 stream 모듈을 통해 구현할 수 있습니다. stream 모듈은 다음과 같은 네 가지 종류의 스트림을 제공합니다.

- Readable: 읽기 가능한 스트림으로, 데이터 소스로부터 데이터를 읽어옵니다. 예를 들어, 파일 읽기, HTTP 요청 등이 있습니다.

- Writable: 쓰기 가능한 스트림으로, 데이터를 목적지에 씁니다. 예를 들어, 파일 쓰기, HTTP 응답 등이 있습니다.

- Duplex: 양방향 스트림으로, 읽기와 쓰기가 모두 가능합니다. 예를 들어, TCP 소켓 등이 있습니다.

- Transform: 변환 스트림으로, 읽기와 쓰기가 모두 가능하며, 데이터를 읽을 때마다 변환 로직을 적용합니다. 예를 들어, 압축/해제, 암호화/복호화 등이 있습니다.

 

스트림을 사용하면 다음과 같은 이점이 있습니다.

- 메모리 효율성: 데이터를 작은 조각으로 나누어 처리하기 때문에, 메모리에 많은 양의 데이터를 올리지 않아도 됩니다.

- 시간 효율성: 데이터를 처음부터 끝까지 다 읽고 쓰지 않고, 필요한 만큼만 읽고 쓰기 때문에, 시간을 절약할 수 있습니다.

- 파이프라인: 여러 개의 스트림을 연결하여 파이프라인을 구성할 수 있습니다. 파이프라인은 하나의 스트림의 출력을 다른 스트림의 입력으로 전달하는 방식으로, 데이터를 여러 단계에 걸쳐 처리할 수 있습니다.

 

다음은 Node.js에서 스트림을 사용하여 대용량 데이터를 처리하는 예시 코드입니다. 이 코드는 fs 모듈을 사용하여 로컬 파일을 읽고 쓰는 작업을 수행합니다. 파일을 읽는 스트림과 파일을 쓰는 스트림을 생성하고, 두 스트림을 파이프로 연결하여, 파일의 내용을 복사하는 작업을 합니다.

// fs 모듈을 임포트합니다.
const fs = require('fs');

// 읽기 스트림을 만듭니다. input.txt 파일의 데이터를 읽습니다.
const inputStream = fs.createReadStream('input.txt');

// 쓰기 스트림을 만듭니다. output.txt 파일에 데이터를 씁니다.
const outputStream = fs.createWriteStream('output.txt');

// 읽기 스트림과 쓰기 스트림을 파이프로 연결합니다.
inputStream.pipe(outputStream);

// 작업이 끝나면 콘솔에 메시지를 출력합니다.
outputStream.on('finish', () => {
  console.log('파일 복사 완료');
});

아래는 스트림 문법으로 간단하게 비디오 스트리밍 서버를 만드는 예제입니다.

// http 모듈을 불러옵니다.
const http = require('http');

// fs 모듈을 불러옵니다.
const fs = require('fs');

// path 모듈을 불러옵니다.
const path = require('path');

// 비디오 파일의 경로를 지정합니다.
const videoPath = path.join(__dirname, 'video.mp4');

// 서버를 생성합니다.
const server = http.createServer((req, res) => {
  // 비디오 파일의 정보를 가져옵니다.
  const stat = fs.statSync(videoPath);

  // 비디오 파일의 크기를 구합니다.
  const fileSize = stat.size;

  // 요청 헤더에서 range 값을 가져옵니다.
  const range = req.headers.range;

  // range 값이 있으면
  if (range) {
    // 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 readStream = fs.createReadStream(videoPath, { start, end });

    // 응답 헤더를 설정합니다.
    res.writeHead(206, {
      'Content-Range': `bytes ${start}-${end}/${fileSize}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': chunkSize,
      'Content-Type': 'video/mp4',
    });

    // 읽기 스트림과 쓰기 스트림을 파이프로 연결합니다.
    readStream.pipe(res);
  } else {
    // range 값이 없으면

    // 응답 헤더를 설정합니다.
    res.writeHead(200, {
      'Content-Length': fileSize,
      'Content-Type': 'video/mp4',
    });

    // 읽기 스트림을 생성합니다. 비디오 파일의 전체 내용을 읽습니다.
    const readStream = fs.createReadStream(videoPath);

    // 읽기 스트림과 쓰기 스트림을 파이프로 연결합니다.
    readStream.pipe(res);
  }
});

// 서버를 3000번 포트에서 실행합니다.
server.listen(3000, () => {
  console.log('Server running at http://localhost:3000/');
});