Piscina로 자바스크립트 멀티 쓰레딩 쉽게 하기
지난 포스팅에서 Node.js의 worker_threads
모듈을 통해 이제 자바스크립트에서도 멀티 쓰레딩이 가능하다는 것을 배웠는데요.
이번 포스팅에서는 워커 쓰레드 풀링(pooling)을 도와주는 라이브러리인 Piscina에 대해서 알아보겠습니다.
Piscina란?
상용 애플리케이션을 개발할 때는 Node.js의 worker_threads
모듈을 그대로 쓰기는 곤란한 경우가 많은데요.
필요할 때 마다 매번 새로운 워커 쓰레드를 생성하면 서버에 부하를 주어 오히려 성능을 떨어뜨릴 수도 있죠.
그래서 쓰레드 풀(pool)을 사용하여 미리 다수의 워커 쓰레드를 생성해놓고 재사용하는 것이 권장됩니다.
Piscina는 효과적인 멀티 쓰레딩을 위해서 쓰레드 풀 관리 기능을 제공하는 라이브러리입니다.
뿐만 아니라, 콜백(Callback) 기반의 API를 제공하는 worker_threads
모듈과 달리, Piscina의 API는 프라미스(Promise) 기반으로 되어 있어서 사용하기 쉽다는 장점도 있죠.
Piscina 설치
Piscina는 npm 패키지 저장소에 piscina
라는 이름으로 올라와 있습니다.
터미널에서 프로젝트 경로로 진입한 후 npm
명령어를 사용하여 손쉽게 설치할 수 있습니다.
$ npm add piscina
워커 코드 작성
간단한 실습을 위해서 CPU를 많이 사용하는 함수를 하나 작성해보겠습니다.
count()
함수는 인자로 넘어온 시간동안 계속해서 숫자를 센 후 그 숫자를 반환합니다.
CPU는 주어진 시간동안 cnt
변수를 1씩 증가시키기 위해서 엄청나게 바쁘겠죠?
export default function count(second) {
const end = performance.now() + second * 1_000;
let cnt = 0;
while (performance.now() < end) {
cnt += 1;
}
return cnt;
}
작성한 코드를 worker.js
파일에 저장하고 count()
함수를 다른 모듈에서 불러올 수 있도록 내보내겠습니다.
메인 코드 작성
다음으로 index.js
파일에 메인 쓰레드에서 실행할 코드를 작성하겠습니다.
Node.js의 os
내장 모듈과 piscina
패키지로 부터 Piscina
클래스를 불러옵니다.
그리고 메인 쓰레드의 시작 시간을 start
변수에 기록하고 시작 로그를 남기겠습니다.
import os from "os";
import Piscina from "piscina";
const start = performance.now();
console.log("🔵 메인 쓰레드 > 시작");
쓰레드 풀 생성
Piscina를 통해서 멀티 쓰레딩을 하려면 우선 쓰레드 풀을 생성해야 합니다.
Piscina
클래스의 생성자에 워커 쓰레드에서 실행할 함수가 담긴 파일의 위치를 인자로 넘겨서 호출하면 쓰레드 풀 객체가 만들어집니다.
const pool = new Piscina({
filename: "./worker.js",
});
필수 옵션인 filename
외에도 선택 옵션인 minThreads
과 maxThreads
을 통해서 각각 최소 쓰레드 개수와 최대 쓰레드 개수를 설정해줄 수 있습니다.
설정해주지 않으면 CPU 코어 개수에 1.5를 곱한 값이 자동으로 지정됩니다.
const pool = new Piscina({
filename: "./worker.js",
minThreads: 2,
maxThreads: 8,
});
위와 같이 설정을 해주면 Piscina는 최소 2개, 최대 8개 범위 안에서 부하에 따라 쓰레드 풀의 크기를 유기적으로 늘였다 줄였다 해줍니다.
쓰레드 풀 실행
쓰레드 풀 객체의 run()
함수를 통해서 워커 쓰레드에서 실행할 함수를 호출할 수 있습니다.
run()
함수에 넘긴 인자는 그대로 worker.js
파일에 있는 count()
함수에 전달이 됩니다.
run()
함수는 프라미스 객체를 반환하기 때문에 async
함수 내에서 await
키워드를 앞에 붙여서 호출했습니다.
async function runOnWorker(second) {
const result = await pool.run(second);
const time = (performance.now() - start) / 1000;
console.log(`🟠 워커 쓰레드 > 결과: ${result}, 시간: ${time} 초`);
}
async/await
키워드에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.
병렬 실행
Promise.all()
함수를 통해서 runOnWorker()
함수를 총 8번 인자를 1초씩 증가시키면서 동시 호출해보겠습니다.
마지막에는 콘솔에 종료 로그를 남기고, 총 실행 시간도 출력히겠습니다.
await Promise.all([
runOnWorker(1),
runOnWorker(2),
runOnWorker(3),
runOnWorker(4),
runOnWorker(5),
runOnWorker(6),
runOnWorker(7),
runOnWorker(8),
]);
console.log("🔵 메인 쓰레드 > 종료");
console.log(`🔵 총 실행 시간 > ${(performance.now() - start) / 1000} 초`);
console.log(`🔵 CPU 코어: ${os.availableParallelism}`);
console.log(`🔵 최대 쓰레드: ${pool.maxThreads} `);
터미널에서 index.js
파일을 실행해보면 8개의 함수를 동시에 호출하는데 10초 정도 걸리는데요.
순차적으로로 실행됐더라면 총 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36
초가 걸렸을텐데, 쓰레드 풀 덕분에 병렬로 처리되어 시간이 크게 단축된 것을 볼 수 있습니다.
🔵 메인 쓰레드 > 시작
🟠 워커 쓰레드 > 결과: 15109502, 시간: 1.1204804629999998 초
🟠 워커 쓰레드 > 결과: 20639072, 시간: 2.127793784 초
🟠 워커 쓰레드 > 결과: 35397053, 시간: 3.137355516 초
🟠 워커 쓰레드 > 결과: 51667120, 시간: 4.147871099 초
🟠 워커 쓰레드 > 결과: 63738475, 시간: 5.16961114 초
🟠 워커 쓰레드 > 결과: 86058911, 시간: 6.218629933 초
🟠 워커 쓰레드 > 결과: 107845538, 시간: 8.121711485999999 초
🟠 워커 쓰레드 > 결과: 135158653, 시간: 10.128717848 초
🔵 메인 쓰레드 > 종료
🔵 총 실행 시간 > 10.129195065000001 초
🔵 CPU 코어: 4
🔵 최대 쓰레드: 6
참고로 위 코드를 실행한 제 컴퓨터는 현재 4개의 CPU 코어가 있는데요.
만약 여러분의 컴퓨터에 8개 이상의 CPU 코어가 장착되어 있다면 8초 남짓 걸릴 거에요.
8개의 함수 호출을 CPU 코어가 하나씩 전담하면 그 중 가장 오래 걸리는 runOnWorker(8)
에 총 실행 시간이 좌우될 것이기 때문입니다.
전체 코드
본 포스팅에서 작성한 실습 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.
실행 시간을 비교해보실 수 있도록 single.js
파일에는 멀티 쓰레딩을 하지 않은 코드도 넣어두었으니 참고하세요.
마치면서
지금까지 Piscina에서 제공하는 워커 쓰레드 풀을 활용하여 CPU 집약적인 작업을 메인 쓰레드로 부터 분리하여 실행할 수 있는지 알아보았습니다.
Piscina를 통해서 worker_threads
모듈을 쓰는 것보다 훨씬 쉽고 간단하게 멀티 쓰레딩 프로그래밍을 하실 수 있으셨으면 좋겠습니다.