Zettelkasten

Promise executor의 비동기 콜백에서 throw하면 Promise는 영원히 pending에 빠진다

·수정 1

요약

  • new Promise(executor)는 executor의 동기 throw만 자동으로 reject로 변환한다. 콜백 안의 throw는 다른 tick에서 일어나므로 잡히지 않고, Promise는 resolve/reject 어느 쪽도 호출되지 않은 채 영구 pending.
  • callback API를 Promise로 wrapping할 때 가장 흔한 버그. 한 번 발생하면 호출자의 await가 무한 대기.
  • process.on('uncaughtException') 핸들러가 있으면 프로세스도 안 죽어서 외부에서 보면 단순 "느린 처리"로 보일 뿐 발견이 늦다.

본문

메커니즘

return new Promise((resolve, reject) => {
  pool.getConnection((err, conn) => {
    if (err) throw err;          // ← 이미 다른 tick. Promise constructor는 못 잡음.
    conn.query(sql, (e, results) => {
      if (e) throw e;            // ← 동일
      resolve(results);
    });
  });
});

Promise constructor는 executor를 동기로 실행하면서 그 동안 발생한 throw만 자동으로 reject(err)로 변환한다. pool.getConnection/conn.query의 콜백은 이벤트 루프 다음 tick에서 실행되므로, 그 안의 throw는 constructor 컨텍스트를 이미 벗어나 있다. 결과:

  1. throw는 uncaughtException 이벤트로 빠져나간다.
  2. Promise는 resolve도 reject도 호출되지 않아 영구 pending.
  3. 호출자의 await는 fulfilled/rejected 알림이 영원히 오지 않으므로 무한 대기.

왜 발견이 어려운가

  • process.on('uncaughtException') 핸들러가 등록돼 있으면 프로세스는 안 죽고 로그만 남는다.
  • 외부 의존(DB, API)이 평소 정상이다가 일시적으로 모든 호출이 에러를 던지기 시작하면 모든 worker가 한꺼번에 hang한다.
  • BullMQ 같은 큐 시스템의 안전망(stalledInterval, attempts)은 worker process 사망을 전제로 설계돼 있어 hang을 잡지 못한다 (→ BullMQ worker handler에는 timeout을 직접 걸어야 한다).

사례

매칭 서비스의 콜백 기반 MySQL 헬퍼:

function createMatchLog(...) {
  return new Promise((resolve, reject) => {
    pool.getConnection((err, conn) => {
      if (err) throw err;  // ← 버그
      conn.query(sql, (e, r) => {
        if (e) throw e;    // ← 버그
        resolve(r.insertId);
      });
    });
  });
}

운영 중 DB user 계정이 잠겼다. 이후 모든 connection 요청이 ER_ACCOUNT_HAS_BEEN_LOCKED를 던졌고, BullMQ worker가 매칭 job 처리 중 이 헬퍼를 호출 → 비동기 콜백에서 throw → Promise 영구 pending → worker handler가 무한 await. uncaughtException 핸들러로 프로세스도 살아있고 BullMQ lock도 30초마다 정상 갱신되어 stalled detector도 트리거되지 않았다. 단 한 개의 job이 active를 64분간 점유했고 그 뒤로 1,956개가 wait 큐에 적체, 매칭은 0건.

핵심은 에러 자체가 아니라 에러가 reject로 변환되지 않은 것. 정상적으로 reject만 됐다면 BullMQ는 attempts: 3 retry 후 failed 처리하고 다음 job으로 넘어갔을 것이다.

올바른 패턴

콜백 안에서는 항상 reject + return:

return new Promise((resolve, reject) => {
  pool.getConnection((err, conn) => {
    if (err) { reject(err); return; }
    conn.query(sql, (e, r) => {
      if (e) { reject(e); return; }
      resolve(r);
    });
  });
});

또는 util.promisify로 callback API를 직접 변환해서 wrapping 자체를 없앤다. 직접 wrapping이 불가피하다면 콜백 안에서 throw 금지가 원칙.

점검 포인트

callback 기반 legacy 라이브러리(mysql, redis 등)를 Promise로 thin wrapping한 코드부터 점검한다. 같은 파일 안에서도 일관성이 깨지기 쉽다 — 새로 추가한 함수는 executeQuery 헬퍼로 통일하면서 옛 함수는 콜백 throw 패턴이 남아있는 경우가 흔하다.

관련 노트

참고