완벽한 에러 핸들링을 위한 전략: try...catch 그 이상의 실무 패턴

소프트웨어 개발에서 "코드가 실행된다"는 것은 절반의 성공일 뿐입니다. 나머지 절반은 "예상치 못한 상황에서 코드가 어떻게 우아하게 실패하는가"에 달려 있습니다. 특히 네트워크 지연, 잘못된 사용자 입력, API 서버의 장애가 빈번한 현대 웹 생태계에서 에러 핸들링은 서비스의 신뢰도를 결정짓는 핵심 요소입니다.
단순히 에러를 잡는(catch) 것을 넘어, 시스템을 보호하고 사용자 경험을 해치지 않는 시니어 수준의 예외 처리 전략을 짚어보겠습니다.
1. Deep Dive: 왜 단순한 try...catch만으로는 부족할까?
대부분의 입문자는 모든 코드 블록을 try...catch로 감싸면 안전하다고 믿습니다. 하지만 이는 **"무차별적 수용"**이라는 함정에 빠지기 쉽습니다.
에러 핸들링의 본질: 전파(Propagation)와 억제(Suppression)
에러 핸들링의 핵심은 **"여기서 해결할 것인가, 아니면 상위 레이어로 보고할 것인가"**를 결정하는 것입니다. 모든 곳에서 에러를 잡아버리면(Silent Fail), 정작 디버깅이 필요한 시점에 에러의 근원지를 찾을 수 없는 '좀비 코드'가 생성됩니다.
비유로 이해하기 레스토랑에서 요리 도중 재료가 떨어졌다고 가정해 봅시다.
- 하위 레이어(요리사): 재료가 없음을 인지하고 매니저에게 알립니다. (Error Throwing)
- 상위 레이어(매니저): 손님에게 품절을 안내하거나 다른 메뉴를 추천합니다. (Error Handling)
만약 요리사가 재료가 없는데도 매니저에게 알리지 않고(Catch 후 아무것도 안 함) 빈 접시를 내보낸다면, 서비스 전체의 신뢰도가 무너지는 것과 같습니다.
2. Hands-on: 실무형 이커머스 결제 로직 예제
단순한 console.error가 아닌, 상태 복구와 로그 추적을 포함한 실전 결제 프로세스 코드를 살펴보겠습니다.
/**
* 이커머스 결제 처리 함수
* @param {string} orderId - 주문 ID
* @param {object} paymentInfo - 결제 수단 정보
*/
async function processOrderPayment(orderId, paymentInfo) {
try {
// 1. 재고 확인 (Pre-condition Check)
const stockAvailable = await checkInventory(orderId);
if (!stockAvailable) {
// 예상 가능한 비즈니스 에러는 커스텀 에러 객체를 사용합니다.
throw new Error("INSUFFICIENT_STOCK");
}
// 2. 결제 API 호출
const paymentResult = await externalPaymentGateway(paymentInfo);
// 3. 주문 상태 업데이트
await updateOrderStatus(orderId, 'PAID');
return { success: true, transactionId: paymentResult.id };
} catch (error) {
// [Troubleshooting] 에러의 성격에 따른 분기 처리
if (error.message === "INSUFFICIENT_STOCK") {
// 비즈니스 로직 에러: 사용자에게 친절한 안내 필요
console.warn(`[Order-Warn] 주문 ${orderId}: 재고 부족`);
return { success: false, reason: "재고가 부족하여 결제가 취소되었습니다." };
}
if (error.name === "TimeoutError") {
// 인프라 에러: 일시적인 문제이므로 재시도 권장
console.error(`[Order-Error] 결제 게이트웨이 타임아웃: ${error.stack}`);
return { success: false, reason: "결제 서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요." };
}
// 4. 예상치 못한 에러 (Critical)
// 로그 시스템(예: Sentry, Datadog)에 에러 전송
captureException(error, { extra: { orderId } });
// 치명적 에러 발생 시 시스템 안정성을 위해 상위로 에러를 다시 던집니다 (Re-throw)
throw new SystemError("결제 처리 중 내부 시스템 오류가 발생했습니다.", error);
} finally {
// 성공/실패 여부와 상관없이 실행 (예: 로딩 스피너 제거, 리소스 해제)
hideLoadingSpinner();
}
}
핵심 포인트 설명
- Custom Error Identification: error.message나 error.name을 통해 에러의 성격을 분류했습니다.
- Contextual Logging: 단순 에러 메시지가 아닌 orderId와 같은 컨텍스트를 함께 남겨 추적을 용이하게 했습니다.
- finally의 활용: 에러가 발생하더라도 UI의 상태(로딩 인디케이터 등)는 반드시 정리되어야 합니다.
3. Trade-offs: 에러 핸들링의 비용과 대안
완벽한 에러 핸들링에도 비용(Trade-off)이 따릅니다.
- 가독성 저하: 모든 라인을 try...catch로 감싸면 비즈니스 로직보다 에러 처리 코드가 더 길어지는 "Pyramid of Doom"이 발생할 수 있습니다.
- 대안: 미들웨어나 중앙 집중식 에러 핸들러(Global Error Boundary)를 활용하여 공통 로직을 분리하세요.
- 성능 오버헤드: 과도한 호출 스택 생성과 예외 객체 생성은 미세하게나마 메모리와 CPU를 소모합니다.
- 대안: 제어 흐름(Control Flow)을 위해 에러를 사용하지 마세요. (예: 단순히 데이터가 없는 경우 throw를 하기보다 null이나 Optional 패턴을 반환하는 것이 효율적입니다.)
4. 결론 및 요약
에러 핸들링은 단순히 앱이 죽지 않게 만드는 방어 기제가 아닙니다. 실패한 상황에서도 시스템이 다음 동작을 예측 가능하게 만드는 설계입니다.
- 비즈니스 에러와 시스템 에러를 구분하세요.
- 사용자에게는 이해 가능한 언어로, 개발자에게는 추적 가능한 로그로 응답하세요.
- try...catch는 해결할 수 있는 위치에서만 사용하고, 그렇지 않다면 과감히 위로 던지세요.