JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기
이 글의 핵심
로그에는 가끔 Unhandled Rejection만 남고 어디서 난 에러인지 알 수 없는 상황에서 출발합니다. Promise 체인 중간에 return이나 catch가 빠지면 에러가 왜 사라지는지 재현하고, 서비스 레이어에서 에러를 래핑해 타입별로 처리하는 패턴과 Promise.all·타임아웃 처리까지 재발을 막는 구조를 정리합니다.
들어가며
“Unhandled Promise Rejection”은 JavaScript 개발자가 가장 자주 보는 에러 중 하나입니다. 이 글에서는 복잡한 비동기 코드에서 에러를 추적하고 해결한 실전 사례를 공유합니다. 일상에 빗대면, 전화를 돌려주기만 하고 끊지 않은 통화와 비슷합니다. 어디선가 예외가 났는데 호출자에게 돌아오는 길이 끊겨 있으면 “처리 안 된 약속”으로 남습니다.
사례에서 다루는 흐름은 네 단계입니다. 에러가 왜 호출자에게 전달되지 않았는지 재현하고, 비동기 스택 트레이스로 호출 위치를 찾고, 라우트·서비스·전역 세 층위에서 에러 경로를 다시 설계한 뒤, 재발했을 때 바로 원인을 알 수 있도록 모니터링을 붙입니다.
문제: 간헐적 Unhandled Rejection
증상
문제의 핵심은 에러 메시지 자체가 아니라, 어느 요청·어느 사용자 흐름에서 터졌는지 추적하기 어렵다는 점이었습니다. 프로덕션 로그에 간헐적으로 아래와 같은 기록이 남았습니다.
(node:12345) UnhandledPromiseRejectionWarning: Error: Database connection failed
at Database.connect (database.js:45:15)
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
특징
- 재현 불가: 로컬에서는 발생 안 함
- 간헐적: 하루에 5-10번 발생
- 정보 부족: 어디서 호출했는지 알 수 없음
로그 형식을 보면 이 서비스가 Node.js 14 이하에서 돌고 있었다는 것을 알 수 있습니다. UnhandledPromiseRejectionWarning은 예전 버전의 경고 메시지이고, Node.js 15부터는 기본 동작(--unhandled-rejections=throw)이 바뀌어 처리되지 않은 rejection이 발생하면 [UnhandledPromiseRejection: ...] 에러와 함께 프로세스가 종료됩니다. 즉 같은 코드를 최신 Node.js로 올리면 “가끔 경고가 남는” 문제가 “가끔 서버가 죽는” 문제로 바뀝니다. 버전 업그레이드 직후 원인 모를 재시작이 늘었다면 가장 먼저 의심해 볼 부분입니다.
로컬에서 재현되지 않은 이유도 짐작할 수 있습니다. 에러 메시지가 Database connection failed였으니, 로컬 DB는 항상 떠 있지만 프로덕션에서는 커넥션 풀이 고갈되거나 네트워크가 순간적으로 끊길 때만 실패했을 것입니다. 이런 간헐적 인프라 오류는 정상 경로만 테스트하는 환경에서는 거의 나타나지 않으므로, 에러 경로가 제대로 연결되어 있는지는 코드 리뷰와 린트 규칙으로 확인해야 합니다.
증상 분석: 에러가 사라진다
문제 코드
// API 엔드포인트
app.get('/users/:id', (req, res) => {
getUserData(req.params.id)
.then(user => {
res.json(user);
});
// 🚨 .catch()가 없음!
});
async function getUserData(id) {
const user = await db.query('SELECT * FROM users WHERE id = ?', id);
if (!user) {
throw new Error('User not found'); // 💥 에러가 사라짐!
}
return user;
}
왜 에러가 사라지나?
// Promise 체인
getUserData(id) // Promise 생성
.then(user => { // 성공 핸들러만 있음
res.json(user);
});
// .catch() 없음 → 에러가 처리되지 않음!
// 에러 발생 시
// 1. getUserData에서 throw
// 2. Promise가 rejected 상태가 됨
// 3. .catch()가 없으므로 Unhandled Rejection
// 4. Node.js 14 이하: 경고만 출력하고 계속 실행
// Node.js 15 이상: 프로세스 종료
에러가 “사라지는” 핵심은 throw가 호출자에게 전달되는 경로가 Promise라는 점입니다. async 함수 안에서 던진 에러는 호출 스택을 타고 올라가는 것이 아니라, 그 함수가 반환한 Promise를 rejected 상태로 만들 뿐입니다. 누군가 그 Promise에 .catch()를 붙이거나 await해야 에러가 다시 코드로 돌아옵니다. 라우트 핸들러는 getUserData(...).then(...)이 만든 Promise를 어디에도 반환하지 않고 버렸으므로, Express도 그 Promise가 실패했다는 사실을 알 방법이 없습니다.
사용자 입장에서 더 나쁜 결과는 응답이 오지 않는다는 것입니다. res.json()이 호출되지 않았으니 요청은 클라이언트나 로드 밸런서의 타임아웃이 걸릴 때까지 매달려 있습니다. 로그에는 에러 한 줄만 남고, 사용자는 한참 뒤에 504를 보게 됩니다.
Promise 체인 추적
스택 트레이스 개선
// Node.js 플래그로 긴 스택 트레이스 활성화
// package.json
{
"scripts": {
"start": "node --trace-warnings --async-stack-traces server.js"
}
}
결과
(node:12345) UnhandledPromiseRejectionWarning: Error: User not found
at getUserData (user_service.js:23:11)
at processTicksAndRejections (internal/process/task_queues.js:95:5)
at async /home/app/routes/users.js:15:18 ← 호출 위치!
발견: routes/users.js:15 에서 에러 처리 누락!
여기서 주의할 점이 있습니다. V8의 비동기 스택 트레이스(at async ... 줄)는 Node.js 12부터 기본으로 켜져 있어 플래그를 따로 줄 필요가 없지만, await로 연결된 호출에서만 동작합니다. V8은 async 함수가 await에서 중단될 때 “누가 이 함수를 기다리고 있는지”를 따라가 호출자를 복원하는데, .then() 콜백은 이런 연결 정보가 없어서 스택이 processTicksAndRejections에서 끊깁니다. 위 결과처럼 호출 위치가 보이려면 호출하는 쪽도 await를 쓰고 있어야 합니다. 이 점 자체가 async/await로 전환해야 하는 또 하나의 이유입니다.
--trace-warnings는 경고(Node 14 이하의 UnhandledPromiseRejectionWarning 포함)가 출력될 때 그 경고가 발생한 위치의 스택을 함께 찍어 줍니다. 원인 파악이 끝나면 운영 환경에서는 로그가 과하게 늘지 않도록 끄는 편이 좋습니다.
근본 원인: 에러 핸들러 누락
문제 패턴
// 패턴 1: .catch() 누락
promise.then(result => {
console.log(result);
}); // 🚨 .catch() 없음
// 패턴 2: async 함수에서 try-catch 누락
async function handler(req, res) {
const data = await fetchData(); // 🚨 에러 처리 없음
res.json(data);
}
// 패턴 3: 중간에 에러 삼키기
promise
.then(result => processResult(result))
.catch(err => {
console.log(err); // 로그만 찍고 끝
// 🚨 에러를 다시 throw하지 않음
})
.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를 붙여 의도를 드러내면 규칙을 통과합니다.
해결 1: async/await로 전환
개선 코드
// Before: Promise 체인
app.get('/users/:id', (req, res) => {
getUserData(req.params.id)
.then(user => {
res.json(user);
});
// .catch() 누락
});
// After: 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: 전역 에러 핸들러
Node.js 전역 핸들러
// 모든 Unhandled Rejection 캐치
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// 에러 로깅 서비스로 전송
errorLogger.log({
type: 'unhandledRejection',
reason: reason,
stack: reason.stack,
timestamp: new Date().toISOString(),
});
// 프로덕션에서는 프로세스 종료 고려
// process.exit(1);
});
// Uncaught Exception도 처리
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
errorLogger.log({
type: 'uncaughtException',
error: err.message,
stack: err.stack,
});
// 프로세스 종료 (상태가 불안정할 수 있음)
process.exit(1);
});
전역 핸들러는 최후의 안전망이지 에러 처리 방법이 아닙니다. unhandledRejection 핸들러를 등록하면 Node.js 15+의 기본 종료 동작이 꺼지므로, 위 코드처럼 로그만 남기고 계속 실행하면 에러 경로가 끊긴 요청들이 응답 없이 쌓여도 서버는 멀쩡해 보입니다. uncaughtException에서 process.exit(1)을 하는 이유는 동기 코드 한가운데서 예외가 튀어나와 객체나 커넥션이 반쯤 수정된 상태일 수 있기 때문입니다. 공식 문서도 이 이벤트 이후 정상 실행을 재개하는 것은 안전하지 않다고 명시합니다.
reason.stack도 조심해야 합니다. Promise는 Error가 아닌 값으로도 reject될 수 있어서(Promise.reject('failed'), reject()), reason이 문자열이나 undefined이면 reason.stack은 undefined이고, reason이 undefined나 null이면 속성 접근 자체가 TypeError를 던집니다. reason?.stack ?? String(reason)처럼 방어적으로 쓰고, 애초에 reject할 때는 항상 Error 객체를 쓰는 규칙을 두는 것이 좋습니다. errorLogger.log가 비동기라면 process.exit 전에 전송이 끝나지 않아 마지막 에러가 유실될 수 있다는 점도 기억해 두십시오.
Express 에러 미들웨어
// 모든 라우트 핸들러를 래핑
function asyncHandler(fn) {
return (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);
// 에러 발생 시 자동으로 next(err) 호출
}));
// 에러 핸들러
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({ error: err.message });
});
asyncHandler는 async 핸들러가 반환한 Promise에 .catch(next)를 붙여, reject되면 Express의 에러 미들웨어로 넘겨 줍니다. Promise.resolve()로 한 번 감싼 것은 동기 함수가 들어와도 같은 방식으로 동작하게 하기 위해서입니다. Express는 인자가 네 개인 함수만 에러 미들웨어로 인식하므로, next를 쓰지 않더라도 (err, req, res, next) 네 개를 모두 선언해야 합니다. 셋만 쓰면 일반 미들웨어로 취급되어 에러가 전달되지 않습니다. 에러 미들웨어는 모든 라우트보다 뒤에 등록해야 한다는 점도 자주 놓칩니다.
이 에러 핸들러는 err.message를 그대로 클라이언트에 돌려주는데, DB 드라이버의 에러 메시지에는 테이블 이름이나 SQL 조각이 들어 있을 수 있어 운영 환경에서는 정보 노출이 됩니다. 아래 에러 경계 패턴처럼 알려진 에러만 메시지를 내보내고, 나머지는 일반 메시지로 바꾸는 편이 안전합니다.
해결 3: 에러 경계 패턴
Service Layer에서 에러 래핑
class UserService {
async getUser(id) {
try {
const user = await db.query('SELECT * FROM users WHERE id = ?', id);
if (!user) {
throw new NotFoundError(`User ${id} not found`);
}
return user;
} catch (err) {
// 데이터베이스 에러를 도메인 에러로 변환
if (err.code === 'ECONNREFUSED') {
throw new ServiceUnavailableError('Database connection failed');
}
throw err;
}
}
}
// 커스텀 에러 클래스
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
class ServiceUnavailableError extends Error {
constructor(message) {
super(message);
this.name = 'ServiceUnavailableError';
this.statusCode = 503;
}
}
에러 타입별 처리
app.use((err, req, res, next) => {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
if (err instanceof ServiceUnavailableError) {
return res.status(503).json({ error: 'Service temporarily unavailable' });
}
// 예상치 못한 에러
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
});
서비스 레이어에서 에러를 도메인 에러로 바꾸는 이유는 라우트와 에러 미들웨어가 DB 드라이버의 내부 에러 코드(ECONNREFUSED, ER_LOCK_DEADLOCK 등)를 알 필요가 없게 하기 위해서입니다. DB를 MySQL에서 PostgreSQL로 바꿔도 ServiceUnavailableError라는 계약은 유지됩니다. 변환할 때는 원래 에러를 버리지 말고 new ServiceUnavailableError('...', { cause: err })처럼 ES2022의 cause 옵션으로 붙여 두어야 나중에 로그에서 근본 원인을 볼 수 있습니다. 이 경우 커스텀 에러 클래스의 생성자도 constructor(message, options) { super(message, options); }처럼 옵션을 받아 넘겨야 합니다.
instanceof 검사는 같은 클래스가 두 번 로드되면(예: 모노레포에서 같은 패키지의 다른 버전이 설치된 경우) 실패할 수 있습니다. 여러 패키지에서 에러를 공유한다면 err.statusCode나 err.name 같은 속성으로 판별하는 편이 견고합니다.
모니터링: Sentry 연동
Sentry 설정
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
],
});
// Express 미들웨어
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
// 에러 핸들러 (라우트 뒤에 배치)
app.use(Sentry.Handlers.errorHandler());
위 코드는 Sentry Node SDK v7까지의 API입니다. v8부터는 OpenTelemetry 기반으로 바뀌면서 Sentry.Handlers와 new Sentry.Integrations.Http()가 제거되었고, 앱 코드보다 먼저 로드되는 별도 파일(instrument.js)에서 Sentry.init()을 호출한 뒤 라우트 등록이 끝나면 Sentry.setupExpressErrorHandler(app)를 한 줄 추가하는 방식으로 바뀌었습니다. v7 예제를 v8에 그대로 쓰면 Cannot read properties of undefined (reading 'requestHandler') 에러가 납니다.
tracesSampleRate: 1.0은 모든 요청의 성능 트레이스를 보낸다는 뜻이라, 트래픽이 많은 서비스에서는 비용과 오버헤드가 커집니다. 에러 이벤트는 이 샘플링과 별개로 전송되므로, 운영 환경에서는 트레이스 비율을 낮게 잡는 것이 일반적입니다.
커스텀 컨텍스트 추가
app.get('/users/:id', async (req, res) => {
Sentry.setContext('user_request', {
userId: req.params.id,
ip: req.ip,
});
try {
const user = await getUserData(req.params.id);
res.json(user);
} catch (err) {
Sentry.captureException(err);
res.status(500).json({ error: 'Internal server error' });
}
});
setContext는 이후 발생하는 이벤트에 요청 정보를 붙여 “어느 요청에서 터졌는가”라는 원래 문제에 직접 답해 줍니다. 여기서 주의할 점은 동시성입니다. v7에서 요청 핸들러 미들웨어 없이 전역 스코프에 컨텍스트를 설정하면, 동시에 처리되는 다른 요청의 에러에 엉뚱한 사용자 ID가 붙을 수 있습니다. v8은 요청마다 격리된 스코프(isolation scope)를 자동으로 만들어 이 문제를 줄였습니다. 또 req.ip는 개인정보로 분류될 수 있으므로 수집 정책에 맞춰 beforeSend에서 마스킹하는 것을 검토하십시오.
실전 패턴 모음
패턴 1: Promise.all 에러 처리
// ❌ 나쁜 패턴: 하나 실패하면 전체 실패
const results = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
]);
// ✅ 좋은 패턴: 개별 에러 처리
const results = await Promise.all([
fetchUser(1).catch(err => ({ error: err })),
fetchUser(2).catch(err => ({ error: err })),
fetchUser(3).catch(err => ({ error: err })),
]);
// 성공한 것만 필터링
const users = results.filter(r => !r.error);
// ✅ 더 좋은 패턴: Promise.allSettled
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(3),
]);
const users = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
“하나 실패하면 전체 실패”가 항상 나쁜 것은 아닙니다. 주문 생성에 필요한 사용자·재고·결제 정보를 동시에 조회한다면 하나라도 없으면 진행할 수 없으므로 Promise.all의 빠른 실패가 맞는 동작입니다. 반면 대시보드의 위젯 여러 개를 채우는 것처럼 일부만 성공해도 의미가 있을 때 allSettled를 씁니다. 주의할 점은 Promise.all이 하나가 실패해도 나머지 요청을 취소하지 않는다는 것입니다. 나머지는 계속 실행되고 결과만 버려지므로, 비싼 요청이라면 AbortController로 직접 취소해야 합니다. 또 allSettled로 실패를 걸러 낸 뒤에는 실패한 항목도 로그로 남겨야 합니다. 그러지 않으면 이 글의 원래 문제였던 “에러가 조용히 사라지는” 상황을 다시 만들게 됩니다.
패턴 2: 타임아웃 처리
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
),
]);
}
// 사용
try {
const data = await withTimeout(fetchData(), 5000);
} catch (err) {
if (err.message === 'Timeout') {
console.error('Request timed out');
}
}
Promise.race 타임아웃은 간단하지만 두 가지 누수가 있습니다. 첫째, fetchData()가 먼저 끝나도 setTimeout은 취소되지 않아 타이머가 남고, 서버에서는 요청마다 타이머가 쌓이며 테스트 러너에서는 “열린 핸들 때문에 종료되지 않음” 경고의 원인이 됩니다. 둘째, 타임아웃으로 race가 끝나도 원래 요청은 멈추지 않고 계속 실행됩니다. 호출자는 포기했는데 DB 쿼리나 HTTP 요청은 끝까지 자원을 씁니다. 최신 Node.js(17.3+)와 브라우저에서는 fetch(url, { signal: AbortSignal.timeout(5000) })처럼 취소 신호를 넘기는 방식이 두 문제를 모두 해결합니다. 이때 타임아웃 에러는 name === 'TimeoutError'인 DOMException이므로 메시지 문자열 대신 name으로 판별합니다.
패턴 3: 재시도 로직
async function retry(fn, maxAttempts = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts) {
throw err;
}
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
}
}
}
// 사용
const data = await retry(() => fetchData(), 3, 1000);
재시도 로직은 무엇을 재시도할지 고르는 것이 핵심입니다. 이 함수는 모든 에러를 재시도하는데, 404나 400처럼 다시 보내도 결과가 같은 에러를 세 번 반복하면 응답만 늦어집니다. 네트워크 오류, 타임아웃, 503·429처럼 일시적인 에러만 재시도하도록 조건을 두어야 합니다. 여러 클라이언트가 동시에 실패하고 똑같이 1초, 2초, 4초 뒤에 재시도하면 복구 중인 서버에 요청이 한꺼번에 몰리므로, 지연 시간에 무작위 값(jitter)을 섞는 것이 일반적입니다. 마지막으로 결제나 주문 생성처럼 멱등하지 않은 요청을 재시도하면 중복 처리가 생길 수 있으니, 멱등성 키를 함께 보내거나 재시도 대상에서 빼야 합니다.
마무리
JavaScript 비동기 에러 디버깅의 핵심:
- 모든 Promise에 .catch() 또는 try-catch 추가
- 전역 핸들러로 누락된 에러 캐치
- 에러 모니터링 도구 (Sentry) 활용
- async/await로 가독성과 에러 처리 개선 핵심: “에러가 발생하지 않을 것”이라 가정하지 말고, 항상 에러 처리를 추가하세요.
FAQ
Q1. Promise 체인 vs async/await 중 뭘 써야 하나요? async/await를 권장합니다. 에러 처리가 명확하고 스택 트레이스가 더 읽기 쉽습니다.
Q2. try-catch를 모든 함수에 추가해야 하나요? 최상위 핸들러(Express 미들웨어, 전역 핸들러)에서 처리하며, 비즈니스 로직에서는 필요한 곳만 추가하세요.
Q3. Unhandled Rejection이 발생하면 프로세스를 종료해야 하나요? Node.js 15+에서는 기본적으로 종료됩니다. 프로덕션에서는 PM2 등으로 자동 재시작을 설정하세요.
같이 보면 좋은 글
- JavaScript 비동기 프로그래밍
- JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러
- Java Spring Boot | REST API 서버 만들기