에러 핸들링 패턴: 이론 vs 현실
교과서대로 에러를 처리하면 코드가 아름다워지는 줄 알았다. 현실은 try-catch 지옥이었다.
try-catch를 어디에 넣어야 하는지 몰랐다
주니어 시절 에러 핸들링을 처음 배웠을 때, 모든 함수에 try-catch를 감쌌다. 진짜 모든 함수에. 함수 10개가 연쇄적으로 호출되면 try-catch도 10겹이었다. 에러가 잡히긴 잡혔는데 어디서 터진 건지 추적이 안 됐다.
console.log로 찍어도 같은 에러가 10번 출력됐다. 각 레이어에서 catch하고 다시 throw하니까 스택 트레이스도 뒤죽박죽이었다.
상위에서 한 번만 잡으면 된다는 걸 늦게 알았다
에러는 발생한 곳에서 처리할 수 없으면 위로 던져야 한다. 그리고 최종적으로 처리할 수 있는 곳에서 한 번만 잡으면 된다. 이 원칙을 이해하고 나서 코드가 확 깔끔해졌다.
Express 미들웨어로 글로벌 에러 핸들러를 만들고, 비즈니스 로직에서는 에러를 그냥 throw만 했다. 코드 줄 수가 30% 줄었다.
(근데 이것도 만능은 아니었다.)
커스텀 에러 클래스, 언제 만들어야 하나
처음엔 커스텀 에러를 안 만들었다. 그냥 throw new Error("유저를 찾을 수 없습니다")를 했다. 근데 API에서 404를 내려줘야 하는지 400을 내려줘야 하는지 에러 메시지만 보고는 판단할 수 없었다.
그래서 NotFoundError, ValidationError, UnauthorizedError 같은 커스텀 에러를 만들었다. 글로벌 에러 핸들러에서 에러 타입에 따라 HTTP 상태 코드를 매핑했다. 이건 꽤 잘 작동했다.
근데 커스텀 에러가 14개로 불어났을 때 "이거 너무 많은 거 아닌가?" 싶었다. PaymentFailedError, PaymentTimeoutError, PaymentInvalidAmountError... 에러 클래스를 만드는 게 일이 됐다.
에러 메시지 관리의 현실
사용자에게 보여줄 에러 메시지와 개발자가 볼 에러 메시지가 다르다. "Internal Server Error"를 유저한테 보여주면 CS 폭탄이 터지고, 디테일한 에러를 보여주면 보안 이슈가 생긴다.
에러 객체에 userMessage와 debugMessage를 분리했다. 이론적으로 깔끔했는데, 실제로는 userMessage를 안 넣고 배포하는 실수가 잦았다. 유저한테 undefined가 표시된 적이 2번 있다.
(undefined라니, 그게 에러 메시지로 나가다니.)
비동기 에러의 늪
Promise에서 catch를 안 하면 unhandled rejection이 터진다. Node.js에서 이게 프로세스를 죽일 수도 있다. process.on('unhandledRejection', ...) 핸들러를 넣어두긴 했는데, 이게 마지막 방어선이지 이걸 믿고 개발하면 안 된다.
async/await에서 try-catch 안에 await를 여러 개 넣으면 어떤 await에서 터진 건지 구분이 안 된다. 각각 감싸면 또 try-catch 지옥이 된다. 이 딜레마는 아직도 깔끔한 답이 없다.
지금의 원칙
에러는 복구할 수 있는 곳에서만 catch한다. 나머지는 위로 던진다. 커스텀 에러는 HTTP 상태 코드 기준으로 5개 이하만 만든다. 에러 로깅은 한 곳에서만 한다. 이 정도만 지켜도 대부분 상황은 커버된다.
솔직히 완벽한 에러 핸들링은 본 적이 없다. 결국 어딘가에서는 예상 못한 에러가 터지고, 그때마다 코드를 고친다. 그게 현실이다.