async/await 에러 처리 패턴 — try/catch·에러 래핑·fallback
async/await 환경에서 에러를 올바르게 처리하는 패턴(try/catch, Go스타일 튜플, 에러 래핑·재통, 미처리 rejection 감지)과 안티패턴을 정리합니다.
지난 글에서 async/await의 내부 동작과 직렬·병렬 패턴을 살펴봤습니다. 마지막으로 async/await 환경에서 에러를 어떻게 처리하고, 어디서 포착해야 하는지 실전 패턴을 정리합니다.
기본 패턴: try/catch/finally
await 표현식이 rejected Promise를 받으면 throw된 것처럼 동작합니다. 그래서 동기 코드와 동일한 try/catch로 처리할 수 있습니다.
async function loadUser(id) {
try {
const user = await fetchUser(id);
return user;
} catch (e) {
// fetchUser reject 시 여기로
showError('사용자 로드 실패');
return null;
} finally {
hideLoadingSpinner();
}
}
finally는 성공/실패에 무관하게 항상 실행됩니다. 로딩 인디케이터, 연결 해제 같은 정리 작업에 적합합니다.
Go 스타일 — 에러를 값으로
try/catch를 반복 사용하면 코드가 장황해집니다. 에러와 결과를 함께 반환하는 헬퍼를 만들면 깔끔해집니다.
async function safe(promise) {
return promise
.then(value => [null, value])
.catch(error => [error, null]);
}
// 사용
const [err, user] = await safe(fetchUser(id));
if (err) {
log.error('사용자 로드 실패', err);
return;
}
const [postsErr, posts] = await safe(fetchPosts(user.id));
// ...
이 패턴은 각 호출마다 에러를 명시적으로 확인하게 만들어, 에러를 조용히 삼키는 실수를 줄입니다.
에러 래핑 — 원인 추적
에러를 잡았을 때 그대로 re-throw 하면 어느 레이어에서 실패했는지 알기 어렵습니다. 에러를 래핑해서 cause로 원인을 보존하세요.
class UserServiceError extends Error {
constructor(message, { cause } = {}) {
super(message);
this.name = 'UserServiceError';
this.cause = cause;
}
}
async function getUser(id) {
try {
return await fetchUser(id);
} catch (e) {
throw new UserServiceError(`사용자 ${id} 로드 실패`, { cause: e });
}
}
instanceof로 에러 종류를 구분하고, e.cause로 근본 원인을 추적할 수 있습니다.
try {
await getUser(id);
} catch (e) {
if (e instanceof UserServiceError) {
console.error(e.message, e.cause);
} else {
throw e; // 알 수 없는 에러는 재통
}
}
Fallback 패턴
실패해도 서비스를 계속 제공해야 할 때는 기본값으로 폴백합니다.
async function getUserWithFallback(id) {
try {
return await fetchUser(id);
} catch (e) {
logger.warn('사용자 API 실패, 캐시 사용', e);
return cache.getUser(id) ?? defaultUser;
}
}
에러를 조용히 삼키지 말고 반드시 로깅하세요. 나중에 문제를 추적할 때 유일한 단서가 됩니다.
미처리 Rejection 감지
await 없이 async 함수를 호출하거나 .catch 없이 Promise를 만들면 미처리 rejection이 발생합니다.
// 위험: rejection이 조용히 사라짐
async function doWork() { throw new Error('oops'); }
doWork(); // await 없음 → unhandledRejection
브라우저와 Node.js 모두 미처리 rejection 이벤트를 제공합니다.
// 브라우저
window.addEventListener('unhandledrejection', event => {
console.error('미처리 rejection:', event.reason);
event.preventDefault(); // 콘솔 경고 억제
});
// Node.js
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('미처리 rejection', { reason });
process.exit(1);
});
프로덕션에서는 Sentry 같은 에러 추적 서비스와 연동해서 미처리 rejection을 모니터링하세요.
에러 처리 계층 설계
좋은 에러 처리는 계층별로 역할을 분리합니다.
UI 레이어: 사용자에게 표시할 메시지 결정
Service 레이어: 도메인 에러로 래핑
API 레이어: 네트워크 에러 분류 (4xx vs 5xx)
전역 핸들러: 미처리 rejection 로깅 + 알림
각 레이어는 자신이 처리할 수 있는 에러만 잡고, 나머지는 위로 전파해야 합니다. 모든 에러를 한 곳에서 삼키는 “god catch”는 디버깅을 극도로 어렵게 만듭니다.
흔한 안티패턴
// 1. 에러 무시 (절대 금지)
fetchData().catch(() => {});
// 2. 에러를 console.log로만 처리
try {
await fetchData();
} catch (e) {
console.log(e); // 사용자는 실패를 모름
}
// 3. 에러 유형 확인 없는 전체 catch
try { /* ... */ } catch (e) {
// TypeError든 NetworkError든 동일 처리
showGenericError();
}
에러는 항상 (1) 기록하고, (2) 사용자에게 적절히 알리거나 폴백을 제공하고, (3) 가능하면 유형을 구분해서 처리하세요.
지난 글: async/await 내부 동작 — 제너레이터와 Promise의 결합
읽어주셔서 감사합니다. 😊