Devin.KR

worker_threads 로 CPU 작업 나누기

개발자KR 조회 15

이 장에서 배우는 것

앞 장에서 우리는 Node.js의 단일 스레드 기반 이벤트 루프가 어떻게 수많은 동시 연결을 가볍게 처리하는지, 그리고 블로킹이 발생했을 때 서버 전체가 멈추는 부작용을 확인했습니다. 서버가 비동기 입출력을 위임하고 콜백을 기다리는 동안에는 문제가 없지만, 복잡한 수학 연산이나 거대한 텍스트 분석처럼 중앙처리장치(CPU)를 집중적으로 사용하는 작업은 이벤트 루프를 장악합니다. 이번 장에서는 이러한 CPU 집약적인 작업을 분리하여 서버의 응답성을 유지하는 방법을 다룹니다.

  • CPU 집약적인 작업이 이벤트 루프를 막았을 때 서버에 미치는 영향을 확인한다.
  • 워커 스레드(worker thread) 모듈을 사용하여 무거운 연산을 별도의 스레드로 분리한다.
  • 메인 스레드와 워커 스레드 간의 안전한 메시지 전달 방식을 구현한다.
  • 작업 풀(worker pool) 패턴을 도입하여 워커 생성 비용을 최소화하고 메모리를 방어한다.
  • 효율적인 자원 활용을 위해 스레드 수를 결정하는 기준을 학습한다.

문제 상황

우리가 만들고 있는 로그 수집 서버에 새로운 요구사항이 추가되었습니다. 단순히 로그를 받아 파일로 저장하는 것을 넘어, 들어온 방대한 로그 데이터를 정규표현식이나 복잡한 파싱 로직을 통해 실시간으로 분석하고 위험도 점수를 계산하는 라우트를 추가해야 합니다. 이 분석 작업은 수십만 번의 텍스트 매칭과 반복적인 수학 연산을 수반합니다.

만약 서버로 들어온 HTTP 요청의 라우터 안에서 이 무거운 반복문을 그대로 실행하면 문제가 발생합니다. 비동기 입출력과 달리 CPU 연산은 운영체제 커널로 위임할 수 없습니다. Node.js 프로세스를 구동하는 단 하나의 주 스레드가 이 연산을 끝낼 때까지 다른 모든 작업을 멈추고 기다려야 합니다. 이 연산이 처리되는 5초 동안, 접수된 다른 모든 웹 요청의 처리는 지연되고 서버는 응답 불능 상태에 빠집니다.

이렇게 되면 건강 상태를 확인하는 헬스 체크 엔드포인트마저 시간 초과를 일으켜, 앞단에 있는 로드 밸런서가 이 서버를 비정상 상태로 판단하고 종료시킬 수도 있습니다. 웹 서버에게 메인 이벤트 루프의 점유는 가장 경계해야 할 현상입니다. 서버의 원래 목적인 빠른 네트워크 통신을 유지하면서도 무거운 분석 작업을 병행할 수 있는 구조적 분리가 필요합니다.

Node.js의 멀티 스레드와 워커

Node.js는 단일 스레드 논블로킹 모델을 지향하지만, 순수한 연산 능력이 집중적으로 필요한 상황을 위해 표준 모듈로 멀티 스레딩 기능을 제공합니다. 일반적인 다른 프로그래밍 언어에서 스레드를 생성하면 보통 모든 스레드가 같은 메모리 공간을 공유합니다. 하지만 이로 인해 변수를 동시에 수정하다가 데이터가 오염되는 현상을 막고자 잠금장치 등을 다뤄야 하는 어려움이 따릅니다.

Node.js의 워커 스레드는 이와 전혀 다른 독립적인 방식을 취합니다. 새로운 워커를 생성하면 그 안에 완전히 분리된 자바스크립트 실행 엔진(V8 인스턴스)과 독자적인 이벤트 루프가 새로 할당됩니다. 메인 스레드와 워커 스레드는 서로의 변수나 객체에 직접 접근할 수 없으며 철저히 격리되어 동작합니다.

메인 스레드와 워커 스레드의 분리된 구조와 메시지 통신 방식

서로 격리된 스레드가 협력하려면 메시지 전달 방식에 의존해야 합니다. 메인 스레드가 분석할 로그 데이터를 워커 스레드에게 메시지로 보내면, 워커 스레드는 할당받은 자신의 공간에서 연산을 수행합니다. 계산이 끝나면 결과만을 다시 메시지에 담아 메인 스레드로 돌려보냅니다. 이 덕분에 메인 스레드의 이벤트 루프는 분석이 진행되는 동안에도 새로운 HTTP 요청을 자유롭게 받아낼 수 있습니다.

작업 풀 만들기

워커 스레드가 유용하다고 해서 HTTP 요청이 들어올 때마다 새로운 워커를 동적으로 생성하는 것은 대단히 비효율적인 설계입니다. 스레드를 새로 띄우고 가상 머신 환경을 구성하는 일에는 적지 않은 시간과 자원이 소모됩니다. 사용자가 몰려 수천 개의 워커가 순간적으로 생성되면 서버의 물리적 메모리는 곧바로 고갈되고 응답 속도는 오히려 단일 스레드보다 느려집니다.

이 문제를 해결하기 위해 실무에서는 반드시 작업 풀 패턴을 도입합니다. 서버가 구동될 때 감당할 수 있는 적정 개수의 워커 스레드를 미리 만들어 둡니다. 그리고 요청이 들어오면 작업을 큐(queue)에 차례대로 쌓습니다. 대기 중인 빈 워커가 있다면 큐에서 작업을 꺼내어 넘겨주고, 모든 워커가 연산 중이라면 작업은 큐에서 자신의 순서를 기다립니다.

작업 풀을 통해 큐에 쌓인 요청을 여러 워커가 분배하여 처리하는 구조

이렇게 풀을 구성하면 서버에 아무리 많은 연산 요청이 쏟아져도 우리가 미리 정해둔 워커 개수 이상의 스레드가 생성되지 않으므로, 시스템 메모리를 안전하게 방어할 수 있습니다. 또한 워커 생성에 드는 초기화 시간을 생략할 수 있어 전체적인 분석 응답 속도도 훨씬 일정하게 유지됩니다.

메모리 공유와 스레드 수 정하기

워커 스레드로 메시지를 보낼 때, 자바스크립트 엔진은 내부적으로 구조화된 클론(structured clone) 알고리즘을 사용합니다. 즉, 보낸 데이터의 복사본을 메모리에 새로 만들어 워커에게 전달합니다. 만약 100MB 크기의 대용량 JSON 데이터를 워커로 보낸다면 100MB를 온전히 복사하는 시간과 공간이 추가로 발생하며, 이 복사 작업 자체도 메인 스레드를 일시적으로 블로킹합니다.

이러한 복사 비용을 줄이기 위해 메모리 버퍼를 두 스레드가 직접 같이 사용하는 객체를 활용할 수 있습니다. 그러나 이 방식을 사용하려면 동시에 같은 메모리 위치를 수정하여 데이터가 손상되지 않도록 원자적(Atomics) 연산을 이용해 개발자가 직접 제어해야 합니다. 코드가 기하급수적으로 복잡해지기 때문에, 보통의 웹 서버에서는 메시지 복사본을 전달하거나 필요한 최소한의 식별자 데이터만 넘긴 뒤 워커 내부에서 직접 디스크를 조회하는 방식을 선호합니다.

작업 풀을 만들 때 워커의 개수는 몇 개가 적당할까요? 서버의 논리적 중앙처리장치 코어 개수에 맞추는 것이 보편적입니다. 코어 수를 초과하여 무리하게 워커를 만들면, 운영체제가 여러 스레드를 번갈아 실행하기 위해 문맥 교환에 자원을 낭비하여 오히려 전체 처리 성능이 저하됩니다.

완성 코드

worker.mjs

import { parentPort } from 'node:worker_threads';

function analyzeLogs(logs) {
  let riskScore = 0;
  
  for (const log of logs) {
    let dummyCompute = 0;
    // 복잡한 텍스트 파싱을 흉내 내기 위해 의도적으로 CPU를 소모하는 연산
    for (let i = 0; i < 5000000; i++) {
      dummyCompute += (i % 2);
    }
    
    if (log.includes('ERROR')) {
      riskScore += 10;
    } else if (log.includes('WARN')) {
      riskScore += 5;
    } else {
      riskScore += 1;
    }
  }
  
  return {
    processedLogs: logs.length,
    riskScore: riskScore
  };
}

// 메인 스레드로부터 작업 메시지를 받아 분석을 시작한다
parentPort.on('message', (message) => {
  const { taskId, data } = message;
  const result = analyzeLogs(data);
  parentPort.postMessage({ taskId, result });
});

worker-pool.mjs

import { Worker } from 'node:worker_threads';

export class WorkerPool {
  constructor(workerPath, numThreads) {
    this.workerPath = workerPath;
    this.numThreads = numThreads;
    this.workers = [];
    this.idleWorkers = [];
    this.taskQueue = [];
    this.taskIdCounter = 0;
    this.callbacks = new Map();

    for (let i = 0; i < numThreads; i++) {
      this.addWorker();
    }
  }

  addWorker() {
    const worker = new Worker(this.workerPath);
    worker.currentTaskId = null;
    
    worker.on('message', (message) => {
      const { taskId, result, error } = message;
      worker.currentTaskId = null;
      
      const callback = this.callbacks.get(taskId);
      if (callback) {
        if (error) {
          callback(new Error(error), null);
        } else {
          callback(null, result);
        }
        this.callbacks.delete(taskId);
      }
      
      // 작업을 마친 워커를 다시 유휴 목록에 넣고 다음 작업을 확인한다
      this.idleWorkers.push(worker);
      this.processQueue();
    });

    worker.on('error', (err) => {
      console.error('워커 스레드 비정상 종료:', err);
      // 에러로 중단된 워커가 맡고 있던 작업의 콜백을 실패 처리한다
      if (worker.currentTaskId) {
        const callback = this.callbacks.get(worker.currentTaskId);
        if (callback) {
          callback(err, null);
          this.callbacks.delete(worker.currentTaskId);
        }
      }
      
      // 손상된 워커를 배열에서 제거하고 새로운 워커를 투입하여 풀 크기를 유지한다
      this.workers = this.workers.filter(w => w !== worker);
      this.idleWorkers = this.idleWorkers.filter(w => w !== worker);
      this.addWorker();
    });

    this.workers.push(worker);
    this.idleWorkers.push(worker);
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const taskId = ++this.taskIdCounter;
      this.callbacks.set(taskId, (err, result) => {
        if (err) reject(err);
        else resolve(result);
      });
      this.taskQueue.push({ taskId, data });
      this.processQueue();
    });
  }

  processQueue() {
    // 대기 중인 작업이 없거나 쉴 수 있는 워커가 없다면 함수를 종료한다
    if (this.taskQueue.length === 0 || this.idleWorkers.length === 0) {
      return;
    }
    const task = this.taskQueue.shift();
    const worker = this.idleWorkers.pop();
    worker.currentTaskId = task.taskId;
    worker.postMessage(task);
  }
}

main.mjs

import { createServer } from 'node:http';
import { cpus } from 'node:os';
import { WorkerPool } from './worker-pool.mjs';

const numCPUs = cpus().length;
const pool = new WorkerPool('./worker.mjs', numCPUs);

const server = createServer(async (req, res) => {
  if (req.url === '/analyze') {
    const dummyLogs = [
      'INFO: Server started properly',
      'WARN: Disk usage at 80%',
      'ERROR: Database connection timeout'
    ];
    
    try {
      const result = await pool.run(dummyLogs);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ success: true, data: result }));
    } catch (err) {
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('내부 서버 오류');
    }
  } else if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('OK');
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not Found');
  }
});

server.listen(3000, () => {
  console.log(`서버가 3000번 포트에서 실행 중입니다. 워커 수: ${numCPUs}`);
});

줄별 해설

분석 로직을 담은 worker.mjs는 통신을 위해 parentPort 객체를 사용합니다. on('message') 리스너를 열어두면 메인 스레드에서 전송한 데이터가 콜백의 매개변수로 전달됩니다. 긴 연산이 끝나면 postMessage를 호출하여 작업 고유 식별자와 결과를 하나로 묶어 다시 반환합니다.

핵심 역할을 하는 worker-pool.mjs는 스레드 생명 주기를 전담하여 관리합니다. 생성자를 통해 설정된 개수만큼 워커를 배양하여 배열에 저장합니다. 라우터에서 run() 메서드를 호출하면 프로미스 객체가 만들어지고, 이 프로미스를 해결할 수 있는 함수를 맵 구조에 담은 뒤 본 작업을 큐에 밀어 넣습니다. 유휴 워커가 연산을 무사히 마치고 메시지를 보내오면, 맵에서 해당 식별자로 콜백을 찾아 결과를 넘겨주어 대기 중이던 await 구문을 풀어냅니다. 워커가 연산 도중 메모리 부족 등으로 비정상 종료되는 상황을 대비해 on('error') 이벤트를 감지하고, 죽어버린 워커 대신 즉시 새로운 워커를 재배양하여 전체 풀의 역량을 보존합니다.

진입점인 main.mjs에서는 운영체제 모듈을 이용해 가용한 전체 논리 코어 개수를 알아내어 워커 풀의 크기로 지정합니다. /analyze 주소로 HTTP 요청이 들어오면 가상의 로그 배열을 만들어 워커 풀의 run() 메서드에 넘깁니다. 서버의 입장에서는 꽤 긴 연산이 시작되었지만 await로 프로미스를 기다릴 뿐 메인 이벤트 루프는 막히지 않으므로, 이 순간에 들어오는 /health 같은 다른 요청도 대기열에 묶이지 않고 즉시 응답을 돌려줍니다.

실행 결과

먼저 터미널을 열고 메인 서버를 실행합니다.

$ node main.mjs
서버가 3000번 포트에서 실행 중입니다. 워커 수: 8

다른 터미널 창을 열어 분석 엔드포인트를 호출해 봅니다. 연산에 일정 시간이 소요된 후 분석된 점수가 반환됩니다.

$ curl http://localhost:3000/analyze
{"success":true,"data":{"processedLogs":3,"riskScore":16}}

무거운 분석 요청을 보내놓고 결괏값을 기다리는 도중에 헬스 체크 엔드포인트를 호출해 보아도 서버가 즉각적인 응답을 보냅니다. 메인 스레드가 블로킹되지 않고 본연의 기능을 다하고 있음을 확인할 수 있습니다.

$ curl http://localhost:3000/health
OK

실무에서 자주 틀리는 것

입출력 작업을 워커로 넘기기

데이터베이스 쿼리 대기, 디스크 파일 읽기 쓰기, 외부 웹 주소로의 호출은 대표적인 입출력 작업입니다. 이러한 작업을 굳이 워커 스레드로 넘기는 것은 서버 구조만 복잡하게 만들 뿐 이득이 없습니다. 메인 스레드의 이벤트 루프는 이미 비동기 입출력을 대기 없이 효율적으로 스케줄링하도록 설계되어 있습니다. 워커 스레드는 오로지 복잡한 이미지 변환, 방대한 텍스트의 잦은 탐색과 같은 순수한 연산 영역에만 투입해야 합니다.

워커를 매번 새로 생성하기

초기 구현 시 흔하게 관찰되는 실수로, HTTP 요청 라우터 안에서 워커 생성자를 직접 호출하여 사용한 뒤 버리는 패턴이 있습니다. 자바스크립트 가상 머신을 띄우고 메모리를 할당하는 일련의 과정은 무겁습니다. 접속 횟수와 비례하여 서버의 물리적 메모리를 순식간에 소진시키며 처리량마저 떨어뜨리므로, 반드시 예제처럼 풀을 만들어 재사용해야 합니다.

에러 처리를 누락하여 풀이 고갈되는 현상

데이터 형식이 잘못되어 워커 스크립트 내부에서 예외를 제대로 잡아내지 못하면 해당 스레드는 멈추고 파괴됩니다. 이때 풀에서 오류 이벤트를 잡아내지 못하면, 그 워커는 다시는 유휴 워커 목록으로 돌아오지 못합니다. 에러가 반복되면 사용 가능한 워커가 점차 줄어들고 결국 모든 연산 요청이 큐에 쌓인 채 무한정 대기하는 상황을 맞이합니다. 빈자리를 채울 교체 로직과 함께, 대기 중이던 프로미스를 실패로 처리해 주는 과정이 반드시 동반되어야 합니다.

한눈에 보기

적합한 작업 분류 (입출력과 CPU)
구분 특징 권장 처리 방식 예시
입출력 집약적 디스크나 네트워크 응답 대기가 대부분을 차지 메인 스레드 이벤트 루프 데이터베이스 조회, 외부 서버 응답 수신
CPU 집약적 서버의 연산 장치를 쉬지 않고 소모 워커 스레드로 위임 대규모 텍스트 검색 및 점수 계산, 암호화
단일 스레드와 워커 스레드 모델 비교
항목 메인 스레드 워커 스레드
주요 역할 네트워크 스케줄링, 통신 라우팅 및 전반적 제어 시간이 오래 걸리는 CPU 무거운 연산 전담
실행 환경 단일 메인 가상 환경 별도로 완전히 격리된 가상 환경과 메모리
데이터 교환 - 공유 없이 메시지를 복사 전송하는 방식

연습 문제

  1. 단일 스레드 구조에서 무거운 수학 연산을 메인 스레드에서 그대로 실행하면 웹 서버에 어떤 운영 상의 문제가 발생하는가?
  2. 작업 풀을 구축하지 않고 요청마다 스레드를 동적으로 만들어 낼 때 예상되는 성능 상의 불이익은 무엇인가?
  3. 두 스레드가 데이터를 복사하지 않고 한 곳의 메모리 영역을 직접 같이 사용할 수 있게 설계된 자바스크립트 객체는 무엇인가?

정답과 해설

  1. 이벤트 루프가 차단되어 응답 불능에 빠진다. 메인 스레드가 하나의 긴 연산에 묶이면 파일 읽기, 데이터베이스 통신 완료 신호 처리, 새로운 네트워크 요청 접수 등 다른 모든 비동기 작업의 흐름이 단절됩니다.
  2. 응답 속도 저하 및 메모리 고갈. 매 요청마다 자바스크립트 환경을 구성하는 막대한 초기화 시간이 소모되며, 동시 접속이 크게 늘어날 경우 한정된 서버 자원을 단숨에 모두 소진하여 프로세스가 비정상 종료될 수 있습니다.
  3. 공유 배열 버퍼 객체. 대량의 데이터를 매번 복사하는 비용을 아끼기 위해 사용하지만, 여러 스레드가 동시에 접근하여 값이 훼손되지 않도록 원자적 연산을 통한 철저하고 복잡한 동시성 제어가 필수적으로 뒤따라야 합니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.