Node.js (노드제이에스) 프로세스 종료 전략: 시그널, 오류 그리고 우아한 종료 완벽 가이드

Node.js (노드제이에스) 프로세스 종료 전략: 시그널, 오류 그리고 우아한 종료 완벽 가이드

배경 소개

우리 서비스가 배포된 후에는 런타임 환경(예: 컨테이너, PM2 (피엠투) 등)에 의해 스케줄링되거나, 서비스 업그레이드로 인해 재시작되거나, 다양한 예외 상황으로 인해 프로세스가 갑자기 중단되는 일을 피할 수 없습니다.

일반적으로 런타임 환경에는 서비스 프로세스에 대한 상태 모니터링 메커니즘이 있어서, 프로세스가 중단되면 런타임이 이를 다시 시작합니다.

업그레이드 시에는 보통 롤링 업그레이드 전략을 사용합니다.

하지만 런타임 환경의 스케줄링 전략은 우리 서비스 프로세스를 내부 상태를 고려하지 않는 '블랙박스'처럼 취급합니다.

따라서 우리 서비스 프로세스는 런타임 환경으로부터의 스케줄링 동작을 사전에 감지하고 종료하기 전에 필요한 정리 작업을 수행해야 합니다.

오늘은 Node.js (노드제이에스) 프로세스를 종료시키는 다양한 시나리오를 요약하고, 이러한 프로세스 종료 이벤트를 수신하여 우리가 할 수 있는 일들에 대해 논의해 보겠습니다.

원칙

프로세스는 다음 두 가지 방법 중 하나로 종료됩니다.

프로세스가 자발적으로 종료합니다.


프로세스가 종료하라는 시스템 시그널을 받습니다.

시스템 시그널을 통한 종료

Node.js (노드제이에스) 공식 문서에는 일반적인 시스템 시그널 목록이 나와 있습니다.

여기서는 다음에 초점을 맞추겠습니다.

SIGHUP (씨그헙): Ctrl + C (컨트롤 씨)를 사용하여 프로세스를 중지하는 대신 터미널을 직접 닫을 때 발생합니다.


SIGINT (씨그인트): Ctrl + C (컨트롤 씨)를 눌러 프로세스를 중지할 때 발생합니다.

PM2 (피엠투)도 재시작하거나 중지할 때 자식 프로세스에 이 시그널을 보냅니다.


SIGTERM (씨그텀): 일반적으로 프로세스를 정상적으로 종료하는 데 사용됩니다.

예를 들어, Kubernetes (쿠버네티스)가 파드(pod)를 삭제할 때, 파드가 타임아웃 기간(기본값: 30초) 내에 정리 작업을 수행할 수 있도록 SIGTERM (씨그텀) 시그널을 보냅니다.


SIGBREAK (씨그브레이크): Windows (윈도우) 시스템에서 Ctrl + Break (컨트롤 브레이크)를 누를 때 발생합니다.


SIGKILL (씨그킬): 프로세스를 즉시 강제 종료하여 어떠한 정리 작업도 수행할 수 없게 합니다.

kill -9 pid를 실행하면 프로세스가 이 시그널을 받습니다.

Kubernetes (쿠버네티스)에서는 파드가 30초 타임아웃 내에 종료되지 않으면 Kubernetes (쿠버네티스)가 SIGKILL (씨그킬) 시그널을 보내 즉시 종료합니다.

마찬가지로 PM2 (피엠투)도 재시작 또는 종료 중에 프로세스가 1.6초 내에 종료되지 않으면 SIGKILL (씨그킬)을 보냅니다.

강제 종료가 아닌 시그널의 경우, Node.js (노드제이에스) 프로세스는 이러한 시그널을 수신하고 사용자 정의 종료 동작을 정의할 수 있습니다.

예를 들어, 작업 실행에 오랜 시간이 걸리는 CLI (씨엘아이) 도구가 있다면, Ctrl + C (컨트롤 씨)를 눌렀을 때 종료하기 전에 사용자에게 확인 메시지를 표시할 수 있습니다.

const readline = require('readline'); // readline 모듈을 가져옵니다.

// SIGINT 시그널(Ctrl+C)을 수신했을 때 실행될 콜백 함수를 등록합니다.
process.on('SIGINT', () => {
  // readline을 사용하여 간단한 명령줄 상호작용을 설정합니다.
  const rl = readline.createInterface({
    input: process.stdin, // 표준 입력
    output: process.stdout, // 표준 출력
  });

  // 사용자에게 종료 여부를 묻는 질문을 표시합니다.
  rl.question('The task is not yet complete. Are you sure you want to exit? ', (answer) => {
    if (answer === 'yes') { // 사용자가 'yes'라고 답하면
      console.log('Task interrupted. Exiting process.'); // 작업 중단 메시지를 출력하고
      process.exit(0); // 프로세스를 정상 종료합니다.
    } else { // 그렇지 않으면
      console.log('Task continues...'); // 작업 계속 메시지를 출력합니다.
    }
    rl.close(); // readline 인터페이스를 닫습니다.
  });
});

// 1분 동안 실행되는 작업을 시뮬레이션합니다.
const longTimeTask = () => {
  console.log('Task started...'); // 작업 시작 메시지를 출력합니다.
  setTimeout(() => {
    console.log('Task completed.'); // 60초 후 작업 완료 메시지를 출력합니다.
  }, 1000 * 60);
};

longTimeTask(); // 작업을 시작합니다.



이 스크립트는 Ctrl + C (컨트롤 씨)를 누를 때마다 프롬프트를 표시합니다.

작업이 아직 완료되지 않았습니다.

정말로 종료하시겠습니까? no


작업을 계속합니다...

작업이 아직 완료되지 않았습니다.

정말로 종료하시겠습니까? no


작업을 계속합니다...

작업이 아직 완료되지 않았습니다.

정말로 종료하시겠습니까? yes


작업이 중단되었습니다.

프로세스를 종료합니다.

자발적인 프로세스 종료

Node.js (노드제이에스) 프로세스는 다음과 같은 시나리오로 인해 자발적으로 종료될 수 있습니다.

실행 중 처리되지 않은 오류 발생 (process.on('uncaughtException') (프로세스 점 온 언컷익셉션)을 사용하여 캡처 가능).


처리되지 않은 Promise (프로미스) 거부 발생 (Node.js (노드제이에스) v16부터 처리되지 않은 거부는 프로세스를 종료시킴; process.on('unhandledRejection') (프로세스 점 온 언핸들드리젝션)을 사용하여 처리).


EventEmitter (이벤트이미터)에서 오류 이벤트가 발생했지만 처리되지 않음.


프로세스가 명시적으로 process.exit() (프로세스 점 엑시트)를 호출함.


Node.js (노드제이에스) 이벤트 루프가 비어 있음 (즉, 대기 중인 작업이 없음), process.on('exit') (프로세스 점 온 엑시트)를 사용하여 감지 가능.

PM2 (피엠투)에는 서비스가 충돌하면 다시 시작하는 데몬 프로세스가 있습니다.

Node.js (노드제이에스)에서도 cluster (클러스터) 모듈을 사용하여 유사한 자가 치유 메커니즘을 구현할 수 있으며, 작업자 프로세스가 충돌하면 자동으로 다시 시작됩니다.

const cluster = require('cluster'); // cluster 모듈을 가져옵니다.
const http = require('http'); // http 모듈을 가져옵니다.
const numCPUs = require('os').cpus().length; // CPU 코어 수를 가져옵니다.
const process = require('process'); // process 모듈을 가져옵니다.

if (cluster.isMaster) { // 현재 프로세스가 마스터 프로세스인 경우
  console.log(`Master process started: ${process.pid}`); // 마스터 프로세스 ID를 출력합니다.

  // CPU 코어 수만큼 작업자 프로세스를 생성합니다.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 작업자 프로세스 종료 이벤트를 수신합니다.
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} exited with code: ${code || signal}. Restarting...`); // 종료된 작업자 정보와 재시작 메시지를 출력합니다.
    cluster.fork(); // 새로운 작업자 프로세스를 생성합니다.
  });
}

if (cluster.isWorker) { // 현재 프로세스가 작업자 프로세스인 경우
  // 처리되지 않은 예외 이벤트를 수신합니다.
  process.on('uncaughtException', (error) => {
    console.log(`Worker ${process.pid} encountered an error`, error); // 오류 발생 작업자 정보와 오류 내용을 출력합니다.
    process.emit('disconnect'); // 연결 끊김 이벤트를 발생시킵니다.
    process.exit(1); // 비정상 종료 코드로 프로세스를 종료합니다.
  });

  // HTTP 서버를 생성합니다.
  http
    .createServer((req, res) => {
      res.writeHead(200); // 응답 헤더를 설정합니다.
      res.end('Hello world\n'); // 응답 본문을 전송하고 연결을 종료합니다.
    })
    .listen(8000); // 8000번 포트에서 수신 대기합니다.

  console.log(`Worker process started: ${process.pid}`); // 작업자 프로세스 ID를 출력합니다.
}



실제 구현

이제 Node.js (노드제이에스) 프로세스가 종료될 수 있는 다양한 시나리오를 분석했으므로, 사용자가 사용자 정의 종료 동작을 정의할 수 있는 프로세스 종료 리스너를 구현해 보겠습니다.

// exit-hook.js
const tasks = []; // 종료 시 실행할 작업들을 저장할 배열입니다.

// 종료 작업을 추가하는 함수입니다.
const addExitTask = (fn) => tasks.push(fn);

// 종료를 처리하는 함수입니다.
const handleExit = (code, error) => {
  // 구현 세부 정보는 아래에서 설명합니다.
};

// 다양한 프로세스 종료 이벤트에 대해 handleExit 함수를 등록합니다.
process.on('exit', (code) => handleExit(code)); // 일반적인 종료 이벤트
process.on('SIGHUP', () => handleExit(128 + 1)); // SIGHUP 시그널 (터미널 닫힘)
process.on('SIGINT', () => handleExit(128 + 2)); // SIGINT 시그널 (Ctrl+C)
process.on('SIGTERM', () => handleExit(128 + 15)); // SIGTERM 시그널 (정상 종료 요청)
process.on('SIGBREAK', () => handleExit(128 + 21)); // SIGBREAK 시그널 (Windows Ctrl+Break)
process.on('uncaughtException', (error) => handleExit(1, error)); // 처리되지 않은 예외
process.on('unhandledRejection', (error) => handleExit(1, error)); // 처리되지 않은 Promise 거부



handleExit (핸들엑시트)의 경우, process.nextTick() (프로세스 점 넥스트틱)을 사용하여 동기 및 비동기 작업이 모두 올바르게 처리되도록 합니다.

let isExiting = false; // 현재 종료 중인지 여부를 나타내는 플래그입니다.

const handleExit = (code, error) => {
  if (isExiting) return; // 이미 종료 중이면 아무것도 하지 않습니다.
  isExiting = true; // 종료 중 플래그를 true로 설정합니다.

  let hasDoExit = false; // process.exit()가 호출되었는지 여부를 나타내는 플래그입니다.
  const doExit = () => {
    if (hasDoExit) return; // 이미 호출되었으면 아무것도 하지 않습니다.
    hasDoExit = true; // 호출됨 플래그를 true로 설정합니다.
    // 다음 틱에서 process.exit()를 호출하여 현재 이벤트 루프의 작업들이 완료될 시간을 줍니다.
    process.nextTick(() => process.exit(code));
  };

  let asyncTaskCount = 0; // 비동기 작업의 수를 추적합니다.
  // 비동기 작업 완료 시 호출될 콜백 함수입니다.
  let asyncTaskCallback = () => {
    process.nextTick(() => {
      asyncTaskCount--; // 비동기 작업 수를 줄입니다.
      if (asyncTaskCount === 0) doExit(); // 모든 비동기 작업이 완료되면 프로세스를 종료합니다.
    });
  };

  // 등록된 모든 종료 작업을 실행합니다.
  tasks.forEach((taskFn) => {
    if (taskFn.length > 1) { // 작업 함수가 콜백을 받는 비동기 작업인 경우
      asyncTaskCount++; // 비동기 작업 수를 늘립니다.
      taskFn(error, asyncTaskCallback); // 오류 정보와 콜백 함수를 전달하여 실행합니다.
    } else { // 동기 작업인 경우
      taskFn(error); // 오류 정보를 전달하여 실행합니다.
    }
  });

  if (asyncTaskCount > 0) { // 실행 중인 비동기 작업이 있는 경우
    // 10초 후 강제로 종료합니다 (타임아웃).
    setTimeout(() => doExit(), 10 * 1000);
  } else { // 동기 작업만 있거나 비동기 작업이 없는 경우
    doExit(); // 즉시 프로세스를 종료합니다.
  }
};



우아한 프로세스 종료

웹 서버를 재시작하거나 런타임 컨테이너 스케줄링(PM2 (피엠투), Docker (도커) 등)을 처리할 때, 우리는 다음을 원합니다.

진행 중인 요청 완료.


데이터베이스 연결 정리.


오류 기록 및 알림 발생.


기타 필요한 종료 작업 수행.


exit-hook (엑시트훅) 도구 사용 예시:

const http = require('http'); // http 모듈을 가져옵니다.

// HTTP 서버를 생성하고 8000번 포트에서 수신 대기합니다.
const server = http
  .createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello world\n');
  })
  .listen(8000);

// addExitTask는 위에서 정의한 exit-hook.js의 함수라고 가정합니다.
// 프로세스 종료 시 실행될 비동기 작업을 추가합니다.
addExitTask((error, callback) => {
  console.log('Process exiting due to error:', error); // 오류로 인해 프로세스가 종료됨을 알립니다.
  // HTTP 서버를 닫아 새로운 요청을 받지 않도록 합니다.
  server.close(() => {
    console.log('Stopped accepting new requests.'); // 새로운 요청 수신 중단 메시지를 출력합니다.
    // 5초 후 콜백 함수를 호출하여 프로세스 종료를 진행합니다.
    // 이는 진행 중인 요청을 처리할 시간을 주기 위함입니다.
    setTimeout(callback, 5000);
  });
});



결론

Node.js (노드제이에스) 프로세스를 종료시키는 다양한 시나리오를 이해함으로써, 우리는 비정상적이거나 예정된 종료를 사전에 감지하고 처리할 수 있습니다.

Kubernetes (쿠버네티스) 및 PM2 (피엠투)와 같은 도구가 충돌한 프로세스를 다시 시작할 수 있지만, 코드 내 모니터링을 구현하면 문제를 더 일찍 감지하고 해결할 수 있습니다.