Logo

워커 쓰레드를 통한 자바스크립트 멀티 쓰레딩

worker_threads는 싱글 쓰레드 언어로 알려진 자바스크립트로도 멀티 쓰레드 프로그래밍을 가능하게 해주는 Node.js의 내장 모듈입니다.

이번 포스팅에서는 worker_threads 모듈을 사용하여 어떻게 멀티 쓰레드 프로그래밍을 할 수 있는지 예제를 통해서 설명드리겠습니다.

싱글 쓰레드의 한계

자바스크립트는 태성적으로 하나의 쓰레드로 동작하는 여러 작업을 처리할 수 있는 비동기 프로그래밍 언어였는데요. 이러한 특징은 작은 하드웨어 리소스로 여러 개의 IO 작업을 동시에 처리하는데 유리했으며 특히 브라우저 환경에서 빛을 발휘했죠.

하지만 Node.jsBun과 같은 서버 런타임을 통해서 자바스크립트가 백엔드에서도 사용되면서 멀티 쓰레드를 지원하지 않는 부분은 항상 약점으로 지적되어 왔습니다. 쓰레드가 하나라는 것은 해당 쓰레드에 문제가 생기면 전체 애플리케이션이 먹통이 될 수 있다는 뜻이라서 성능과 안정성이 떨어지죠. 뿐만 아니라, 요즘에는 CPU가 대부분 멀티 코어인데 코어를 하나 밖에 쓰지 않는 부분도 리소스 활용 측면에서 불리하게 작용합니다.

worker_threads 모듈

Node.js는 worker_threads 모듈을 사용하면 자바스크립트에서도 다른 언어처럼 멀티 쓰레드를 사용할 수 있습니다. 메인 쓰레드에서 워커 쓰레드를 만들어서 특정 작업을 처리하도록 위임하면 메인 쓰레드의 부담을 덜어줄 수 있습니다. 특히 암복호화나 이미지 처리 등 CPU가 많이 소모되는 작업을 처리할 때 워커 쓰레드를 많이 사용합니다.

자바스크립트로 서버 애플리케이션을 작성하면 메인 쓰레드가 돌아가는 CPU가 너무 바빠서 소위 블라킹(Blocking) 상태에 빠지는데요. 그러면 다른 요청을 받을 수가 없기 때문에 트래픽이 많은 서비스의 경우 치명적인 문제로 이어지죠. 이럴 때, CPU 집약적인 작업을 워크 쓰레드를 통해서 메인 쓰레드로 부터 분리해주면, 메인 쓰레드가 돌아가는 CPU를 언제나 다른 작업을 처리할 수 있는 상태로 유지할 수 있습니다.

하지만 데이터베이스 연동이나 원격 API 호출처럼 입출력(I/O)이나 네트워크 작업의 경우에는 워커 쓰레드를 쓰는 것이 오히려 독이 될 수도 있습니다. 이렇게 CPU를 별로 쓰지 않는 작업은 기존의 이벤트 루프를 통해서 비동기로 처리하는 방식이 더 효율적이고 적합한 경우가 많거든요.

워커 쓰레드 생성

워커 쓰레드는 worker_threads 모듈에서 제공하는 Worker 클래스를 통해서 생성할 수 있는데요.

생성자를 호출할 때 필수적으로 워커 쓰레드에서 실행할 코드를 담은 파일의 경로를 첫 번째로 인자로 넘겨야합니다. 선태적으로 워커 쓰레드에서 사용할 초기 데이터를 두 번째 인자로 넘길 수 있습니다.

import { Worker } from "node:worker_threads";

const worker = new Worker("./worker.js", {
  workerData: "워커 쓰레드의 초기 데이터",
});

메인 쓰레드와 워커 쓰레드 간 통신

다른 자바스크립트의 표준 API처럼 메인 쓰레드와 워커 쓰레드 간에는 이벤트를 기반으로 통신을 합니다.

메인 쓰레드에서는 생성한 워커 인스터스를 상대로 postMessage() 함수를 호출하여 워커 쓰레드에 메시지를 전달할 수 있습니다. 그리고 워커 인스턴스를 상대로 on() 함수를 통해서 워커 쓰레드에서 발생한 message, error, exit과 같은 이벤트를 처리할 수 있습니다.

import { Worker } from "node:worker_threads";

const worker = new Worker("./worker.js");

worker.postMessage("나는 메인 쓰레드야!");

worker.on("message", (msg) => {
  console.log(`워커 쓰레드가 보낸 메시지: ${msg}`);
});

워커 쓰레드 측에서는 worker_threads 모듈에서 불러온 parentPort를 통해서 메인 쓰레드와 메시지를 주고 받을 수 있습니다. 비슷한 방식으로 parentPort를 상대로 on() 함수를 통해서 메인 쓰레드에서 발생한 이벤트를 처리할 수 있습니다. parentPort를 상대로 postMessage() 함수를 호출하여 메인 쓰레드에 메시지를 전달할 수 있습니다.

import { parentPort } from "node:worker_threads";

parentPort.on("message", (msg) => {
  console.log(`메인 스레드가 보낸 메시지: ${msg}`);
  parentPort.postMessage(`나는 워커 쓰레드야!`);
});

예제 1. 싱글 쓰레드

간단한 실습을 위해서 CPU를 많이 사용하는 함수를 하나 작성해보겠습니다.

아래 count() 함수는 인자로 넘어온 시간동안 계속해서 숫자를 센 후 그 숫자를 반환합니다. CPU는 주어진 시간동안 cnt 변수를 1씩 증가시키기 위해서 엄청나게 바쁘겠죠?

index.js
function count(second) {
  const end = performance.now() + second * 1_000;
  let cnt = 0;
  while (performance.now() < end) {
    cnt += 1;
  }
  return cnt;
}

메인 쓰레드의 시작 시간을 start 변수에 기록하고 시작 로그를 남기겠습니다.

index.js
const start = performance.now();
console.log("🔵 메인 쓰레드 > 시작");

아래 runOnMain() 함수는 인자로 넘어온 시간을 그대로 count() 함수에 넘겨서 호출해줍니다. 그리고 count() 함수의 반환 결과를 비동기로 처리할 수 있도록 Promise 객체로 반환합니다. 그 때까지 걸린 시간에 대한 로그도 남기겠습니다.

index.js
function runOnMain(second) {
  return new Promise((resolve) => {
    const result = count(second);
    const time = (performance.now() - start) / 1000;
    console.log(`🔵 메인 쓰레드 > 결과: ${result}, 시간: ${time}`);
    resolve(result);
  });
}

그 다음, Promise.all() 함수를 통해서 runOnMain() 함수를 총 8번 인자를 1초씩 증가시키면서 동시 호출합니다.

index.js
await Promise.all([
  runOnWorker(1),
  runOnWorker(2),
  runOnWorker(3),
  runOnWorker(4),
  runOnWorker(5),
  runOnWorker(6),
  runOnWorker(7),
  runOnWorker(8),
]);

마지막에는 콘솔에 종료 로그를 남기고, 총 실행 시간도 출력히겠습니다.

index.js
console.log("🔵 메인 쓰레드 > 종료");
console.log(`🔵 총 실행 시간 > ${(performance.now() - start) / 1000}`);

터미널에서 Node.js로 index.js를 실행하면 8개의 함수를 동시에 호출하는데 36초 이상이 걸리는 것을 볼 수 있습니다. 첫 번째 호출이 1초가 걸리고, 두 번째 호출이 2초가 걸리고, 세 번째 호출이 3초가 걸리고… 이런 식으로 실행 시간이 누적되어 결국 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36초가 된 것입니다.

$ node ./index.js
🔵 메인 쓰레드 > 시작
🔵 메인 쓰레드 > 결과: 35181927, 시간: 1.000034334
🔵 메인 쓰레드 > 결과: 71285977, 시간: 3.000168334
🔵 메인 쓰레드 > 결과: 107400761, 시간: 6.000266834
🔵 메인 쓰레드 > 결과: 141959176, 시간: 10.000415792
🔵 메인 쓰레드 > 결과: 173833071, 시간: 15.000617792000002
🔵 메인 쓰레드 > 결과: 209222026, 시간: 21.000717709
🔵 메인 쓰레드 > 결과: 239103189, 시간: 28.000917167
🔵 메인 쓰레드 > 결과: 271104978, 시간: 36.001187249999994
🔵 메인 쓰레드 > 종료
🔵 총 실행 시간 > 36.001723709

이 실습을 통해서 우리는 여러 작업은 비동기로 처리하더라도 CPU를 많이 쓰는 경우, 거의 연달에 호출한 것처럼 실행 시간이 많이 소모된다는 것을 알 수 있습니다.

예제 2. 멀티 쓰레드

이제 CPU 소모가 큰 숫자를 세는 작업을 워커 쓰레드에서 처리할 수 있도록 프로그램을 수정해볼까요?

먼저 index.js 파일에 있는 runOnMain() 함수를 runOnWorker() 함수로 변경하겠습니다. 직접 count() 함수를 호출하는 대신에 인자로 넘어온 시간을 그대로 초기 데이터로 넘겨서 워커 쓰레드를 생성합니다.

그 다음, 생성된 워커 쓰레드의 on() 함수를 통해서 워커 쓰레드에서 보낸 메시지를 메인 쓰레드에서 받아서 처리합니다. 워커 쓰레드에서 보내준 처리 결과와 그 때까지 걸린 시간에 대한 로그도 남기겠습니다.

index.js
import { Worker } from "node:worker_threads";

function runOnWorker(second) {
  return new Promise((resolve) => {
    const worker = new Worker("./worker.js", { workerData: second });

    worker.on("message", (msg) => {
      const time = (performance.now() - start) / 1000;
      console.log(
        `🟠 워커 쓰레드 ${worker.threadId} > 결과: ${msg}, 시간: ${time}`
      );
      resolve(msg);
    });
  });
}

마찬가지로 Promise.all() 함수를 통해서 runOnWorker() 함수를 8회 동시에 호출합니다.

index.js
await Promise.all([
  runOnWorker(1),
  runOnWorker(2),
  runOnWorker(3),
  runOnWorker(4),
  runOnWorker(5),
  runOnWorker(6),
  runOnWorker(7),
  runOnWorker(8),
]);

워커 쓰레드에서 실행 할 코드는 메인 쓰레드에서 실행한 코드와 분리하여 별도의 파일에 두는 경우가 많습니다. 그러면 하나의 파일 내에서 어느 부분이 메인 쓰레드에서 실행되고 어느 부분이 워커 쓰레드에서 실행되는지 분기를 나눌 필요가 없어서 구현도 용이하고 유지 보수도 쉽워지기 때문이죠.

그럼 worker.js 파일을 생성하고 워커 쓰레드에서 실행 할 코드를 작성해보겠습니다.

worker_threads 모듈에서 parentPortworkerData를 불러 옵니다. parentPort를 통해서 워커 쓰레드에서 메인 쓰레드로 메시지를 보낼 수 있습니다. workerData는 메인 쓰레드에서 워커 쓰레드를 만들 때 생성자에 넘겨줬던 데이터가 들어있습니다.

그 다음 count() 함수를 index.js 파일로 부터 그대로 복사해서 붙여넣기 한 후, workerData를 인자로 넘겨서 count() 함수를 호출합니다.

worker.js
import { parentPort, workerData } from "node:worker_threads";

function count(second) {
  const end = performance.now() + second * 1_000;
  let cnt = 0;
  while (performance.now() < end) {
    cnt += 1;
  }
  return cnt;
}

const result = count(workerData);

parentPort.postMessage(result);

다시 터미널에서 index.js 파일을 실행해보면 실행 시간이 36초에서 8초로 눈에 띄게 단축된 것을 볼 수 있습니다. 총 실행 시간이 8초 동안 숫자를 센 runOnWorker(8)의 결과와 거의 비슷합니다.

$ node ./index.js
🔵 메인 쓰레드 > 시작
🟠 워커 쓰레드 1 > 결과: 21711063, 시간: 1.043009708 초
🟠 워커 쓰레드 2 > 결과: 37556482, 시간: 2.0420582080000003 초
🟠 워커 쓰레드 3 > 결과: 58854738, 시간: 3.044510833 초
🟠 워커 쓰레드 4 > 결과: 83713769, 시간: 4.043756958 초
🟠 워커 쓰레드 5 > 결과: 110986970, 시간: 5.042783375 초
🟠 워커 쓰레드 6 > 결과: 146983406, 시간: 6.041664 초
🟠 워커 쓰레드 7 > 결과: 171129999, 시간: 7.043559166 초
🟠 워커 쓰레드 8 > 결과: 218331517, 시간: 8.043146666 초
🔵 메인 쓰레드 종료
🔵 총 실행 시간 > 8.043622458

이 실습을 통해서 우리는 함수 호출이 병렬로 처리되었고, 총 실행 시간은 가장 오래 걸린 호출에 좌우되었다는 것을 알 수 있습니다.

마치면서

지금까지 Node.js에서 워커 쓰레드를 활용하여 어떻게 CPU 집약적인 작업을 메인 쓰레드로 부터 분리하여 실행할 수 있는지 알아보았습니다. 브라우저에서는 서비스 워커가 도입되고, 서버에서는 워커 쓰레드가 도입되면서 자바스립트에서도 풀스택으로 멀티 쓰레드 프로그래밍의 길이 마침내 열렸습니다. 🥳🎊

후속 포스팅에서는 Piscina라는 라이브러리를 통해서 쉽게 워커 쓰레드를 풀링(pooling)하는 방법을 알려드리겠습니다.

브라우저로에서 지원하는 서비스 워커(Service Worker)에 대해서는 별도 포스팅에서 다루고 있으니 참고하세요.