JavaScript 비동기 디버깅 예제 | Unhandled Rejection과 Promise 체인 에러 추적하기

이 글의 핵심

로그에는 가끔 Unhandled Rejection만 남고 어디서 난 에러인지 알 수 없는 상황을 예제로 구성해, Promise 체인 중간에 return이나 catch가 빠지면 에러가 왜 사라지는지 재현합니다. 비동기 스택 트레이스가 호출 위치를 보여 주는 조건, 서비스 레이어에서 에러를 래핑해 타입별로 처리하는 패턴, Promise.all·타임아웃·재시도 처리까지 재발을 막는 구조를 정리합니다.

들어가며

이 글은 특정 서비스의 장애 기록이 아니라, Node.js 서버에서 흔히 보이는 “가끔 Unhandled Rejection만 남는” 상황을 예제 코드로 재구성해 원인을 추적하고 고치는 과정입니다. 에러가 왜 호출자에게 전달되지 않는지 재현하고, 비동기 스택 트레이스로 호출 위치를 찾고, 라우트·서비스·전역 세 층위에서 에러 경로를 다시 설계한 뒤, 재발했을 때 바로 원인을 알 수 있도록 모니터링을 붙입니다.


예제 상황: 간헐적 Unhandled Rejection

Express 서버의 로그에 가끔 다음과 같은 기록이 남는데, 로컬에서는 재현되지 않고 어느 요청에서 난 것인지도 알 수 없다고 가정해 봅시다. 아래는 Node.js 14 이하에서 이런 상황일 때 나오는 출력 형식의 예입니다.

(node:12345) UnhandledPromiseRejectionWarning: Error: Database connection failed
    at Database.connect (/app/database.js:45:15)
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection. ...

UnhandledPromiseRejectionWarning은 예전 버전의 경고 메시지입니다. Node.js 15부터는 기본 동작이 --unhandled-rejections=throw로 바뀌어, 처리되지 않은 rejection이 있으면 그 에러를 잡히지 않은 예외처럼 출력하고 프로세스가 종료 코드 1로 끝납니다(reject된 값이 Error가 아니면 [UnhandledPromiseRejection: ...] 형태로 감싸서 보여 줍니다). 즉 같은 코드를 최신 Node.js로 올리면 “가끔 경고가 남는” 문제가 “가끔 서버가 죽는” 문제로 바뀝니다. 버전 업그레이드 직후 원인 모를 재시작이 늘었다면 가장 먼저 의심해 볼 부분입니다.

로컬에서 재현되지 않는 이유도 짐작할 수 있습니다. 에러 메시지가 DB 연결 실패라면, 로컬 DB는 항상 떠 있지만 운영 환경에서는 커넥션 풀이 고갈되거나 네트워크가 순간적으로 끊길 때만 실패합니다. 이런 간헐적 인프라 오류는 정상 경로만 테스트하는 환경에서는 거의 나타나지 않으므로, 에러 경로가 제대로 연결되어 있는지는 코드 리뷰와 린트 규칙으로 확인해야 합니다.


에러가 사라지는 코드

// API 엔드포인트
app.get('/users/:id', (req, res) => {
  getUserData(req.params.id)
    .then(user => {
      res.json(user);
    });
  // .catch()가 없고, Promise를 반환하지도 않음
});

async function getUserData(id) {
  const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);  // mysql2/promise 기준
  const user = rows[0];
  if (!user) {
    throw new Error('User not found');
  }
  return user;
}

에러가 “사라지는” 핵심은 throw가 호출자에게 전달되는 경로가 Promise라는 점입니다. async 함수 안에서 던진 에러는 호출 스택을 타고 올라가는 것이 아니라, 그 함수가 반환한 Promise를 rejected 상태로 만들 뿐입니다. 누군가 그 Promise에 .catch()를 붙이거나 await해야 에러가 다시 코드로 돌아옵니다. 라우트 핸들러는 getUserData(...).then(...)이 만든 Promise를 어디에도 반환하지 않고 버렸으므로, Express도 그 Promise가 실패했다는 사실을 알 방법이 없습니다. 결국 Node.js는 “아무도 처리하지 않은 rejection”으로 보고하고, 버전에 따라 경고를 남기거나 프로세스를 종료합니다.

사용자 입장에서 더 나쁜 결과는 응답이 오지 않는다는 것입니다. res.json()이 호출되지 않았으니 요청은 클라이언트나 로드 밸런서의 타임아웃이 걸릴 때까지 매달려 있습니다. 로그에는 에러 한 줄만 남고, 사용자는 한참 뒤에 504를 보게 됩니다.


호출 위치 추적: 비동기 스택 트레이스의 조건

V8의 비동기 스택 트레이스(at async ... 줄)는 Node.js 12부터 기본으로 켜져 있어 플래그를 따로 줄 필요가 없습니다. 다만 이 기능은 await로 연결된 호출에서만 동작합니다. V8은 async 함수가 await에서 중단될 때 “누가 이 함수를 기다리고 있는지”를 따라가 호출자를 복원하는데, .then() 콜백은 이런 연결 정보가 없어서 스택이 끊깁니다.

그래서 위 예제 그대로라면 스택에는 getUserData 프레임까지만 나오고, 그 위로는 processTicksAndRejections 같은 내부 프레임만 보일 가능성이 큽니다. 라우트를 다음처럼 await로 바꾸면 호출 위치가 함께 나타납니다.

app.get('/users/:id', async (req, res) => {
  const user = await getUserData(req.params.id);  // 아직 에러 처리는 없음
  res.json(user);
});
Error: User not found
    at getUserData (/app/services/user.js:23:11)
    at async /app/routes/users.js:15:18

위 출력은 이런 코드에서 기대할 수 있는 형태를 보여 주기 위한 예시입니다. 호출 위치가 보이려면 호출하는 쪽도 await를 써야 한다는 점 자체가 async/await로 전환해야 하는 이유 중 하나입니다. Node.js 14 이하에서 경고가 어디서 출력됐는지 알고 싶다면 node --trace-warnings server.js로 실행하면 경고와 함께 발생 위치의 스택이 찍힙니다. 원인 파악이 끝나면 운영 환경에서는 로그가 과하게 늘지 않도록 끄는 편이 좋습니다.


근본 원인: 에러 경로가 끊기는 세 가지 패턴

Express 4와 5에서 async 핸들러의 rejection 처리 결과와 종료 코드, await 경로와 then 경로의 비동기 스택 트레이스, catch가 에러를 삼킨 뒤 이어지는 then의 값

그림: 실제 실행 결과(Node v26.8.2, 가짜 DB로 글의 라우트를 재현) — Express 4.22.3에서 async 핸들러가 reject되면 처리되지 않은 rejection으로 프로세스가 종료 코드 1로 죽는다. Express 5.3.0도 .then()으로 Promise를 버린 핸들러는 똑같이 죽지만, async 핸들러는 에러를 next(err)로 넘겨 500을 응답하고 서버가 살아 있다. await로 이은 호출은 스택에 at async 줄이 이어지고 then 경로는 중간 함수 프레임이 빠지며, .catch가 삼킨 뒤 이어지는 .then은 undefined를 받는다. (환경: macOS 27.0.1 · Node v26.8.2 · Express 4.22.3 / 5.3.0)

// 패턴 1: .catch() 누락
promise.then(result => {
  console.log(result);
});

// 패턴 2: Express 4에서 async 핸들러의 에러를 넘기지 않음
app.get('/data', async (req, res) => {
  const data = await fetchData();  // reject되면 Express 4는 모름
  res.json(data);
});

// 패턴 3: 중간에 에러 삼키기
promise
  .then(result => processResult(result))
  .catch(err => {
    console.log(err);  // 로그만 찍고 다시 던지지 않음
  })
  .then(result => {
    // 에러가 났다면 result는 undefined
  });

세 패턴 중 가장 찾기 어려운 것은 패턴 3입니다. .catch()는 에러를 “처리한 것”으로 간주하고 콜백의 반환값으로 fulfilled Promise를 만들기 때문에, 뒤의 .then()은 정상 흐름처럼 undefined를 받아 실행됩니다. 결국 에러는 로그 한 줄로 남고, 이후 코드는 Cannot read properties of undefined (reading 'id') 같은 엉뚱한 에러를 냅니다. 로그를 남기고 싶을 뿐이라면 .catch(err => { log(err); throw err; })처럼 다시 던져야 합니다.

패턴 2는 Express 4에서만 문제입니다. Express 4는 핸들러가 반환한 Promise를 보지 않으므로 async 핸들러의 rejection이 그대로 unhandled가 됩니다. Express 5부터는 핸들러가 반환한 Promise가 reject되면 자동으로 next(err)를 호출하므로, 아래의 asyncHandler 래퍼가 필요 없어집니다. 사용 중인 Express 메이저 버전을 먼저 확인하십시오.

이런 누락을 사람이 리뷰로 모두 잡기는 어렵습니다. TypeScript를 쓴다면 typescript-eslint의 @typescript-eslint/no-floating-promises 규칙이 “반환된 Promise를 await하지도, catch하지도, 반환하지도 않은 호출”을 찾아 주므로, CI에서 에러로 설정해 두면 새로 생기는 누락을 막을 수 있습니다. 기존 코드에서 처음 켜면 경고가 한꺼번에 쏟아지는데, 그중 상당수가 실제로 에러가 버려지던 곳입니다. 일부러 기다리지 않는 호출은 void sendAnalytics();처럼 void를 붙여 의도를 드러내면 규칙을 통과합니다. 이 규칙은 타입 정보가 필요하므로 typescript-eslint의 타입 인식 설정(parserOptions.projectService 등)을 함께 켜야 합니다.


해결 1: async/await와 try/catch

app.get('/users/:id', async (req, res) => {
  try {
    const user = await getUserData(req.params.id);
    res.json(user);
  } catch (err) {
    console.error('Error fetching user:', err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

이제 에러가 응답으로 이어지지만, 이 코드에도 약점이 있습니다. User not found와 DB 연결 실패가 모두 500으로 응답됩니다. 존재하지 않는 사용자를 조회한 것은 클라이언트가 404를 받아야 하는 정상적인 결과인데, 500으로 섞이면 모니터링 대시보드의 에러율이 부풀고 진짜 장애가 묻힙니다. 또 모든 라우트에 같은 try/catch를 복사해야 합니다. 이 두 문제를 해결하는 것이 뒤의 에러 미들웨어와 에러 경계 패턴입니다.


해결 2: 전역 핸들러와 Express 에러 미들웨어

const server = app.listen(3000);

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled Rejection:', reason?.stack ?? String(reason));
  // 상태를 신뢰할 수 없으면 정리 후 종료하고, 프로세스 매니저가 재시작하게 합니다
  process.exitCode = 1;
  server.close(() => process.exit());
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  process.exit(1);  // 상태가 반쯤 바뀐 채일 수 있으므로 계속 실행하지 않음
});

전역 핸들러는 최후의 안전망이지 에러 처리 방법이 아닙니다. unhandledRejection 핸들러를 등록하면 Node.js 15+의 기본 종료 동작이 꺼지므로, 로그만 남기고 계속 실행하면 에러 경로가 끊긴 요청들이 응답 없이 쌓여도 서버는 멀쩡해 보입니다. uncaughtException 뒤에 종료하는 이유는 동기 코드 한가운데서 예외가 튀어나와 객체나 커넥션이 반쯤 수정된 상태일 수 있기 때문이고, Node.js 공식 문서도 이 이벤트 이후 정상 실행을 재개하는 것은 안전하지 않다고 명시합니다.

reason?.stack ?? String(reason)처럼 방어적으로 쓴 이유는 Promise가 Error가 아닌 값으로도 reject될 수 있기 때문입니다(Promise.reject('failed'), 인자 없는 reject()). reason이 undefined이면 reason.stack은 TypeError를 던지고, 핸들러 안에서 난 그 에러는 다시 uncaughtException이 됩니다. 애초에 reject할 때는 항상 Error 객체를 쓰는 규칙을 두는 것이 좋습니다. 에러를 외부 로깅 서비스로 비동기 전송한다면 process.exit 전에 전송이 끝나지 않아 마지막 에러가 유실될 수 있다는 점도 기억해 두십시오.

Express 4에서 라우트마다 try/catch를 반복하지 않으려면 래퍼를 씁니다.

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserData(req.params.id);
  res.json(user);
}));

// 모든 라우트 뒤에 등록
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);  // 응답이 이미 시작됐다면 Express 기본 처리에 맡김
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

asyncHandler는 async 핸들러가 반환한 Promise에 .catch(next)를 붙여, reject되면 Express의 에러 미들웨어로 넘겨 줍니다. Promise.resolve()로 한 번 감싼 것은 Promise가 아닌 값을 반환하는 함수가 들어와도 같은 방식으로 동작하게 하기 위해서입니다. Express는 인자가 네 개인 함수만 에러 미들웨어로 인식하므로, next를 쓰지 않더라도 (err, req, res, next) 네 개를 모두 선언해야 합니다. 셋만 쓰면 일반 미들웨어로 취급되어 에러가 전달되지 않습니다. 에러 미들웨어는 모든 라우트보다 뒤에 등록해야 한다는 점도 자주 놓칩니다.

에러 응답에 err.message를 그대로 담는 예제가 많은데, DB 드라이버의 에러 메시지에는 테이블 이름이나 SQL 조각이 들어 있을 수 있어 운영 환경에서는 정보 노출이 됩니다. 다음 절처럼 알려진 에러만 메시지를 내보내고 나머지는 일반 메시지로 바꾸는 편이 안전합니다.


해결 3: 서비스 레이어의 에러 경계

class AppError extends Error {
  constructor(message, statusCode, options) {
    super(message, options);  // options.cause로 원래 에러 보존 (ES2022, Node.js 16.9+)
    this.name = new.target.name;
    this.statusCode = statusCode;
  }
}
class NotFoundError extends AppError {
  constructor(message, options) { super(message, 404, options); }
}
class ServiceUnavailableError extends AppError {
  constructor(message, options) { super(message, 503, options); }
}

class UserService {
  async getUser(id) {
    let rows;
    try {
      [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);
    } catch (err) {
      if (err.code === 'ECONNREFUSED' || err.code === 'PROTOCOL_CONNECTION_LOST') {
        throw new ServiceUnavailableError('Database unavailable', { cause: err });
      }
      throw err;
    }
    if (rows.length === 0) {
      throw new NotFoundError(`User ${id} not found`);
    }
    return rows[0];
  }
}

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  console.error('Unexpected error:', err);
  res.status(500).json({ error: 'Internal server error' });
});

서비스 레이어에서 에러를 도메인 에러로 바꾸는 이유는 라우트와 에러 미들웨어가 DB 드라이버의 내부 에러 코드(ECONNREFUSED, ER_LOCK_DEADLOCK 등)를 알 필요가 없게 하기 위해서입니다. DB를 MySQL에서 PostgreSQL로 바꿔도 ServiceUnavailableError라는 계약은 유지됩니다. 변환할 때 원래 에러를 cause로 붙여 두어야 나중에 로그에서 근본 원인을 볼 수 있습니다. DB 호출만 try로 감싸고 “없음” 판정은 try 밖에서 한 것은, 우리가 던진 NotFoundError가 catch 블록의 변환 로직을 다시 거치지 않게 하기 위해서입니다.

instanceof 검사는 같은 클래스가 두 번 로드되면(예: 모노레포에서 같은 패키지의 다른 버전이 설치된 경우) 실패할 수 있습니다. 여러 패키지에서 에러를 공유한다면 err.statusCode나 err.name 같은 속성으로 판별하는 편이 견고합니다.


모니터링: Sentry 연동

“어느 요청에서 터졌는가”라는 원래 질문에 답하려면 에러를 요청 정보와 함께 수집해야 합니다. Sentry Node SDK v8 이후 버전에서는 앱 코드보다 먼저 로드되는 파일에서 초기화하고, 라우트 등록이 끝난 뒤 에러 핸들러를 붙입니다.

// instrument.js — 다른 모듈보다 먼저 로드
const Sentry = require('@sentry/node');
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 0.1,  // 성능 트레이스 비율. 에러 이벤트는 이와 별개로 전송됨
});
// server.js
require('./instrument');
const Sentry = require('@sentry/node');
const express = require('express');
const app = express();

// ... 라우트 등록 ...

Sentry.setupExpressErrorHandler(app);  // 라우트 뒤, 다른 에러 미들웨어 앞
app.use((err, req, res, next) => { /* 위의 에러 미들웨어 */ });

v7까지의 예제에 흔히 보이는 Sentry.Handlers.requestHandler(), new Sentry.Integrations.Http({ tracing: true })는 v8에서 제거되었으므로, 옛 예제를 그대로 쓰면 undefined의 속성을 읽는 TypeError가 납니다. tracesSampleRate: 1.0은 모든 요청의 성능 트레이스를 보낸다는 뜻이라 트래픽이 많은 서비스에서는 비용과 오버헤드가 커지므로, 운영 환경에서는 낮게 잡는 것이 일반적입니다.

요청별 정보는 Sentry.setTag('route', 'GET /users/:id')나 Sentry.setContext(...)로 붙입니다. v8 이후에는 HTTP 요청마다 격리된 스코프(isolation scope)가 자동으로 만들어지므로, 핸들러 안에서 설정한 값이 동시에 처리되는 다른 요청의 에러에 섞이지 않습니다. v7에서 요청 핸들러 미들웨어 없이 전역 스코프에 설정하면 다른 요청의 에러에 엉뚱한 값이 붙을 수 있었습니다. IP 주소나 사용자 ID는 개인정보로 분류될 수 있으므로 수집 정책에 맞춰 beforeSend에서 마스킹하는 것을 검토하십시오.


자주 쓰는 비동기 에러 처리 패턴

Promise.all과 Promise.allSettled

// 하나라도 실패하면 전체를 실패로 처리
const [user, stock, payment] = await Promise.all([
  fetchUser(userId),
  fetchStock(itemId),
  fetchPayment(orderId),
]);

// 일부만 성공해도 의미가 있을 때
const results = await Promise.allSettled(widgetIds.map(fetchWidget));
const widgets = [];
for (const [i, r] of results.entries()) {
  if (r.status === 'fulfilled') widgets.push(r.value);
  else console.warn(`widget ${widgetIds[i]} failed`, r.reason);
}

“하나 실패하면 전체 실패”가 항상 나쁜 것은 아닙니다. 주문 생성에 필요한 사용자·재고·결제 정보를 동시에 조회한다면 하나라도 없으면 진행할 수 없으므로 Promise.all의 빠른 실패가 맞는 동작입니다. 반면 대시보드의 위젯 여러 개를 채우는 것처럼 일부만 성공해도 의미가 있을 때 allSettled를 씁니다. 주의할 점은 Promise.all이 하나가 실패해도 나머지 요청을 취소하지 않는다는 것입니다. 나머지는 계속 실행되고 결과만 버려지므로, 비싼 요청이라면 AbortController로 직접 취소해야 합니다. 또 allSettled로 실패를 걸러 낸 뒤에는 위 코드처럼 실패한 항목을 로그로 남겨야 합니다. 그러지 않으면 “에러가 조용히 사라지는” 상황을 다시 만들게 됩니다.

타임아웃

// 취소 가능한 API라면 AbortSignal로 실제 요청을 멈춥니다 (Node.js 17.3+)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

// 취소할 수 없는 Promise에 시간 제한만 두는 경우
function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

Promise.race로 만든 타임아웃은 원래 작업을 멈추지 않습니다. 호출자는 포기했는데 DB 쿼리나 HTTP 요청은 끝까지 자원을 쓰므로, 취소 신호를 받는 API라면 AbortSignal.timeout()을 넘기는 방식이 낫습니다. 이때 시간 초과로 난 에러는 name === 'TimeoutError'인 DOMException이므로 메시지 문자열 대신 name으로 판별합니다. 직접 만든 withTimeout에서는 finally로 타이머를 지워야 합니다. 그러지 않으면 작업이 먼저 끝나도 타이머가 남아, 서버에서는 요청마다 타이머가 쌓이고 테스트 러너에서는 열린 핸들 때문에 프로세스가 끝나지 않습니다.

재시도

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function isTransient(err) {
  return ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED'].includes(err.code)
    || err.name === 'TimeoutError'
    || [429, 502, 503, 504].includes(err.status);
}

async function retry(fn, { attempts = 3, baseDelay = 500 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= attempts || !isTransient(err)) throw err;
      const delay = baseDelay * 2 ** (attempt - 1);
      await sleep(delay / 2 + Math.random() * delay / 2);  // 지수 백오프 + jitter
    }
  }
}

재시도 로직은 무엇을 재시도할지 고르는 것이 핵심입니다. 404나 400처럼 다시 보내도 결과가 같은 에러를 반복하면 응답만 늦어지므로, 네트워크 오류, 타임아웃, 429·503처럼 일시적인 에러만 재시도합니다. 여러 클라이언트가 동시에 실패하고 똑같이 1초, 2초, 4초 뒤에 재시도하면 복구 중인 서버에 요청이 한꺼번에 몰리므로, 지연 시간에 무작위 값(jitter)을 섞습니다. 결제나 주문 생성처럼 멱등하지 않은 요청을 재시도하면 중복 처리가 생길 수 있으니, 멱등성 키를 함께 보내거나 재시도 대상에서 빼야 합니다. err.status 같은 속성 이름은 사용하는 HTTP 클라이언트에 따라 다르므로 맞춰 고쳐 씁니다.



같이 보면 좋은 글