Promise 를 이용한 병렬처리시 주의사항

Promise를 이용한 병렬 처리 시 발생할 수 있는 unhandledRejection 문제의 원인과 해결 방법을 자세히 알아봅니다.

Contents


문제 상황

  운영중인 DB 에서 작업이 진행되어있는데, 작업이 진행되는 시점에 Node.js 서버에서 unhandledRejection 가 발생하였다. 서버가 운영중인 상황에서 DB 작업이 이루어지면, 경우에 따라 일시적으로 API 가 실패할 수 있다. 하지만 unhandledRejection 이 발생하는 것으 비정상적인 상황이라고 볼 수 있다. 특히 Node.js 에서 unhandledRejection 이 발생하면 기본적으로 Process 가 죽기 때문에 구체적인 원인을 파악해 보았다.


원인 분석

  문제가 발생하던 시점은 DB 작업이 이루어지던 시점이었으므로, 가장 먼저 DB Driver 및 ORM 을 의심해 보았다. 팀에서는 MikroORM 을 사용하고 있었다. 다행히 MikroORM 의 QueryBuilder.execute() 를 몽키패치(DB 커넥션이 원활하지 않는 것처럼 코드 수정)하였더니, 해당 현상을 재현할 수 있었다. DB 작업이 진행되던 시점에 문제가 발생하였고, ORM 라이브러리를 몽키패치한 후에 해당 현상이 재현되었으므로 DB 라이브러리의 문제라고 생각하였다. 하지만 삽질을 반복한 후에, 이 이슈는 Promise 와 관련되어 있다는 사실을 알게 되었다.

먼저 예시에 사용될 간단한 함수들을 살펴보자.

async function bomb() {
  throw new Error("bomb");
}


비동기적으로 에러를 발생시키는 함수이다. 문제가 되었던 상황은 DB 커넥션을 올바르게 반환받지 못하여 에러가 발생하였지만, 이를 단순화하기 위해 간단한 함수로 만들어 보았다. 그렇다면, 다음과 같은 코드들을 살펴보자

async function main() {
  try {
    // 1 번 상황
    await Promise.all([await bomb(), bomb()]);

    // 2 번 상황
    await Promise.all([bomb(), await bomb()]);  
  } catch (e) {
    console.log("에러가 정상적으로 Catch 되었습니다");
  }
}

main();


얼핏 보면 별다른 문제가 없다고 생각할 수도 있는 코드들이다. Javascript 의 Promise.all() 은 Promise 뿐만 아니라 일반적인 값들도 받아들일 수 있으므로, bomb() 또는 await bomb() 모두 제대로 처리할 수 있다고 생각하였다. 하지만 위의 비슷한 2가지 코드 중 한 코드는 unhandledRejection 을 발생시킨다. 문제를 발생시키는 코드는 2번 코드이다.

1번을 실행한 결과


2번을 실행한 결과


  비슷하게 생긴 코드인데, 왜 한 코드만 문제를 발생시키는 것일까 ? Promise.all() 구문을 한 줄 한 줄 나누어 생각하면 답을 찾을 수 있다. 위의 코드를 다음과 같이 바꾸어 보자.


async function main() {
  try {
    // 1 번 상황
    const bomb1 = await bomb();
    const bomb2 = bomb();
    await Promise.all([bomb1, bomb2]);

    // 2 번 상황
    const bomb3 = bomb();
    const bomb4 = await bomb();
    await Promise.all([bomb3, bomb4]);  
  } catch (e) {
    console.log("에러가 정상적으로 Catch 되었습니다");
  }
}

main();


이렇게 코드를 스텝별로 나누어 보면, 문제를 금방 발견할 수 있을 것이다. 1번 상황에서는 bomb1 를 평가하는 과정에서 바로 에러가 발생하여, catch 절에서 에러가 핸들링되게 된다. 2번 상황에서는 bomb3 은 Promise 로 비동기적으로 평가된 후, bomb4 를 평가하게 된다. bomb4 를 평가하고, Promise.all 을 평가하는 과정에서 bomb3 는 에러가 발생하여 reject 되게 될 것이다. 이 경우, bomb3 의 Promise Rejection 이 올바르게 처리되지 않는다. (비동기 작업이 올바르게 핸들링되지 않으면, catch 절에 잡히지 않기 때문이다.) 따라서 Node.js 프로세스에서는 unhandledRejection 이 발생하고, Node.js Process 는 죽게 된다.


조치사항


  그렇다면 어떻게 이런 상황을 개선할 수 있을까 ? 먼저 1. Promise.all 에는 Promise 인자만 넘기고, 2. Node.js 프로세스에서 unhandledRejection Event Listener 를 구현하면 된다. 위에서 발견한 문제는 모두 Promise.all 의 인자로는 Promise 만 넘기면 발생할 수 없는 이슈이다. 따라서 Promise.all 에는 가급적이면 Promise 만 넘기는 것이 좋다. 추가로, Node.js Process 의 unhandledRejection Event Listener 은 다음과 같이 간단하게 구현할 수 있다.

/*
 * Node.js 서버에서 unhandledRejection 가 발생하면, Datadog 에 로그를 남김. 서버는 죽지 않는다.
 * @see - https://github.com/inflearn/inflearn-backend/pull/1736/files
 */
function setProcessListener(logger: Logger) {
  process.on('unhandledRejection', (reason: Error) => {
    const message = `Unhandled Rejection Exception: reason=${reason.toString()}, stack=${
      reason?.stack
    }`;
    logger.error(message);
  });
}


위와 같은 Event Listener 를 Node.js 서버가 기동될 때 실행시키도록 한다면, 설사 unhandledRejection 이 발생하여도 안정적으로 서버를 운영할 수 있다. 사실 위와 같은 설정은 필수적으로 Node.js 서버에 세팅되어야 한다. 설사 어플리케이션 개발자들이 완벽하게 코드를 구현한다고 하더라도, 어플리케이션에서 사용하는 수많은 라이브러리들에서 unhandledRejection 을 발생시킬 수 있기 때문이다.


이것도 읽어보세요