
새벽 3시, 갑자기 울리는 호출벨 소리에 잠에서 깹니다. 프로덕션 서버가 다운되었다는 알림입니다. 비몽사몽간에 노트북을 열고 로그를 확인하니 화면에는 건조한 에러 메시지 한 줄만 덩그러니 남아 있습니다. "Error: serialization error: expected , or } at line 3, column 7". JSON 형식이 깨졌다는 건 알겠습니다. 하지만 도대체 어디서 발생한 문제일까요? 설정 파일을 불러오던 중이었을까요, 아니면 사용자 API 요청이었을까요? 혹시 외부 웹훅을 처리하던 중은 아니었을까요? 에러는 스택의 20개 레이어를 타고 올라오면서 본래의 메시지는 보존했을지 몰라도, 정작 우리가 문제를 해결하는 데 필요한 '의미'는 모두 잃어버렸습니다. 저 역시 주니어 시절, 이런 상황에서 무작정 서비스를 재시작하고 제발 문제가 재현되지 않기를 기도하며 밤을 지새운 적이 한두 번이 아닙니다.
우리는 흔히 이것을 '에러 핸들링(Error Handling)'이라고 부릅니다. 하지만 냉정하게 말해, 우리가 현업에서 관성적으로 하고 있는 행위는 그저 '에러 전달(Error Forwarding)'에 가깝습니다. 마치 뜨거운 감자 게임을 하듯, 에러가 발생하면 일단 잡아서 적당히 포장한 뒤 최대한 빨리 상위 스택으로 던져버리기 급급합니다. `println!` 혹은 로깅 라이브러리를 추가하고, 다시 빌드하고 배포하며 시간을 허비합니다. 근본적인 원인은 우리가 에러를 다루는 방식, 즉 에러를 시스템의 부산물이 아닌 '설계의 대상'으로 바라보지 않는 데에 있습니다. 10년 동안 수많은 장애를 겪으며 깨달은 것은, 에러 처리에 대한 논쟁이 끝이 없는 이유는 우리가 잘못된 질문을 던지고 있기 때문이라는 사실입니다.
Rust 생태계를 예로 들어보겠습니다. `std::error::Error` 트레이트는 에러가 체인(Chain) 형태라고 가정합니다. 즉, 하나의 에러는 하나의 원인(source)을 가진다는 전제입니다. 하지만 현실은 훨씬 복잡합니다. 유효성 검사 실패처럼 여러 필드에서 동시에 문제가 발생하거나, 타임아웃과 부분적인 성공이 섞여 있는 경우처럼 에러의 원인이 '트리(Tree)' 구조를 이룰 때, 표준 라이브러리의 추상화는 무력해집니다. 또한, `Backtrace`는 동기적인 코드에서는 유용할지 몰라도, 모던 애플리케이션의 핵심인 비동기(Async) 환경에서는 거의 쓸모가 없습니다. 스택 트레이스의 절반 이상이 런타임의 `poll` 호출로 채워져 있고, 정작 비즈니스 로직의 흐름은 끊겨 보이기 일쑤입니다. 이는 마치 교통사고가 났을 때, 사고 지점의 위도와 경도는 알려주지만 운전자가 어떤 경로로 운전해왔는지는 전혀 알려주지 않는 내비게이션과 같습니다.
현업에서 가장 흔하게 저지르는 실수는 에러를 '발생 위치(Origin)' 기준으로만 분류하는 것입니다. `thiserror`와 같은 라이브러리를 사용해 `DatabaseError`, `NetworkError`, `SerializationError` 등으로 깔끔하게 열거형(Enum)을 정의하면 코드는 그럴듯해 보입니다. 하지만 이 에러를 받은 호출자(Caller) 입장에서 생각해보셨나요? `DatabaseError`를 받았다면, 재시도를 해야 할까요? 아니면 사용자에게 에러 팝업을 띄워야 할까요? 혹은 조용히 로그만 남기고 넘어가야 할까요? 이 에러 타입은 "어떤 라이브러리가 실패했는지"만 알려줄 뿐, "그래서 무엇을 해야 하는지(Action)"에 대한 정보는 전혀 담고 있지 않습니다. 이는 구현 세부 사항을 노출하는 것이지, 에러를 설계한 것이 아닙니다.
반대로 `anyhow` 같은 라이브러리를 사용해 모든 에러를 하나로 뭉뚱그려 처리하는 경우도 위험합니다. 물음표 연산자(`?`) 하나로 에러를 전파하는 것은 너무나 편리해서 중독적이기까지 합니다. 개발자는 "나중에 `.context()`로 어떤 상황인지 설명을 달아야지"라고 생각하지만, 그 '나중'은 절대 오지 않습니다. 결국 새벽 3시에 깨진 JSON 에러 하나만 붙들고 있게 되는 것입니다. 사용자 ID가 무엇이었는지, 어떤 외부 API를 호출했는지, 어떤 데이터를 계산 중이었는지에 대한 맥락(Context)이 에러 전파 과정에서 모두 소거되었기 때문입니다. 타입 시스템이 이를 강제하지 않기 때문에 발생하는 전형적인 인재(人災)입니다.
결국 "라이브러리는 `thiserror`, 애플리케이션은 `anyhow`"라는 식의 단순한 이분법적 조언은 정답이 될 수 없습니다. 핵심은 '의도(Intent)'와 '청중(Audience)'입니다. 에러 메시지를 소비하는 대상은 크게 두 부류입니다. 하나는 코드 내에서 복구 로직을 수행해야 하는 '기계'이고, 다른 하나는 문제를 디버깅해야 하는 '사람(운영자)'입니다. 기계에게는 이 에러가 일시적인지 영구적인지 판단할 수 있는 상태 코드가 필요하고, 사람에게는 에러가 발생한 논리적인 경로와 당시의 변수 값들이 필요합니다. 에러를 단순히 상위로 던지는 것을 멈추고, 호출자가 이 에러를 가지고 무엇을 하길 원하는지를 고민해야 합니다. 그것이 바로 '에러 설계'의 시작입니다. 오늘 작성하신 코드의 `catch` 블록을 다시 한번 들여다보세요. 혹시 뜨거운 감자를 그저 위로 던지고 있지는 않으신가요?


