[2026] 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. 증상 분석: 에러가 사라진다
문제 코드
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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;
}
왜 에러가 사라지나?
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 체인 추적
스택 트레이스 개선
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// Node.js 플래그로 긴 스택 트레이스 활성화
// package.json
{
"scripts": {
"start": "node --trace-warnings --async-stack-traces server.js"
}
}
결과
다음은 간단한 code 코드 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
(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. 근본 원인: 에러 핸들러 누락
문제 패턴
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 패턴 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로 전환
개선 코드
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 전역 핸들러
다음은 javascript를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 모든 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 에러 미들웨어
다음은 javascript를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 모든 라우트 핸들러를 래핑
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에서 에러 래핑
다음은 javascript를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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;
}
}
에러 타입별 처리
아래 코드는 javascript를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 설정
다음은 javascript를 활용한 상세한 구현 코드입니다. 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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());
커스텀 컨텍스트 추가
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 에러 처리
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 나쁜 패턴: 하나 실패하면 전체 실패
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: 타임아웃 처리
다음은 javascript를 활용한 상세한 구현 코드입니다. 함수를 통해 로직을 구현합니다, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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: 재시도 로직
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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, 비동기, Promise, async/await, Unhandled Rejection, 에러 처리, 디버깅, Sentry, 실전 사례, Node.js