출처 : https://betterprogramming.pub/a-memory-friendly-way-of-reading-files-in-node-js-a45ad0cc7bb6

Node.js 에서 메모리를 효과적으로 사용해 파일을 읽는 방법

파일을 읽는 대표적인 방법 세가지

  • fs.readFile, fs.readFileSync 빌트인 함수 사용
  • fs.createReadSteram 스트림을 활용해 읽어오기
  • fs.read 빌트인 함수와 shared buffer 를 활용해 file read 함수를 만들어 읽어오기

1. 배경

세 가지 파일 read 코드를 사용해 1GB 의 파일을 10MB chunk 로 읽어 처리하는 node.js 어플리케이션을 실행한다. 10MB 크기는 utf-8 인코딩 기준 10,000,000 문자를 저장할 수 있다. 이는 보통 로그, csv 파일의 한 줄의 크기 보다 훨씬 크다. 아래는 각 방법으로 파일을 읽었을때 어플리케이션에서 사용하는 메모리의 크기이다.

Screenshot_20210412-224819_Samsung Internet

readFileSync 함수를 활용하면 파일 사이즈 이상의 메모리를 사용하고 createReadStream 을 활용하면 20~90MB 메모리를 사용한다.
fs.read 함수와 shared buffer 를 활용한 file read 함수를 사용할 때 가장 효과적인데, 10~20MB 정도의 메모리를 사용한다.

2. readFileSync

const CHUNK_SIZE = 10000000;
const data = fs.readFileSync('./file');
for (let bytesRead = 0; bytesRead < data.length; bytesRead = bytesRead + CHUNK_SIZE) {
  // do something with data
}

파일에서 읽은 데이터는 ‘data’ 변수에 저장되기 때문에 메모리에서 1GB 이상을 사용하는 것이 당연하다.
큰 파일을 다룰때는 불리하지만 파일의 모든 부분에 접근할 수 있기 때문에 작은 파일을 다룰 때는 유리하다.

3. createReadStream

const CHUNK_SIZE=10000000;
async function start() {
  const  stream = fs.createReadStream('./file', { highWaterMark: CHUNK_SIZE });
  for await(const data of stream) {
    // do someting with data
  }
}

파일 data 대신 stream 을 리턴한다. stream 은 실제 파일 data 에 접근하기 위해 추가적인 for-await 반복 작업이 필요하다.
highWaterMark 옵션은 파일을 매번 읽어올 때 옵션으로 지정된 만큼만 읽어 오도록 설정한다.
이미 읽은 chunk 는 GC 에서 정리하기 전까지 메모리에 남아있기 때문에 최대 90MB 정도를 메모리로 사용한다.

4. Read Shared buffer

이 어플리케이션은 세 가지로 구성되어 있다.

  • Promise 로 감싼 fs.read
  • async generator
  • data 를 처리하기 위한 메인 loop

모든 함수에서 새 버퍼를 생성하는 대신 shared buffer 를 refence 로 전달한다. 이를 통해 메모리 사용량을 줄일 수 있다.

function readBytes(fd, sharedBuffer) {
  return new Promise((resolve, reject) => {
    fs.read(
      fd,  // file descriptor
      sharedBuffer,  // data 를 쓸 버퍼
      0,   // 버퍼에 data 를 쓸 시작점
      shardBuffer.length,  // 파일을 읽을 크기, CHUNK_SIZE 만큼 읽을 것
      null,  // 파일을 읽을 시작점. null 로 설정하면 첫 번째 byte 부터 시작해 자동으로 위치를 update 하며 읽는다.
      err => {
        if (err) return reject(err);
        resolve();
      }
  });
}

async function* generateChunks(filePath, size) {
  const shadBuffer = Buffer.alloc(size);
  const stats = fs.statSync(filePath);
  const fd = fs.openSync(filePath);
  let bytesRead = 0;  // how many bytes were read
  let end = size;
  
  for (let i = 0; i < Math.ceil(stats.size / size); i++) {
    await readBytes(fd, shaerdBuffer);
    bytesRead = (i + 1) * size;
    if (bytesRead > stats.size) {
      // When we reach the end of file,
      // we have to calculate how many bytes were actually read
      end = size - (bytesRead - stats.size);
    }
    
    yield sharedBuffer.slice(0, end);
  }
}

const CHUNK_SIZE = 10000000;
async function main() {
  for await(const chunk of generateChunks('./file', CHUNK_SIZE)) {
    // do something with data
  }
}