[2026] JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기

[2026] JavaScript 비동기 디버깅 실전 사례 | Promise 체인 에러 추적하기

이 글의 핵심

JavaScript 비동기 코드에서 발생한 에러를 추적하고 해결한 실전 사례. Promise 체인, async/await, 에러 핸들링, 스택 트레이스 분석 방법을 다룹니다.

들어가며

“Unhandled Promise Rejection”은 JavaScript 개발자가 가장 자주 보는 에러 중 하나입니다. 이 글에서는 복잡한 비동기 코드에서 에러를 추적하고 해결한 실전 사례를 공유합니다. 일상에 빗대면, 전화를 돌려주기만 하고 끊지 않은 통화와 비슷합니다. 어디선가 예외가 났는데 호출자에게 돌아오는 길이 끊겨 있으면 “처리 안 된 약속”으로 남습니다.

이 글을 읽으면

  • Promise 에러가 사라지는 이유를 이해합니다
  • 비동기 스택 트레이스를 추적하는 방법을 배웁니다
  • async/await에서 에러 처리 패턴을 익힙니다
  • 프로덕션 환경에서 에러 모니터링하는 법을 습득합니다

실무에서 마주한 현실

개발을 배울 때는 모든 게 깔끔하고 이론적입니다. 하지만 실무는 다릅니다. 레거시 코드와 씨름하고, 급한 일정에 쫓기고, 예상치 못한 버그와 마주합니다. 이 글에서 다루는 내용도 처음엔 이론으로 배웠지만, 실제 프로젝트에 적용하면서 “아, 이래서 이렇게 설계하는구나” 하고 깨달은 것들입니다. 특히 기억에 남는 건 첫 프로젝트에서 겪은 시행착오입니다. 책에서 배운 대로 했는데 왜 안 되는지 몰라 며칠을 헤맸죠. 결국 선배 개발자의 코드 리뷰를 통해 문제를 발견했고, 그 과정에서 많은 걸 배웠습니다. 이 글에서는 이론뿐 아니라 실전에서 마주칠 수 있는 함정들과 해결 방법을 함께 다루겠습니다.

목차

  1. 문제: 간헐적 Unhandled Rejection
  2. 증상 분석: 에러가 사라진다
  3. Promise 체인 추적
  4. 근본 원인: 에러 핸들러 누락
  5. 해결 1: async/await로 전환
  6. 해결 2: 전역 에러 핸들러
  7. 해결 3: 에러 경계 패턴
  8. 모니터링: Sentry 연동
  9. 마무리

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 비동기 에러 디버깅의 핵심:

  1. 모든 Promise에 .catch() 또는 try-catch 추가
  2. 전역 핸들러로 누락된 에러 캐치
  3. 에러 모니터링 도구 (Sentry) 활용
  4. 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

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3