JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기
이 글의 핵심
JavaScript 비동기 에러 디버깅 실전 - Promise 체인, async/await, 스택 트레이스 분석
들어가며
“Unhandled Promise Rejection”은 JavaScript 개발자가 가장 자주 보는 에러 중 하나입니다. 이 글에서는 복잡한 비동기 코드에서 에러를 추적하고 해결한 실전 사례를 공유합니다.
일상에 빗대면, 전화를 돌려주기만 하고 끊지 않은 통화와 비슷합니다. 어디선가 예외가 났는데 호출자에게 돌아오는 길이 끊겨 있으면 “처리 안 된 약속”으로 남습니다.
이 글을 읽으면
- Promise 에러가 사라지는 이유를 이해합니다
- 비동기 스택 트레이스를 추적하는 방법을 배웁니다
- async/await에서 에러 처리 패턴을 익힙니다
- 프로덕션 환경에서 에러 모니터링하는 법을 습득합니다
목차
- 문제: 간헐적 Unhandled Rejection
- 증상 분석: 에러가 사라진다
- Promise 체인 추적
- 근본 원인: 에러 핸들러 누락
- 해결 1: async/await로 전환
- 해결 2: 전역 에러 핸들러
- 해결 3: 에러 경계 패턴
- 모니터링: Sentry 연동
- 마무리
1. 문제: 간헐적 Unhandled Rejection
증상
문제의 핵심은 에러 메시지 자체가 아니라, 어느 요청·어느 사용자 흐름에서 터졌는지 추적하기 어렵다는 점이었습니다. 프로덕션 로그에 간헐적으로 아래와 같은 기록이 남았습니다.
(node:12345) UnhandledPromiseRejectionWarning: Error: Database connection failed
at Database.connect (database.js:45:15)
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
특징
- 재현 불가: 로컬에서는 발생 안 함
- 간헐적: 하루에 5-10번 발생
- 정보 부족: 어디서 호출했는지 알 수 없음
2. 증상 분석: 에러가 사라진다
문제 코드
// 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가 경고만 출력하고 계속 실행
3. 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 에서 에러 처리 누락!
4. 근본 원인: 에러 핸들러 누락
문제 패턴
// 패턴 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
});
5. 해결 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' });
}
});
장점
- 에러 처리가 명확함
- 스택 트레이스가 더 읽기 쉬움
- 동기 코드처럼 읽힘
6. 해결 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);
});
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 });
});
7. 해결 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' });
});
8. 모니터링: 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());
커스텀 컨텍스트 추가
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' });
}
});
9. 실전 패턴 모음
패턴 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);
패턴 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');
}
}
패턴 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);
마무리
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 Promise 완벽 가이드
- JavaScript async/await 마스터
키워드
JavaScript, 비동기, Promise, async/await, Unhandled Rejection, 에러 처리, 디버깅, Sentry, 실전 사례, Node.js