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

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. 증상 분석: 에러가 사라진다

문제 코드

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

  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 비동기 프로그래밍
  • JavaScript Promise 완벽 가이드
  • JavaScript async/await 마스터

키워드

JavaScript, 비동기, Promise, async/await, Unhandled Rejection, 에러 처리, 디버깅, Sentry, 실전 사례, Node.js