MOVE vs COPY Performance

mochang2·2023년 12월 19일
0

RETROSPECT

목록 보기
3/4

0. 공부하게 된 이유

Node.js 환경에서 임시 폴더를 만들어 파일을 다운로드하고, 그 임시 폴더 안에 있는 파일들을 다시 실제 필요한 폴더로 이동(같은 파티션 내에서)해야 되는 일이 필요했다.
기존에는 이 부분이 copy 방식으로 구현되어 있었다.
한 팀원 분이 move가 copy보다 더 빠르다는 글을 봤는데 move로 변경하는 게 어떻냐고 제안하셨고, 그 작업을 내가 맡아 진행했다.

https://www.vintageisthenewold.com/game-pedia/which-is-faster-cut-or-copy
바로 구글링을 해보니 위와 같이 move와 copy에 대해 비교한 글들이 많아서 한 번 진행해보기로 했다.

1. performance 확인

코드를 3초만에 짜준 chatGPT님 감사합니다.

먼저 정말 copy보다 move가 빠른지 간단히 테스트를 위해 80MB 정도의 임시 파일들(디렉터리 X)을 src 디렉터리에서 dst 디렉터리로 이동시키는 코드로 실험해봤다.

const fs = require('fs/promises');
const path = require('path');

async function moveFiles(sourceFolder, destinationFolder) {
  console.time('move');
  try {
    const files = await fs.readdir(sourceFolder);

    for (const file of files) {
      const sourceFilePath = path.join(sourceFolder, file);
      const destinationFilePath = path.join(destinationFolder, file);

      await fs.rename(sourceFilePath, destinationFilePath);
      console.log(`${file}을(를) 성공적으로 이동했습니다.`);
    }
  } catch (err) {
    console.error('오류 발생:', err);
  }
  console.timeEnd('move'); // 90ms 내외
}

async function copyFiles(sourceFolder, destinationFolder) {
  console.time('copy');
  try {
    const files = await fs.readdir(sourceFolder);

    for (const file of files) {
      const sourceFilePath = path.join(sourceFolder, file);
      const destinationFilePath = path.join(destinationFolder, file);

      await fs.copyFile(sourceFilePath, destinationFilePath);
      console.log(`${file}을(를) 성공적으로 복사했습니다.`);
    }
  } catch (err) {
    console.error('오류 발생:', err);
  }
  console.timeEnd('copy'); // 50ms 내외
}

const sourceFolder = './src';
const destinationFolder = './dst';

비록 수십 ms지만 파일 용량이 커지면 유의미한 차이가 보일 것 같으니, 적용해볼 가치가 있다고 판단했다.

2. 코드 변경(feat. async)

이 글의 제목은 move vs copy이지만 변경하면서 같이 개선했던 부분들도 기록한다.

step1

기존 코드가 callback 방식으로 되어 있었어서 async 키워드를 추가하는 것보다 fs 모듈의 sync 메서드를 사용하는 것이 (특히 아래 함수를 사용하는 쪽)변경사항이 적을 것 같았다.

const path = require("path");
const fs = require("fs");

const copyRecursively = (src, dest) => {
	const entries = fs.readdirSync(src, { withFileTypes: true });

	fs.mkdirSync(dest, { recursive: true });

	for (const entry of entries) {
		const srcPath = path.join(src, entry.name);
		const destPath = path.join(dest, entry.name);

		if (entry.isDirectory()) {
			copyRecursively(srcPath, destPath);
		} else {
			fs.copyFileSync(srcPath, destPath);
		}
	}
}

Promise를 사용하는 fs 모듈들과 똑같이 동작할 거라 기대(내부적으로 Promise를 감싼 형태일거라 기대했다)하고 사용했지만 아니었다.
용량이 큰 파일에 대해 복사할 때 어플리케이션이 "응답 없음" 상태가 될 때가 있었다.

이는 Node.js가 백그라운드 스레드풀을 이용하지 않으면 기본적으로 단일 스레드로 동작하기 때문이다.
파일 복사는 CPU 바운드 작업(주로 CPU 성능에 의해 제한되는 작업. 작업의 처리 속도가 주로 CPU의 성능에 의존함)으로, CPU 연산 중에 이벤트 루프가 블로킹되어 다른 이벤트나 요청을 처리하지 못하게 만들어 어플리케이션이 멈춘 것이다.

step2

이후 Promise를 활용하여 copy를 진행하도록 수정했다.

const path = require("path");
const fs = require("fs/promises");

const copyRecursively = async (src, dest) => {
	const entries = await fs.readdir(src, { withFileTypes: true });

	await fs.mkdir(dest, { recursive: true });

	for (const entry of entries) {
		const srcPath = path.join(src, entry.name);
		const destPath = path.join(dest, entry.name);

		if (entry.isDirectory()) {
			await copyRecursively(srcPath, destPath);
		} else {
			await fs.copyFile(srcPath, destPath);
		}
	}
}

step3

팀원분의 제안대로 copy를 move로 변경했다.

const path = require("path");
const fs = require("fs/promises");

const copyRecursively = async (src, dest) => {
	const entries = await fs.readdir(src, { withFileTypes: true });

	await fs.mkdir(dest, { recursive: true });

	for (const entry of entries) {
		const srcPath = path.join(src, entry.name);
		const destPath = path.join(dest, entry.name);

		if (entry.isDirectory()) {
			await copyRecursively(srcPath, destPath);
		} else {
			await fs.rename(srcPath, destPath); // 여기만 변경
		}
	}
}

반복문 중간에 await이 있는 것도 개선의 여지가 있었다.
await을 만나면 그 다음 루프까지 해당 함수의 결과를 기다려야 하기 때문이다.
waterfall 형식으로 처리되던 비동기 함수를 Promise.all을 이용해 병렬적인 처리로 바꿨다.

step4

위와 같은 step을 거쳐 최종적으로 작성한 코드는 아래와 같다.

const path = require("path");
const fs = require("fs/promises");

const moveRecursively = async (src, dest) => {
    const entries = await fs.readdir(src, { withFileTypes: true });

    await fs.mkdir(dest, { recursive: true });

    const movePromises = [];

    for (const entry of entries) {
        const srcPath = path.join(src, entry.name);
        const destPath = path.join(dest, entry.name);

        if (entry.isDirectory()) {
            movePromises.push(moveRecursively(srcPath, destPath));
        } else {
            movePromises.push(fs.rename(srcPath, destPath));
        }
    });

    await Promise.all(movePromises);
};

예전에 for ... of 보다 es6에 생긴 forEach와 같은 메서드가 더 빠르다는 글을 봤던 적이 있다.
하지만 막상 실험해보니 for ... of가 더 빨랐다(Node.js 버전, OS, 어쩌면 새로운 array를 만드는 것이 아니라서? 등의 요소가 영향을 끼쳤을 수도 있다).
유의미한 차이가 있는 것도 아니었고, 테스트마다 조금씩 결과가 달라져서 굳이 forEach로 바꾸진 않았다.

2. 결과

  • OS: Windows 10 x64
  • RAM: 32GB
  • Windows 실행을 위한 필수 프로그램을 제외한 모든 응용 프로그램 종료한 상태
  • 파일 크기 1.9GB

각각 50번씩 실행해 평균을 냈더니 move는 2.017s, copy는 12.3552s만큼의 시간이 걸렸다.
(80MB 짜리 파일로 실험했을 때보다 더 큰 차이가 발생했다)
대략 6배 정도의 성능이 향상됐다!!!

3. performance 차이가 나는 이유

생각해보면 단순한 이유였다(그 단순한 것을 왜 개발할 때는 생각해내지 못했는지...).
같은 파티션 내에서 move는 단순히 파일의 이름을 변경하는 작업에 불과하다.
하지만 copy는 복사할 파일의 바이너리 데이터를 읽고, 사용 중이지 않는 저장 공간(디스크)에 해당 데이터를 쓰는 작업이다.
차이가 날 수 밖에...

cf) 다른 파티션 간에 move를 한다면?
이때는 move 수행이 단순히 파일의 이름을 변경하는 작업이 아니다.
cut, 정확히 말하자면 copy 후 기존 파티션에서 delete하는 작업을 진행하는 거라고 한다.
아마 다른 파티션 간에 작업이 필요했다면 결과가 달라졌을 수도 있겠다.

profile
개인 깃헙 repo(https://github.com/mochang2/development-diary)에서 이전함.

0개의 댓글