setTimeout 안에서 던진 에러를 try-catch로 감싸도 잡히지 않습니다. 왜 그럴까요?

try-catch동기 코드의 런타임 에러 만 잡을 수 있습니다. 비동기 콜백은 try-catch가 이미 끝난 후에 별도의 실행 컨텍스트에서 실행되기 때문입니다. 이 차이를 모르면 프로덕션에서 에러가 삼켜지는 심각한 버그를 만들게 됩니다.

Error 객체의 기본 구조

자바스크립트의 모든 에러는 Error 객체를 기반으로 합니다.

JS
const error = new Error('뭔가 잘못됐습니다');
console.log(error.message);  // "뭔가 잘못됐습니다"
console.log(error.name);     // "Error"
console.log(error.stack);    // 에러 발생 위치의 스택 트레이스

내장 에러 클래스 계층

PLAINTEXT
Error
├── SyntaxError     — 문법 오류
├── ReferenceError  — 존재하지 않는 변수 참조
├── TypeError       — 타입이 맞지 않는 연산
├── RangeError      — 범위를 벗어난 값
├── URIError        — URI 처리 함수의 잘못된 사용
└── EvalError       — eval() 관련 에러 (거의 안 씀)

TypeError와 ReferenceError는 혼동하기 쉬운데, 차이는 이렇습니다.

  • ReferenceError: 선언되지 않은 변수에 접근 (console.log(x) — x가 없을 때)
  • TypeError: 변수는 있지만 해당 타입에서 불가능한 연산 (null.foo)

try-catch-finally 기본

JS
try {
  // 에러가 발생할 수 있는 코드
  const data = JSON.parse(invalidJson);
} catch (error) {
  // 에러 처리
  console.error('파싱 실패:', error.message);
} finally {
  // 에러 여부와 관계없이 항상 실행
  console.log('정리 작업 완료');
}

알아두면 좋은 포인트들

1. catch 바인딩 생략 가능 (ES2019)

JS
try {
  JSON.parse(data);
} catch {
  // error 파라미터 없이도 사용 가능
  console.log('파싱 실패');
}

2. finally에서 return하면 위험하다

JS
function test() {
  try {
    return 1;
  } finally {
    return 2; // try의 return 1을 덮어씀
  }
}
console.log(test()); // 2 — 주의!

이건 거의 버그를 만드는 패턴이라 실제로 finally 안에 return을 넣으면 안 됩니다.

3. try-catch는 동기 코드만 잡는다

JS
try {
  setTimeout(() => {
    throw new Error('비동기 에러'); // try-catch로 잡히지 않음!
  }, 1000);
} catch (error) {
  // 여기에 도달하지 않음
  console.log('잡았다!');
}

setTimeout의 콜백은 try-catch가 이미 끝난 후에 실행되기 때문에 잡히지 않습니다.

커스텀 에러 클래스

내장 에러만으로는 부족할 때 커스텀 에러를 만듭니다.

JS
class HttpError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.name = 'HttpError';
    this.statusCode = statusCode;
  }
}

class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

// 사용
function fetchUser(id) {
  if (!id) throw new ValidationError('id', 'ID는 필수입니다');
  // ... API 호출 로직
}

try {
  fetchUser(null);
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`${error.field} 필드 오류: ${error.message}`);
  } else if (error instanceof HttpError) {
    console.log(`HTTP ${error.statusCode}: ${error.message}`);
  } else {
    throw error; // 알 수 없는 에러는 다시 던진다
  }
}

instanceof로 에러 타입을 구분하면 각 에러에 맞는 처리를 할 수 있습니다. 알 수 없는 에러는 다시 throw해서 상위로 전파하는 게 좋습니다.

비동기 에러 처리

Promise의 에러 처리

JS
// .catch()로 처리
fetch('/api/users')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(error => console.error('요청 실패:', error));

// then의 두 번째 인자로 처리 (잘 안 쓰는 방식)
promise.then(
  result => { /* 성공 */ },
  error => { /* 실패 */ }
);

.catch()가 체인 어디서든 발생한 에러를 잡아주므로 더 안전합니다.

async/await의 에러 처리

JS
async function getUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new HttpError(response.status, '사용자 조회 실패');
    }
    return await response.json();
  } catch (error) {
    if (error instanceof HttpError) {
      console.error(`API 에러: ${error.statusCode}`);
    } else {
      console.error('네트워크 에러:', error.message);
    }
    return null;
  }
}

여러 Promise의 에러 처리

JS
// Promise.all — 하나라도 실패하면 즉시 reject
try {
  const [users, posts] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
  ]);
} catch (error) {
  // 먼저 실패한 에러만 잡힘
}

// Promise.allSettled — 전부 끝날 때까지 기다림
const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/posts'),
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('성공:', result.value);
  } else {
    console.log('실패:', result.reason);
  }
});

두 메서드의 핵심 차이는 에러 처리 방식입니다. Promise.all은 하나라도 실패하면 전체가 실패하지만, Promise.allSettled는 모든 결과를 기다립니다.

전역 에러 처리

아무리 꼼꼼하게 에러 처리를 해도 빠져나가는 에러가 있을 수 있습니다. 전역 핸들러는 마지막 안전망입니다.

브라우저 환경

JS
// 동기 에러 + 리소스 로딩 에러
window.onerror = function(message, source, line, column, error) {
  console.error('전역 에러:', { message, source, line, column });
  // 에러 리포팅 서비스로 전송
  reportError({ message, source, line, column, stack: error?.stack });
  return true; // true를 반환하면 콘솔에 에러가 출력되지 않음
};

// 또는 addEventListener 방식
window.addEventListener('error', (event) => {
  console.error('에러 발생:', event.error);
});

// 처리되지 않은 Promise rejection
window.addEventListener('unhandledrejection', (event) => {
  console.error('미처리 rejection:', event.reason);
  event.preventDefault(); // 콘솔 에러 출력 방지
});

Node.js 환경

JS
// 처리되지 않은 예외
process.on('uncaughtException', (error) => {
  console.error('미처리 예외:', error);
  // 로그 기록 후 프로세스 종료가 권장됨
  process.exit(1);
});

// 처리되지 않은 Promise rejection
process.on('unhandledRejection', (reason, promise) => {
  console.error('미처리 rejection:', reason);
});

uncaughtException 발생 후에는 프로세스 상태를 신뢰할 수 없으므로, 로그를 남기고 프로세스를 재시작하는 게 안전합니다.

에러 처리 패턴

Result 패턴 — 에러를 값으로 다루기

try-catch 대신 성공/실패를 값으로 반환하는 패턴입니다.

JS
function safeParseJSON(jsonString) {
  try {
    const data = JSON.parse(jsonString);
    return { success: true, data };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

const result = safeParseJSON('{ invalid }');
if (result.success) {
  console.log(result.data);
} else {
  console.log('파싱 실패:', result.error);
}

에러 바운더리 패턴 — React에서의 에러 처리

JSX
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스로 전송
    logErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>문제가 발생했습니다.</h1>;
    }
    return this.props.children;
  }
}

// 사용
<ErrorBoundary>
  <MyComponent />
</ErrorBoundary>

React Error Boundary는 렌더링 중 발생하는 에러를 잡아줍니다. 이벤트 핸들러의 에러는 잡지 못하므로 별도 try-catch가 필요합니다.

에러 처리 전략

API 호출 래퍼 함수

JS
async function apiCall(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });

    if (!response.ok) {
      throw new HttpError(response.status, `API 요청 실패: ${url}`);
    }

    return { data: await response.json(), error: null };
  } catch (error) {
    if (error instanceof HttpError) {
      return { data: null, error };
    }
    // 네트워크 에러 등
    return { data: null, error: new Error('네트워크 연결을 확인해주세요') };
  }
}

// 사용
const { data, error } = await apiCall('/api/users');
if (error) {
  showToast(error.message);
} else {
  renderUsers(data);
}

에러 리포팅

프로덕션에서는 Sentry, Datadog 같은 에러 모니터링 도구를 연동하는 게 일반적입니다. 전역 핸들러에서 에러를 수집하고, 사용자 환경 정보(브라우저, OS 등)와 함께 전송합니다.

주의할 점

finally에서 return하면 try의 return을 덮어쓴다

finally 블록에 return을 넣으면 trycatch의 반환값을 무시하고 finally의 값을 반환합니다. 거의 항상 버그이므로 finally 안에 return을 넣지 않아야 합니다.

catch 없는 Promise는 에러가 삼켜진다

.catch()를 달지 않은 Promise가 reject되면 에러가 조용히 사라집니다. 프로덕션에서는 unhandledrejection 이벤트를 전역으로 등록하여 에러 리포팅 서비스로 전송하는 것이 필수입니다.

uncaughtException 후 프로세스 상태를 신뢰할 수 없다

Node.js에서 uncaughtException이 발생하면 프로세스 내부 상태가 불일치할 수 있습니다. 로그를 남기고 프로세스를 재시작하는 것이 안전합니다.

정리

항목설명
try-catch동기 코드의 런타임 에러만 잡음
비동기 에러async/await + try-catch 또는 .catch()
커스텀 에러extends Error로 타입별 구분, instanceof로 분기
전역 핸들러window.onerror, unhandledrejection (브라우저), uncaughtException (Node.js)
Result 패턴{ success, data/error } 형태로 에러를 값으로 다룸
댓글 로딩 중...