컨텐츠를 불러오는 중...
Serverless
에 대해서 공부해야겠다고 마음을 갖게 된 계기는 블로그의 성능을 개선하는 과정에서 겪었던 일에서부터 시작 되었습니다.Vercel
에 배포되어 있는 실제 사이트의 페이지 간 이동 속도가 대략 1~3초 사이의 시간이 걸린다는 문제가 있었습니다. 이 시간은 사용자가 지루해하고 사이트가 정상적으로 동작하지 않는다고 생각하기에 충분한 시간이기 때문에 해결해야 한다고 생각 했습니다.Worker
를 사용하여 기존의 I/O
작업의 효율성을 더 증진 시켜보자고 생각 했습니다. 저는 Next.js의 Server Action 함수를 이용해 .mdx
파일을 읽어오는 작업을 수행하고 있었습니다.Promise.all
을 사용하는 것이 반드시 효율적인지에 대한 의문이 들었습니다. 그래서 여러 경우의 코드를 작성하고 밴치마크를 해보았습니다.100000
개의 1000줄의 글이 작성되어 있는 텍스트 파일을 생성하는 작업을 수행합니다. 이 명령어를 작성하면서 공부 했던 내용도 간단하게 정리하고 넘어가겠습니다.yes $LONG_TEXT
명령어는 기본적으로 무한히 "y"를 출력하는 명령어인데, 여기에 인자를 주면 그 인자를 무한히 반복 출력합니다. 위의 명령어는 $LONG_TEXT
를 인자로 주었기 때문에 이 문자가 무한히 생성되는 작업을 수행합니다.head -n 1000
명령어는 앞서 실행했던 무한히 출력되는 결과물 중 상위의 결과 1000줄을 받고 나면 종료되는 필터입니다. 하지만 지금까지의 설명만 본다면 작업이 어떻게 수행되는지 의아할 수 있습니다. 무한히 출력되는 명령어를 어떻게 1000개만 잘라서 수행할 수 있는 이유는 |(pipeline)
명령어 덕분입니다.(|)
는 전체 데이터를 한꺼번에 모아서 처리하는 것이 아니라, 한 줄씩 데이터를 넘겨 받으며 스트리밍 방식으로 처리합니다. 위의 명령어는 A의 stdout 을 B의 stdin으로 전달하고 B의 stdout을 C의 stdin으로 병렬로 한줄씩 처리하게 됩니다. 이를 파일을 생성하는 명령어 적용하면 다음과 같은 과정을 처리하게 됩니다.yes
는 $LONG_TEXT를 stdout으로 계속 작성합니다.head
는 그 스트림에서 한 줄씩 읽습니다.head
가 1000줄을 읽고 나면 head
는 스스로 종료합니다.yes
는 Broken pipe 신호(SIGPIPE) 를 받아서 종료됩니다.head -n 1000
이 출력한 1000줄 (abc\nabc\n...
)은 tr -d '\n'
로 전달되고, 여기서 줄바꿈 문자를 모두 제거하여 하나의 긴 문자열로 만듭니다.cat
, grep
, head는 전체 데이터를 한꺼번에 메모리에 올리지 않고, 한 줄 또는 한 블록씩 스트리밍으로 처리를 합니다.Promise.all
을 사용하는 것이 반드시 효율적인가?Promise.all
메서드는 iterable
을 매개 변수로 받고 매개변수로 객체 내부의 Promise
의 상태들이 모두 fulfilled
상태를 만족하기 이전까지 iterable
로 주어진 Promise 내부의 비동기 함수를 수행합니다.reject
되었을 경우에는 어떻게 될까요? Promise.all은 한 가지 작업이라도 오류가 발생한다면 모든 작업을 무시합니다. 여기서 주의할 점은 이미 진행중인 Promise
들을 강제로 종료하는게 아니라 계속 실행되지만 결과를 무시한다는 점입니다. 이러한 부분은 Promise.allSettled과 대조되는 부분입니다.files.map()
은 각 파일에 대해 즉시 fs.readFile()
을 호출합니다. readFile 메서드는 async
내부에서 호출이 되기 때문에 비동기 작업으로 전환되어 Promise를 반환하며 이 작업은 libuv Thread Pool
에 등록이 됩니다. libuv는 I/O 바운드 작업을 위해 별도의 스레드 풀, 기본 4개의 스레드를 사용하여, 동시에 여러 작업을 실행합니다.Promise.all
또한 libuv Thread Pool
을 활용하여 병렬적으로 작업을 처리하기 때문에 Promise.all 을 사용하지 않았지만 유사한 결과가 나온다는 것을 확인할 수 있었습니다.itreable
에 묶여 있는 Promise 작업들에 대한 에러 처리, 트랜잭션과 같이 일부 작업만 수행되지 않기를 원하지 않는 경우에 사용하기 적합하다는 특징을 갖고 있습니다. 이러한 작업에 부합할 경우, 단순 병렬 작업만을 위한 용도로 Promise.all을 사용하는 것이 아니라는 것을 배울 수 있었습니다.8-Core Processor
이기 때문에 4개의 카테고리를 4개의 Worker를 사용하여 작업을 수행하기 때문에 이를 사용한다면 성능을 향상 시킬 수 있을 것이라고 생각 했습니다.0.6 vCPU
를 사용할 수 있다는 것을 확인할 수 있습니다. 여기서 vCPU
란 클라우드 플랫폼에서 물리적 CPU의 자원을 가상화한 단위를 이야기 합니다. 보통 1 vCPU
는 하나의 물리적 코어나 하드웨어 스레드에 대응됩니다.0.6 vCPU
는 60%의 CPU를 사용하다는 의미입니다. 전체 코어의 성능이 100% 일 경우 그 중 60%만 사용이 가능하다는 이야기입니다. 이 말이 의미하는 것은 하나의 전체 코어의 100% 성능을 독점적으로 사용할 수 없고 약 60%의 연산 능력을 할당받는다는 것을 의미합니다. 따라서 CPU 집약적 작업의 경우, 1코어 전체를 사용하는 것보다 성능이 낮을 수 있습니다.0.6vCPU
에서 4개의 Worker을 이용하여 작업을 수행할 경우 결과는 더 심각해집니다. Worker가 할당받는 CPU 리소스는 극히 제한적이게 됩니다. 하나의 전체 코어를 사용할 수 없기 때문에, 4개의 Worker가 동시에 실행되면 각각 약 15% 정도의 CPU 리소스를 받게 되는 셈입니다. 그렇기 때문에 많은 수의 Worker를 사용함으로 인해서 오히려 작업당 연산 속도가 떨어지고, 경우에 따라 CPU 리소스를 분할해야 하므로 순차적으로, 동기적으로 작업을 처리하는 것보다 전체 실행이 길어질 수도 있습니다."use server"
async function readMDXFile(category: string, dir: string) {
const files = await fs.readdir(dir);
await Promise.all(
files.map(async (filePath) => {
const fullPath = path.join(dir, filePath);
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
this.readMDXFile(category, fullPath);
} else {
const fileData = await fs.readFile(fullPath, "utf-8");
const parsedPostContent = matter(fileData);
const frontMatter = FrontMatterSchema.safeParse(
parsedPostContent.data,
);
...
}
);
}
Sequential readFile: 27.151s
Sequential readFile: 26.964s
Sequential readFile: 27.267s
Sequential readFile: 26.789s
Sequential readFile: 26.289s
Sequential readFile: 24.253s
Sequential readFile: 24.203s
Sequential readFile: 24.234s
Sequential readFile: 24.274s
Sequential readFile: 24.228s
Concurrent readFile with Promise.all: 13.577s
Concurrent readFile with Promise.all: 13.096s
Concurrent readFile with Promise.all: 13.469s
Concurrent readFile with Promise.all: 13.324s
Concurrent readFile with Promise.all: 12.651s
Concurrent readFile with Promise.all: 12.159s
Concurrent readFile with Promise.all: 12.633s
Concurrent readFile with Promise.all: 12.434s
Concurrent readFile with Promise.all: 12.221s
Concurrent readFile with Promise.all: 12.241s
10000개의 파일을 처리: 14.315s
10000개의 파일을 처리: 13.897s
10000개의 파일을 처리: 14.147s
10000개의 파일을 처리: 14.473s
10000개의 파일을 처리: 15.334s
10000개의 파일을 처리: 14.181s
10000개의 파일을 처리: 14.376s
10000개의 파일을 처리: 13.466s
10000개의 파일을 처리: 14.348s
10000개의 파일을 처리: 14.275s
Worker [50000 ~ 75000] 파일 읽기: 5.445s
Worker [0 ~ 25000] 파일 읽기: 5.943s
Worker [25000 ~ 50000] 파일 읽기: 6.062s
Worker [75000 ~ 100000] 파일 읽기: 6.469s
Worker 작업 수행 시간: 7.252s
Worker [0 ~ 25000] 파일 읽기: 4.534s
Worker [75000 ~ 100000] 파일 읽기: 5.525s
Worker [25000 ~ 50000] 파일 읽기: 6.184s
Worker [50000 ~ 75000] 파일 읽기: 6.418s
Worker 작업 수행 시간: 6.905s
Worker [0 ~ 100000] 파일 읽기: 15.976s
Worker 작업 수행 시간: 16.026s
Worker [0 ~ 100000] 파일 읽기: 15.837s
Worker 작업 수행 시간: 15.883s
Worker [0 ~ 100000] 파일 읽기: 16.350s
Worker 작업 수행 시간: 16.394s
Worker [0 ~ 100000] 파일 읽기: 17.016s
Worker 작업 수행 시간: 17.061s
Worker [0 ~ 100000] 파일 읽기: 16.807s
Worker 작업 수행 시간: 16.854s
Worker [0 ~ 100000] 파일 읽기: 17.226s
Worker 작업 수행 시간: 17.284s
Worker [50000 ~ 75000] 파일 읽기: 20.062s
Worker [25000 ~ 50000] 파일 읽기: 20.113s
Worker [75000 ~ 100000] 파일 읽기: 20.708s
Worker [0 ~ 25000] 파일 읽기: 20.806s
Worker 작업 수행 시간: 21.756s
#!/usr/bin/env bash
NUM_FILES=100000
TARGET_DIR="./longtext-files"
LONG_TEXT="longonglndinfdfkjds"
mkdir -p "$TARGET_DIR"
LONG_TEXT_REPEATED=$(yes "$LONG_TEXT" | head -n 1000 | tr -d '\n')
echo "총 $NUM_FILES 개의 파일을 생성합니다."
for ((i = 0; i < NUM_FILES; i++)); do
echo "$LONG_TEXT_REPEATED" > "$TARGET_DIR/file-$i.txt"
if ((i > 0 && i % 10000 == 0)); then
echo "$i 개의 파일 생성 완료"
fi
done
echo "모든 파일 생성 완료!"
LONG_TEXT_REPEATED=$(yes "$LONG_TEXT" | head -n 1000 | tr -d '\n')
A | B | C
echo "$LONG_TEXT_REPEATED" > "$TARGET_DIR/file-$i.txt"
async function processFile(fullPath: string, file: string) {
if (!parentPort) return;
try {
let frontmatter: z.infer<typeof FrontMatterSchema> | null = null;
const slug = file.split('.')[0];
const readStream = createReadStream(fullPath, { encoding: 'utf-8' });
for await (const chunk of readStream) {
if (!frontmatter && FRONTMATTER_REGEX.test(chunk)) {
const { data, content } = matter(chunk);
if (validateFrontMatter(data)) frontmatter = data;
parentPort.postMessage({
type: 'data',
slug,
frontmatter,
content,
});
} else {
parentPort.postMessage({
type: 'data',
slug,
content: chunk,
});
}
}
parentPort.postMessage({
type: 'fileComplete',
slug,
});
} catch (error) {
parentPort.postMessage({
type: 'error',
message: `${file} 파일을 읽는 중 오류가 발생했습니다.`,
});
}
}
import fs from 'fs/promises';
import path from 'path';
const fileName = 'longtext-files';
(async () => {
const dirPath = path.join(process.cwd(), fileName);
const files = await fs.readdir(dirPath);
console.time('Sequential readFile');
for (const file of files) {
await fs.readFile(path.join(dirPath, file));
}
console.timeEnd('Sequential readFile');
})();
import fs from 'fs/promises';
import path from 'path';
const fileName = 'longtext-files';
(async () => {
const dirPath = path.join(process.cwd(), fileName);
const files = await fs.readdir(dirPath);
console.time('Concurrent readFile with Promise.all');
await Promise.all(files.map(file => fs.readFile(path.join(dirPath, file))));
console.timeEnd('Concurrent readFile with Promise.all');
})();
import fs from 'fs/promises';
import path from 'path';
const fileName = 'longtext-files';
(async () => {
const dirPath = path.join(process.cwd(), fileName);
const files = await fs.readdir(dirPath);
console.time('10000개의 파일을 Promise.all');
let count = 0;
files.map(async file => {
fs.readFile(path.join(dirPath, file)).then(() => {
count++;
if (count === 10000) {
console.timeEnd('10000개의 파일을 Promise.all');
}
});
});
})();
import { Worker } from 'node:worker_threads';
const workerFiles = [getFile('1.js'), getFile('2.js'), getFile('3.js'), getFile('4.js')];
(async () => {
console.time('Worker 작업 수행 시간');
let activateWorker = workerFiles.length;
workerFiles.forEach((workerFile, i) => {
const worker = new Worker(workerFile, {
workerData: files,
});
worker.on('message', message => {
if (message === 'end') {
worker.terminate();
activateWorker--;
if (activateWorker === 0) {
console.timeEnd('Worker 작업 수행 시간');
}
}
});
const rangeStart = Math.floor((i * TOTAL_FILES) / 4);
const rangeEnd = Math.floor(((i + 1) * TOTAL_FILES) / 4);
worker.postMessage(`${rangeStart} ${rangeEnd}`);
});
})();
import fs from 'fs/promises';
import path from 'path';
import { parentPort, workerData } from 'node:worker_threads';
parentPort.on('message', async message => {
const [start, end] = message.split(' ').map(Number);
const filesToRead = workerData.slice(start, end);
console.time(`Worker [${start} ~ ${end}] 파일 읽기`);
await Promise.all(filesToRead.map(file => fs.readFile(path.join(dirPath, file))));
console.timeEnd(`Worker [${start} ~ ${end}] 파일 읽기`);
parentPort.postMessage('end');
});