웰제오의 개발 블로그

[Node.js] Thread Hang 을 야기할 수 있는 작업의 핸들링 (Promise.race, Worker Thread) 본문

개발

[Node.js] Thread Hang 을 야기할 수 있는 작업의 핸들링 (Promise.race, Worker Thread)

웰치스제로오렌지 2022. 10. 27. 13:26

대부분의 백엔드 시스템은 24시간, 365 일 쉬지 않고 돌아간다 (물론 서버리스 아키텍처로 구성된 시스템은 예외)
이러한 프로그램을 운영하다 보면 정말 기상천외한 이슈들을 계속 마주하게 되는데, 그 중에서는 정확한 원인파악을 통한 문제를 해결이 불가능한 상황에서, 우선 큰 그림에서 문제를 우회할 수 있는 방식으로 로직을 수정해 이슈를 해결하는 경우가 있었다.

이번 글에서는 이전에 경험했던 이슈를 바탕으로, Node.js 환경에서 스레드의 hang 을 야기할 수 있는 작업들을 어떻게 핸들링할 수 있는지 공유하려고 한다.


이슈 상황


필자가 운영했던 프로그램은 미션 크리티컬한 프로그램으로서, 해당 프로그램의 특성을 고려해, pre defined 된 에러상황이 아니라면, 발생하는 모든 예외상황에 대해 프로그램을 종료시키고 오케스트레이션 툴이 해당 프로그램을 재실행 시키는 시스템을 구성했다.
예외상황이 발생하면, 전역적으로 에러를 처리하는 콜백함수가 실행되었고, 해당 함수에서는 graceful shutdown 을 수행하며, 사용중이던 여러 리소스들을 반납하고 종료되는 과정이 포함되어 있었다.

어느 날, 운영중이던 백엔드 시스템의 lag 관련 지표가 점점 안좋아지기 시작했고, 경고 수준까지 도달하면서 원인 파악에 나서게 됐다.
한가지 특이한 점이었다면, 만약 프로그램에 문제가 발생했을 경우 이를 곧바로 종료하고 슬랙으로 알림을 보내게 설정해놨었는데,
지속적으로 문제가 발생하는 경우였다면 프로그램이 계속 종료가 되면서 알람이 엄청 와야 하는데 그러한 상황이 아니었다. 반신반의 하며 프로그램 상태를 확인하는 순간... 몇몇 작업에서 graceful shutdown 함수가 실행되었다는 마지막 로그와 함께 프로세스가 hanging 되어있는 것을 발견했다.
hanging 된 프로세스가 종료되질 못하면서 새로운 작업이 실행되지 못하고 있었고, 이로 인해 전체 처리량이 감소하면서 데이터 처리에 지연이 발생하게 된 상황이었다. 트러블 슈팅을 통해 graceful shutdown 수행 도중 호출되는 모든 메소드의 라이브러리들을 뜯어봤지만 이렇다 할 명쾌한 원인은 찾지 못했다.
로컬에서 상황을 재현해보려고 부단히 노력했지만 이마저도 재현이 되지 않았다.
따라서 원인에 대한 파악은 제쳐두고, 같은 상황이 또 발생했을 때 이를 어떻게 파해할 것 인지를 우선적으로 고민하기로 했다. Node.js 에서 작업을 크게 두가지로 분류한다면, 이는 비동기 작업과 동기 작업으로 나뉜다.
각각의 작업이 thread 의 hang 을 유발한다고 가정했을 때, 이를 어떻게 처리할지 살펴보자


비동기 작업의 처리


비동기 작업으로 인해 thread 가 hanging 되는 예시를 보자,

아래와 같은 코드에서는 비동기 작업인 asyncJobHangsThreadForLongTime 메소드로 인해 다음 컨텍스트로 넘어가지 못해, All jobs finished! 가 콘솔에 출력되지 않을 것 이다.

 

const asyncJobHangsThreadForLongTime = () => new Promise((res, _rej) => {
    setTimeout(() => res(), 100000000);
});

(async () => {
    await asyncJobHangsThreadForLongTime();
    // block...
    console.log("All jobs finished!");
})();


위와 같은 상황에선 Promise 의 race 메소드로 해결이 가능하다.

Promise.race 는 Promise 의 iterable 을 인자로 받아, 최초로 resolve or reject 된 Promise 개체를 리턴하는 메소드인데,

임계시간을 두어 해당 시간이 지나도 나머지 작업이 종료되지 않았을 때, block 되어 있던 컨텍스트를 다음으로 진행시킬 수 있게 응용이 가능하다.

const asyncJobHangsThreadForLongTime = () => new Promise((res, _rej) => {
    setTimeout(() => res(), 100000000);
});

const asyncJobFinishIn3Sec = () => new Promise((res, _rej) => {
    setTimeout(() => res(), 3000);
});

(async () => {
    const promises = [asyncJobHangsThreadForLongTime(), asyncJobFinishIn3Sec()];
    await Promise.race(promises);
    console.log("All jobs finished!");
})();


// 3초후... All jobs finished!

 

한가지 주의할 점이라면, Promise.race 는 인자로 넘겨받은 Promise 개체들 중 최초로 resolve or reject 된 Promise 의 결과를 리턴할 뿐이지, 나머지 Promise 들은 여전히 pending 상태로 존재한다

즉, 아직 작업이 남아있는 상태에서는 event loop 가 계속 작업들을 기다리게 되므로

해당 작업 이후 process 를 종료시키는게 목적이라면 뒤이어 process.exit() 을 명시적으로 호출 하거나

setTimeout 메소드를 작업 시작 이전에 호출해 일정 시간 후에 종료되게 끔 구성해야 한다.

 

const cutTheHang0 = async () => {
    await Promise.race([asyncJobHangsThreadForLongTime(), asyncJobFinishIn3Sec()]);
	
    // 이렇게 명시적으로 프로세스를 종료한다
    process.exit();
}

const cutTheHang1 = async () => {
    // 비동기 작업 이전에 setTimeout 의 콜백으로 process.exit() 을 등록

    setTimeout(() => process.exit(), 3000);
    await syncJobHangsThreadForLongTime();
    
    // 최대 3초안에 process 가 종료될 것
}



이렇게 해서 서술한 방법은 필자가 이슈를 해결하기 위해 고안했던 첫번째 해결책이었다.
사용중이던 라이브러리에서 문제가 될만한 부분들은 모두 비동기 작업이라 생각했고, 위와 같이 graceful shutdown 수행 직전, 10초의 유예시간을 가진 setTimeout 메소드를 실행함으로서 문제를 해결한 것 같았으나... 여전히 같은 이슈가 발생했다

로그를 확인해본 결과 setTimeout 함수는 정상적으로 실행이 된 상태였고,
그럼에도 불구하고 프로세스가 종료되지 않았다는 것은 (= setTimeout의 콜백함수가 실행되지 않았다는 것은) 누군가가 event loop를 block 하고 있다는 뜻이다
이러한 경우는 비동기 작업이 아닌 동기 작업에 의한 것이므로, 이에 대한 해결책을 다음 챕터에서 이어서 설명하겠다.


동기 작업의 처리


이제 thread 의 hang 을 유발하는 작업이 동기 작업일때의 상황을 알아보자.

동기 작업이 thread 의 hang 을 유발하는 경우는 infinite loop 혹은 child_process 모듈의 execSyncexecFileSync 와 같은 메소드들의 호출이 있다


비동기 작업의 처리 예시와 같은 setTimeout 메소드를 통해 콜백함수를 걸어놓아도, 해당 콜백함수는 event loop 가 timer phase 에 도달했을 때 실행되는 점을 고려하면,
poll phase, 혹은 nextTick queue 나 microtask queue 의 작업들로 인해 event loop 가 block 당해, timer phase 에 도달하지 못하게 되면 setTimeout 의 콜백함수는 실행될 수 없게 된다.

두가지 작업이 병렬적으로 수행되어야 하는 점을 고려할 때, 싱글스레드로 동작하는 Node.js 에서는 해결할 수 없는 문제같이 보여진다.
하지만 독립적인 event loop 를 가지는 워커 스레드를 활용함으로서 이의 해결이 가능하다.

이제, thread 의 hang 을 유발하는 작업 A 와, 몇초간의 유예기간 후 프로세스를 종료시키는 작업 B, 각각을 main 또는 worker 스레드에 할당해야 하는데
이들을 어디에 할당하느냐에 따라 의도한대로 동작할 수도, 하지 않을수도 있게 된다,

작업 A 를 main thread 에, 작업 B 를 worker thread 에 할당해보자

/*
    directory 구조는 다음과 같다
    
    root
     ㄴ index.js
     ㄴ worker.js
*/


// 📄 worker.js

setTimeout(() => process.exit(), 3000);

////////////////////////////////////////////////


// 📄 index.js

const { Worker } = require("worker_threads");

const hangThread = () => {
    while(true){}
}

const worker = new Worker(`${__dirname}/worker.js`);
hangThread();

 

index.js 를 실행시키면 어떤 결과나 나올까?


워커 스레드에서 3초 후 프로세스를 종료시키는 작업을 독립적인 이벤트 루프에서 실행 하므로
main 스레드가 hanging 된 상황에서 3초 후 프로세스가 종료될 것 이다.. 라는게 내 처음 추측이었다

허나 위 코드를 실행시켜보면, 3초가 지나도 process.exit 이 실행되지 않아 thread 가 hang 된다

이유인 즉슨, process.exit 은 Node.js 전체 프로세스를 종료시키지 않기 때문인데

https://nodejs.org/api/worker_threads.html#class-worker

 

위의 공식문서에 나와있듯이, process.exit 은 프로세스가 아닌 스레드를 종료시킨다고 한다 ( 그럼 이름을 thread.exit 으로 바꿔야 하는거 아닌가? )


따라서 워커 스레드에서 실행한 setTimeout 의 콜백함수가 실행되었어도, 이는 워커스레드만 종료시키지, 전체 process 를 종료시키지는 않는다.

이러한 이슈는 작업 thread 의 hang 을 유발하는 작업 A 를 메인 스레드가 아닌 워커 스레드에, 작업 B 를 main 스레드에 할당함으로서 해결이 가능하다

// 📄 worker.js

const hangThread = () => {
    while(true){}
}

hangThread();

////////////////////////////////////////////////


// 📄 index.js

const { Worker } = require("worker_threads");


setTimeout(() => process.exit(), 3000);
const worker = new Worker(`${__dirname}/worker.js`);


// 이제 3초 후에 프로세스가 종료된다



이렇게 해서 동기, 비동기 작업에 의한 thread hang 을 우회할 수 있는 방법들에 대해 알아보았다.
각각의 케이스에 대해 상이한 작업들을 처리해야 해 조금 번거로운 감이 없지않아 있지만, 이를 통해 정확한 원인이 파악이 가능하니, 트러블 슈팅에 보다 도움이 될 것 이다.


마지막으로, 위의 두가지 작업들을 한꺼번에 커버할 수 있는 방법을 소개하려 한다. 바로 health check 이다.


Health Check


health check 를 통해서 위에서 언급했던 두가지 상황과 더불어 그 외의 예외상황에 대한 범용적인 확인이 가능하다.

대표적인 방법으로는, Express 같은 라이브러리를 활용해, /healthCheck 와 같은 url 을 통해 특정 포트를 listen 하는 서버를 열어놓고
다른 process 에서 주기적으로 지정된 port 와 url 로 HTTP Request 를 전송하며, timeout 발생 시 해당 process 가 unhealthy 함을 알 수 있게 구성할 수 있다.

 

const getServerStatus = () => {
	// 서버 상태를 확인하고 boolean 을 리턴
}

const express = require("express");
const server = express();

server.get("/health", (req, res) => {
    const isServerHealthy = getServerStatus();

    if (isServerHealthy === false) {
      // 서버가 unhealthy 함
      return res.sendStatus(500);
    }

    return res.sendStatus(200);
});

const PORT = ...;

server.listen(PORT);


// 다른 process 에서 localhost:{PORT}/health 로 get 요청을 보내면서 상태 확인 가능



위처럼, 복잡한 작업이 아님에도 불구하고 필자는 이를 설정해 두지 않았었다.

운영하던 시스템 특성 상 실시간으로 데이터가 계속 처리되는지라, 문제가 발생했다면 이를 즉각적으로 알 수가 있어, 굳이 주기마다 시스템의 상태를 확인하는 health check 를 달아두지 않았던 것이 그 이유인데,
이는 나의 착각이었고, 오히려 실시간으로 데이터를 처리하는 프로그램인 만큼 더 꼼꼼하게 에러상황에 대응하기 위해 health check 를 달아주는게 맞았었다.

다만 health check 는 지연 혹은 thread 의 hang 이 발생할 경우, 사람의 직접적인 관리 없이 이와 같은 문제상황에 대한 24시간 대응이 가능하지만,
프로세스가 바로 종료되는 상황에서, 적절한 로그가 없다면 추후 트러블슈팅이 힘들어질 수 있다는 단점이 있으니 로그에도 신경을 많이 써야할 것 이다.




숱한 이슈들과 트러블 슈팅을 겪었지만, 아직도 갈길이 먼 것 같다
문제가 발생해 프로그램이 종료되는 상황은 염두했어도, 프로세스가 hanging 되어 이러지도, 저러지도 못하는 상황은 정말 생각도 못했었다.
또 한번의 문제를 겪으며 다시한번 겸손해지는 계기가 되었고, 신뢰할 수 있는 시스템을 설계하는 방법에 대해 한가지 더 배울 수 있었다.

Comments