Devin.KR

child_process 와 cluster - 프로세스로 확장하기

개발자KR 조회 16

이 장에서 배우는 것

이 장에서는 단일 프로세스로 동작하는 Node.js 애플리케이션의 한계를 극복하고, 시스템 자원을 효율적으로 다루는 방법을 배운다.

  • 외부 프로그램을 자식 프로세스로 실행하고 제어하는 방법
  • 명령어 주입(shell injection) 공격의 원리와 안전한 프로세스 생성 규칙
  • 표준 입출력 스트림(stdio)을 연결하여 대용량 데이터를 처리하는 파이프라인 구축
  • cluster 모듈을 활용하여 여러 CPU 코어에 서버 부하 분산하기
  • 서비스 중단 없이 애플리케이션을 갱신하는 무중단 재시작(zero-downtime restart)의 기본 원리

문제 상황

앞 장에서 중앙 처리 장치(CPU) 집약적인 작업을 별도의 스레드로 분리하여 주 이벤트 루프의 멈춤 현상을 해결했다. 하지만 로그 수집 서버의 트래픽이 꾸준히 늘어나면서 또 다른 한계에 부딪혔다.

현재 서버 장비는 8개의 코어를 가지고 있지만, 단일 Node.js 프로세스는 기본적으로 1개의 코어만 사용하여 이벤트를 처리한다. 요청이 아무리 많이 들어와도 나머지 7개의 코어는 유휴 상태로 남아 있어 시스템 자원을 낭비하고 트래픽을 온전히 감당하지 못한다.

또한, 서버에 들어온 방대한 양의 원본 로그를 실시간으로 압축하여 파일로 저장해야 하는 요구사항이 생겼다. Node.js 내장 라이브러리로 직접 압축을 수행할 수도 있지만, 운영체제에 이미 설치되어 있는 강력하고 검증된 gzip 유틸리티를 활용하면 구현을 단순화하고 Node.js 프로세스의 메모리 부담을 덜어낼 수 있다. 외부 프로그램을 실행하면서 데이터를 주고받고, 동시에 여러 코어를 활용해 다가오는 대규모 트래픽을 처리하는 구조로 서버를 확장해야 한다.

child_process로 외부 프로그램 실행하기

Node.js는 child_process 모듈을 통해 운영체제의 다른 프로그램을 실행하고 제어할 수 있다. 주로 사용하는 함수는 exec, execFile, spawn 세 가지다.

exec 함수는 전달받은 문자열을 통째로 운영체제의 셸(shell)에 넘겨 실행한다. 명령어 파이프라인이나 리다이렉션 같은 셸의 기능을 바로 쓸 수 있어 편리하지만, 사용자 입력값이 문자열에 섞여 들어갈 경우 심각한 보안 취약점인 셸 주입 공격에 노출된다. 예를 들어 압축할 파일명을 사용자로부터 받아 exec('gzip ' + filename)과 같이 구성했을 때, 사용자가 "a.txt; rm -rf /"라는 값을 넘기면 의도치 않은 파괴적인 명령이 함께 실행된다.

이를 방지하기 위해 execFile이나 spawn을 사용한다. 이 함수들은 셸을 거치지 않고 지정한 실행 파일을 직접 호출하며, 인수(argument)들을 배열 형태로 따로 전달받는다. 인수에 공백이나 세미콜론이 포함되어 있어도 셸의 문법으로 해석되지 않고 단일 문자열 값으로 안전하게 취급된다.

execFile은 외부 프로그램의 실행이 끝날 때까지 기다렸다가 전체 출력 결과를 한 번에 버퍼로 반환한다. 반면 spawn은 자식 프로세스를 생성한 직후 스트림(stream) 객체를 반환한다. 처리해야 할 데이터의 크기가 매우 크거나 끝을 알 수 없는 네트워크 요청을 다룰 때는 메모리 고갈을 막기 위해 반드시 스트림 기반의 spawn을 사용해야 한다.

표준 입출력 스트림(stdio)의 연결

운영체제의 모든 프로세스는 기본적으로 데이터를 주고받는 세 개의 통로를 가진다. 이를 표준 입력(stdin), 표준 출력(stdout), 표준 에러(stderr)라고 부른다.

spawn으로 생성한 자식 프로세스 객체는 이 세 가지 통로를 Node.js의 스트림 인터페이스로 제공한다. 자식 프로세스의 stdin은 데이터를 밀어 넣을 수 있는 쓰기 가능한 스트림(Writable)이고, stdout과 stderr는 데이터를 읽어 들일 수 있는 읽기 가능한 스트림(Readable)이다.

로그 수집 서버로 들어오는 HTTP POST 요청 객체(req) 역시 읽기 가능한 스트림이다. 따라서 req 스트림을 자식 프로세스의 stdin으로 파이프(pipe) 연결하고, 자식 프로세스의 stdout을 다시 파일 시스템의 쓰기 스트림으로 파이프 연결하면 메모리 버퍼를 거의 소비하지 않고도 원활하게 데이터를 압축 프로그램으로 흘려보내 저장할 수 있다.

HTTP 요청 스트림이 Node.js를 거쳐 gzip 프로세스로 파이프되고 파일로 저장되는 흐름

cluster 모듈과 무중단 재시작 원리

cluster 모듈은 내부적으로 child_process.fork를 활용하여 현재 실행 중인 파일과 동일한 코드를 수행하는 워커(worker) 프로세스들을 만든다. 가장 먼저 실행된 부모 프로세스를 프라이머리(primary) 프로세스라고 부르며, 프라이머리는 실제 HTTP 요청을 처리하는 대신 워커 프로세스의 생명 주기를 관리한다.

일반적으로 운영체제에서 여러 프로세스가 동일한 네트워크 포트를 동시에 점유할 수 없다. 하지만 cluster 모듈을 사용하면 프라이머리 프로세스가 먼저 포트를 열어 운영체제로부터 연결을 받아내고, 내부적인 프로세스 간 통신(IPC)을 통해 라운드 로빈(round-robin) 방식으로 워커 프로세스들에게 연결을 고르게 나누어준다. 결과적으로 개발자는 코드 몇 줄만 추가하여 여러 코어를 모두 활용하는 병렬 처리 서버를 구축할 수 있다.

이 구조는 애플리케이션 코드가 업데이트되었을 때 서비스 중단을 막는 무중단 재시작을 가능하게 한다. 단일 프로세스 서버는 새로운 코드를 반영하려면 프로세스를 끄고 다시 켜야 하므로 그사이 들어오는 접속은 모두 유실된다. 반면 클러스터 환경에서는 프라이머리 프로세스가 새로운 코드를 담은 새 워커 프로세스를 먼저 띄운다. 새 워커가 접속을 받을 준비가 끝나면, 프라이머리는 기존 워커에게 더 이상 새로운 요청을 받지 말고 현재 처리 중인 요청만 마무리한 뒤 종료하라고 지시(정상 종료)한다. 이 과정을 순차적으로 반복하면 사용자는 어떤 끊김 현상도 겪지 않는다.

프라이머리 프로세스가 새 워커를 띄우고 이전 워커를 안전하게 종료하여 무중단 배포를 달성하는 과정

완성 코드

server.mjs

import cluster from 'node:cluster';
import http from 'node:http';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';

const PORT = 3000;
const LOG_DIR = './logs';

if (cluster.isPrimary) {
  console.log(`프라이머리 프로세스(PID: ${process.pid})가 시작되었다.`);

  if (!fs.existsSync(LOG_DIR)) {
    fs.mkdirSync(LOG_DIR, { recursive: true });
  }

  // 논리 코어 개수만큼 워커 프로세스를 생성한다.
  const numCPUs = os.cpus().length;
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 워커가 예기치 않게 종료되면 새로운 워커를 띄워 개수를 유지한다.
  cluster.on('exit', (worker, code, signal) => {
    console.log(`워커(PID: ${worker.process.pid})가 종료되었다.`);
    if (!worker.exitedAfterDisconnect) {
      console.log('새 워커를 시작한다.');
      cluster.fork();
    }
  });

  // SIGUSR2 신호를 받으면 워커를 순차적으로 무중단 교체한다.
  process.on('SIGUSR2', () => {
    console.log('무중단 재시작을 시작한다.');
    const workers = Object.values(cluster.workers);
    let currentIndex = 0;

    function replaceNextWorker() {
      if (currentIndex >= workers.length) {
        console.log('모든 워커 교체가 완료되었다.');
        return;
      }
      
      const oldWorker = workers[currentIndex];
      const newWorker = cluster.fork();

      newWorker.on('listening', () => {
        console.log(`새 워커(PID: ${newWorker.process.pid}) 준비 완료. 이전 워커(PID: ${oldWorker.process.pid}) 종료 지시.`);
        oldWorker.disconnect();
        currentIndex++;
        replaceNextWorker();
      });
    }
    replaceNextWorker();
  });

} else {
  // 워커 프로세스 로직: 실제 HTTP 서버를 구동한다.
  const server = http.createServer((req, res) => {
    if (req.method === 'POST' && req.url === '/log') {
      const timestamp = Date.now();
      const fileName = path.join(LOG_DIR, `log-${timestamp}-${process.pid}.gz`);
      
      // 운영체제의 gzip 유틸리티를 자식 프로세스로 실행한다.
      const gzipProcess = spawn('gzip', ['-c']);
      const fileStream = fs.createWriteStream(fileName);

      // HTTP 요청 스트림을 gzip의 표준 입력으로, 
      // gzip의 표준 출력을 파일 스트림으로 연결한다.
      req.pipe(gzipProcess.stdin);
      gzipProcess.stdout.pipe(fileStream);

      gzipProcess.on('exit', (code) => {
        if (code === 0) {
          res.writeHead(201, { 'Content-Type': 'text/plain; charset=utf-8' });
          res.end('로그가 압축되어 저장되었다.\n');
        } else {
          res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
          res.end('압축 과정에서 오류가 발생했다.\n');
        }
      });

      req.on('error', (err) => {
        console.error('요청 스트림 오류:', err);
        gzipProcess.kill();
      });
      
      gzipProcess.on('error', (err) => {
        console.error('gzip 실행 오류:', err);
        res.writeHead(500);
        res.end('서버 내부 오류\n');
      });

    } else {
      res.writeHead(404);
      res.end('Not Found\n');
    }
  });

  server.listen(PORT, () => {
    console.log(`워커(PID: ${process.pid})가 포트 ${PORT}에서 대기 중이다.`);
  });

  // 프라이머리로부터 종료 지시(disconnect)를 받으면 정상 종료를 수행한다.
  process.on('disconnect', () => {
    console.log(`워커(PID: ${process.pid})가 새로운 연결을 차단한다.`);
    server.close(() => {
      console.log(`워커(PID: ${process.pid})가 남은 작업을 마치고 종료한다.`);
      process.exit(0);
    });
  });
}

줄별 해설

  • cluster.isPrimary: 현재 코드를 실행하는 프로세스가 처음 실행된 프라이머리 프로세스인지 여부를 확인한다. 이 속성을 기준으로 프라이머리 로직과 워커 로직을 분기한다.
  • os.cpus().length: 현재 시스템의 논리 코어 개수를 가져온다. 코어 개수만큼 cluster.fork()를 호출하여 워커 프로세스를 띄운다.
  • worker.exitedAfterDisconnect: 프라이머리가 의도적으로 disconnect()를 호출하여 워커가 종료된 것인지 나타내는 불리언(boolean) 값이다. 이 값이 거짓(false)이면 프로세스가 오류로 튕긴 것이므로 새 워커를 띄워 복구한다.
  • process.on('SIGUSR2', ...): 운영체제로부터 사용자 정의 시그널을 받았을 때 실행할 콜백을 등록한다. 여기서는 무중단 교체의 트리거로 사용했다.
  • spawn('gzip', ['-c']): gzip 명령어를 실행한다. -c 인수는 압축 결과를 원본 파일 복사 없이 표준 출력(stdout)으로 내보내도록 지시한다.
  • req.pipe(gzipProcess.stdin): 네트워크로 들어오는 원본 로그 데이터를 메모리에 모으지 않고 곧바로 자식 프로세스의 압축 프로그램으로 흘려보낸다.
  • server.close(...): HTTP 서버가 더 이상 새로운 접속을 받지 않도록 닫는다. 이미 연결되어 처리 중인 요청들이 모두 응답을 반환하고 나면 콜백 함수가 실행되며, 이때 프로세스를 완전히 종료(process.exit(0))한다.

실행 결과

터미널에서 메인 파일을 실행하면 프라이머리 프로세스와 워커 프로세스들이 뜬다.

$ node server.mjs
프라이머리 프로세스(PID: 10001)가 시작되었다.
워커(PID: 10002)가 포트 3000에서 대기 중이다.
워커(PID: 10003)가 포트 3000에서 대기 중이다.
...

다른 터미널 창을 열어 curl로 로그 데이터를 전송해 본다.

$ curl -X POST -d "user=123&action=login" http://localhost:3000/log
로그가 압축되어 저장되었다.

logs 폴더 안에 생성된 압축 파일의 내용을 확인한다.

$ gzcat logs/log-1718000000000-10002.gz
user=123&action=login

실무에서 자주 틀리는 것

셸 주입 취약점 방치

외부 명령어를 호출할 때 사용자 입력을 문자열 덧셈 연산으로 결합하면 심각한 보안 문제가 생긴다.

// 틀린 코드 - 사용자가 입력한 fileName에 세미콜론(;)을 넣어 다른 명령어를 실행할 수 있다.
exec('gzip ' + req.body.fileName, (err, stdout) => { ... });

// 고친 코드 - spawn이나 execFile을 사용하고 인수를 배열로 분리한다.
spawn('gzip', [req.body.fileName]);

스트림 파이프라인 에러 처리 누락

네트워크 불안정으로 인해 클라이언트가 요청 도중 연결을 끊으면 req 스트림에 오류가 발생한다. 이때 파이프라인으로 연결된 자식 프로세스를 명시적으로 종료해주지 않으면, 자식 프로세스는 입력이 언제 들어올지 모른 채 무한정 대기하며 시스템 자원을 갉아먹는다.

// 틀린 코드 - req 스트림이 끊어졌을 때의 대비가 없다.
req.pipe(gzipProcess.stdin);
gzipProcess.stdout.pipe(fileStream);

// 고친 코드 - 에러 발생 시 자식 프로세스를 강제로 종료한다.
req.on('error', (err) => {
  gzipProcess.kill(); 
});

코어 수보다 많은 과도한 워커 생성

동시 처리량을 늘리기 위해 논리 코어 개수를 무시하고 무작정 워커를 수십 개씩 띄우는 경우가 있다. Node.js는 싱글 스레드 특성상 CPU 코어를 지속적으로 점유하며 연산을 수행하므로, 코어 수보다 프로세스가 많아지면 운영체제가 프로세스를 번갈아 실행하기 위해 문맥 교환(context switching)을 빈번하게 일으켜 오히려 전체 성능이 심각하게 떨어진다. 워커 수는 시스템의 논리 코어 수(os.cpus().length)와 일치시키는 것이 가장 이상적이다.

한눈에 보기

child_process 명령어 실행 함수 비교
함수명 셸(Shell) 경유 여부 반환 방식 주요 사용처
exec 예 (보안 취약 주의) 전체 결과를 버퍼 메모리로 반환 간단한 셸 명령어 파이프라인(ls | grep 등) 실행
execFile 아니오 전체 결과를 버퍼 메모리로 반환 출력량이 적고 셸의 기능이 필요 없는 단일 실행 파일 호출
spawn 아니오 결과를 스트림(Stream)으로 반환 출력 데이터가 크거나, 지속적으로 데이터를 주고받아야 할 때
비동기 처리 확장 방식 비교
방식 사용 모듈 메모리 공유 적합한 상황
스레드 생성 worker_threads 배열 버퍼를 통해 직접 공유 가능 이미지 처리, 암호화 등 단일 프로세스 내의 CPU 집약적 연산
서버 복제 cluster 공유 불가 (IPC 메시지로 통신) 네트워크 트래픽 증가에 따른 포트 공유 및 서버 부하 분산
외부 프로그램 child_process 공유 불가 (표준 입출력으로 통신) 시스템 명령어(grep, gzip)나 다른 언어로 작성된 프로그램 연동

연습 문제

  1. 다음 중 사용자가 입력한 이미지 파일명을 외부 프로그램(convert)에 넘길 때 셸 주입 공격으로부터 가장 안전하며, 파일 용량이 커도 메모리 부족 현상을 피할 수 있는 방법은 무엇인가?
    ① exec(`convert ${filename} output.jpg`)
    ② execFile('convert', [filename, 'output.jpg'])
    ③ spawn('convert', [filename, 'output.jpg'])
    ④ fork('convert', [filename, 'output.jpg'])
  2. cluster 모듈을 사용하여 4개의 워커 프로세스를 띄웠을 때, 클라이언트 요청을 받아 각 워커에게 분배하는 주체는 누구인가?
    ① 운영체제의 네트워크 커널
    ② 프라이머리 프로세스
    ③ 가장 먼저 생성된 1번 워커 프로세스
    ④ Nginx나 HAProxy 같은 외부 리버스 프록시
  3. 무중단 재시작을 수행할 때, 가장 올바른 프로세스 처리 순서를 고르시오.
    ① 이전 워커 프로세스 즉시 강제 종료(kill) → 새 워커 프로세스 생성 → 클라이언트 연결
    ② 새 워커 프로세스 생성 → 새 워커 준비 완료 → 이전 워커에 새로운 연결 차단 지시(disconnect) → 이전 워커 남은 요청 처리 후 종료
    ③ 이전 워커에 새로운 연결 차단 지시(disconnect) → 이전 워커 종료 대기 → 새 워커 프로세스 생성
    ④ 서버를 유지한 상태에서 프라이머리 프로세스만 재시작하여 새로운 코드 반영

정답과 해설

  1. 정답: ③
    해설: exec는 셸을 통하므로 주입 공격 위험이 있다. execFile은 셸 주입에서는 안전하지만 결과를 한 번에 메모리에 버퍼링하므로 용량이 큰 파일을 다룰 때 메모리 부족을 유발할 수 있다. 따라서 스트림 기반으로 동작하며 배열로 인수를 전달하는 spawn을 사용하는 것이 가장 안전하고 효율적이다.
  2. 정답: ②
    해설: Node.js의 cluster 모듈은 프라이머리 프로세스가 먼저 포트를 열고 연결을 대기하다가, 요청이 들어오면 라운드 로빈 방식을 통해 워커 프로세스들에게 작업을 분배한다.
  3. 정답: ②
    해설: 무중단 재시작은 서비스를 끊김 없이 유지해야 하므로, 반드시 트래픽을 넘겨받을 새 워커를 먼저 생성하고 준비시켜야 한다. 그 후 이전 워커는 강제 종료하는 것이 아니라, 더 이상 새 연결을 받지 않도록 처리한 후 현재 처리 중인 응답만 마저 보내고 스스로 종료하도록 유도(정상 종료)해야 한다.

댓글 0

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

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