에러를 단순히 위로 전파하는 관행이 왜 디버깅과 복구에 도움이 되지 않는지 살펴보고, 머신과 인간이라는 두 청중을 위한 목적 지향적 오류 설계(평탄한 kind 기반 구조 + 저마찰 컨텍스트 캡처)를 제안한다.
URL: https://fast.github.io/blog/stop-forwarding-errors-start-designing-them/
Title: 오류를 전달하지 말고, 설계하라 | FastLabs / 블로그
Jan 04·
10 Min Read
새벽 3시. 프로덕션이 다운됐다. 로그 한 줄을 뚫어져라 보고 있다. 거기엔 이렇게 적혀 있다:
Error: serialization error: expected ',' or '}' at line 3, column 7
JSON이 깨졌다는 건 알겠다. 하지만 왜, 어디서, 누가 깨뜨렸는지는 전혀 감이 없다. 설정 로더 때문인가? 사용자 API 때문인가? 웹훅 컨슈머 때문인가?
이 오류는 스택의 20개 레이어를 거슬러 올라오면서 원래 메시지는 완벽하게 보존했지만, 그 과정에서 의미의 조각은 모조리 잃어버렸다.
우리는 이것에 이름을 붙였다. “에러 핸들링(Error Handling)”이라고 부른다. 하지만 현실에서는 그저 **에러 전달(Error Forwarding)**일 뿐이다. 에러를 뜨거운 감자처럼 다룬다—잡고, (아마) 감싸고, 가능한 한 빨리 스택 위로 던져버린다.
println!을 하나 추가하고, 서비스를 재시작하고, 버그가 재현되길 기다린다. 오늘 밤은 길어질 것이다.
대규모 Rust 프로젝트에서의 에러 핸들링을 다룬 상세 분석에서도 이렇게 언급한다:
“최고의 실천 사례를 홍보하는 수많은 의견 중심의 글이나 라이브러리가 있고, 그로 인해 끝나지 않는 거대한 논쟁이 벌어진다. 우리 모두가 에러 핸들링 관행에 뭔가 문제가 있다는 것을 알아차리기 시작했지만, 정확한 문제를 짚어내는 것은 어렵다.”
std::error::Error 트레이트: 고결하지만 결함 있는 추상화표준 Error 트레이트는 source()를 중심으로 만들어졌다. 하나의 에러가 (선택적으로) 다른 에러를 가리키는 형태다. 많은 실패가 이 모델에 잘 맞는다.
하지만 가장 골치 아픈 문제들 중 일부는 단일한 인과관계의 줄이 아니다. 검증(validation)은 동시에 다섯 군데에서 실패할 수 있다. 배치 작업은 부분적으로 성공할 수 있다. 타임아웃은 부분 결과를 동반할 수 있다. 이런 상황에서는 단일 체인보다 원인들의 집합(set)이나 트리(tree)에 더 가까운 구조가 필요하다.
Rust의 std::backtrace::Backtrace는 에러 관측 가능성(observability)을 개선하기 위해 도입됐다. 없는 것보단 낫다. 하지만 심각한 한계가 있다:
async 코드에서는 시끄럽거나 오해를 부를 수 있다. 백트레이스에는 49개의 스택 프레임이 있고 그 중 12개가 GenFuture::poll() 호출일 수 있다. Async Working Group의 노트에 따르면, 중단(suspend)된 태스크는 전통적인 스택 트레이스에서 보이지 않는다.
원점(origin)은 보여주지만, 경로(path)는 보여주지 못한다. 백트레이스는 에러가 생성된 위치를 알려줄 뿐, 애플리케이션을 통해 어떤 논리적 경로로 흘러왔는지는 말해주지 않는다. “사용자 X의 요청 핸들러가, 파라미터 Z를 가지고 서비스 Y를 호출했다” 같은 정보를 제공하지 못한다.
백트레이스 캡처는 비용이 크다. 표준 라이브러리 문서도 이렇게 인정한다: “백트레이스를 캡처하는 것은 꽤 비싼 런타임 연산이 될 수 있다.”
Provider API (RFC 3192)와 generic member access (RFC 2895)는 에러에 동적(type 기반) 데이터 접근을 추가한다:
fn provide<'a>(&'a self, request: &mut Request<'a>) { request.provide_ref::<Backtrace>(&self.backtrace);}
불안정(unstable)한 Provide/Request API는 에러를 더 유연하게 만들려는 최신 시도다. 아이디어는 이렇다: 에러가 (HTTP 상태 코드나 백트레이스 같은) 타입이 지정된 컨텍스트를 동적으로 제공하고, 호출자는 런타임에 그것을 요청할 수 있다. 하지만 실제로는 새로운 문제를 만든다:
예측 불가능성: 당신의 에러가 HTTP 상태 코드를 제공할 수도 있고, 아닐 수도 있다. 런타임이 되기 전엔 알 수 없다.
복잡성: API가 너무 미묘해서 LLVM이 여러 provide 호출을 최적화하기 어려워한다.
대부분의 경우, 이름 있는 필드를 가진 평범한 struct가 여전히 가장 원하는 형태다.
thiserror: 행동이 아니라, 출처로 분류하기thiserror는 에러 enum을 쉽게 정의할 수 있게 해준다:
#[derive(Debug, thiserror::Error)]pub enum DatabaseError { #[error("connection failed: {0}")] Connection(#[from] ConnectionError), #[error("query failed: {0}")] Query(#[from] QueryError), #[error("serialization failed: {0}")] Serde(#[from] serde_json::Error),}
합리적으로 보인다. 하지만 이런 관행이 에러를 어떻게 분류하는지 보자: 호출자가 할 수 있는 행동이 아니라, 에러의 **출처(origin)**로 분류한다.
DatabaseError::Query를 받았을 때 무엇을 해야 할까? 재시도? 사용자에게 raw SQL을 보여줘야 하나? 이 에러는 알려주지 않는다. 그저 어떤 의존성이 실패했는지만 말해준다.
한 블로거가 정확히 꼬집었듯이: “이 에러 타입은 호출자에게 당신이 어떤 문제를 해결하고 있는지를 말해주지 않고, 어떻게 해결하는지를 말해준다.”
anyhow: 너무 편해서 컨텍스트 추가를 잊는다anyhow는 정반대 접근을 취한다: 타입 소거(type erasure). 어디서나 anyhow::Result<T>를 쓰고 ?로 전파하면 된다. 더 이상 enum variant도, #[from] 어노테이션도 필요 없다.
문제는 그게 너무 편하다는 것이다.
fn process_request(req: Request) -> anyhow::Result<Response> { let user = db.get_user(req.user_id)?; let data = fetch_external_api(user.api_key)?; let result = compute(data)?; Ok(result)}
각각의 ?는 컨텍스트를 추가할 기회를 놓치는 것이다. 사용자 ID는 무엇이었나? 어떤 API를 호출했나? 어떤 계산이 실패했나? 에러는 이 중 아무것도 알지 못한다.
anyhow 문서는 .context()로 정보를 추가하라고 권장한다. 하지만 .context()는 선택 사항이다—타입 시스템이 강제하지 않는다. 그리고 “나중에 컨텍스트를 추가하자”는 건 스스로에게 하기 가장 쉬운 거짓말이다.
Rust 코드베이스에서 흔히 보이는 패턴을 보자:
#[derive(thiserror::Error, Debug)]pub enum ServiceError { #[error("database error: {0}")] Database(#[from] sqlx::Error), #[error("http error: {0}")] Http(#[from] reqwest::Error), #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), // ... ten more variants}
깔끔하고 구조적이며 컴파일도 된다. 하지만 잠깐 멈춰서 물어보자:
DatabaseError::Query를 들고 있다면, 재시도 가능한가? 사용자에게 raw SQL 에러를 보여줘야 하나? 에러 타입은 이런 질문에 답하는 데 도움을 주지 못한다.
디버깅 관점에서 “serialization error: expected , or }”만 보고 어떤 요청, 어떤 필드, 어떤 코드 경로가 여기로 이어졌는지 알 수 있는가?
이것이 에러 핸들링을 바라보는 우리의 관점에서 발생하는 근본적인 단절이다. 우리는 에러를 정확히 _전파_하는 것, 타입을 맞추는 것에 집중한다. 하지만 에러는 메시지라는 사실—언젠가 기계(복구를 시도하는)나 사람(디버깅하는)이 읽게 될 메시지라는 사실—을 잊는다.
아마 이런 통념을 들어봤을 것이다: “라이브러리에는 thiserror, 애플리케이션에는 anyhow를 써라.”
보기 좋은 단순 규칙이지만, 정확하진 않다. Luca Palmieri가 말하듯: “그건 올바른 프레이밍이 아니다. 의도(intent)를 중심으로 생각해야 한다.”
진짜 질문은 라이브러리를 쓰는지 애플리케이션을 쓰는지가 아니다. 진짜 질문은: 호출자가 이 에러로 무엇을 하길 기대하는가?
| 청중 | 목표 | 필요 |
|---|---|---|
| 기계(Machines) | 자동 복구 | 평탄한 구조, 명확한 에러 종류(kind), 예측 가능한 코드 |
| 사람(Humans) | 디버깅 | 풍부한 컨텍스트, 호출 경로, 비즈니스 레벨 정보 |
대부분의 에러 핸들링 설계는 둘 중 어느 쪽도 최적화하지 않는다. _컴파일러_를 최적화할 뿐이다.
에러를 프로그램적으로 처리해야 할 때 복잡성은 적이다. 재시도 로직은 특정 variant를 찾기 위해 중첩된 에러 체인을 순회하고 싶지 않다. 그냥 is_retryable()? 같은 질문을 하고 싶다.
Apache OpenDAL의 에러 설계는 이를 구현하는 한 가지 방법을 보여준다:
pub struct Error { kind: ErrorKind, message: String, status: ErrorStatus, operation: &'static str, context: Vec<(&'static str, String)>, source: Option<anyhow::Error>,}pub enum ErrorKind { NotFound, PermissionDenied, RateLimited, // ... categorized by what the caller CAN DO}pub enum ErrorStatus { Permanent, // Don't retry Temporary, // Safe to retry Persistent, // Was retried, still failing}
그러면 호출부는 간단하게 유지된다:
match result { Err(e) if e.kind() == ErrorKind::RateLimited && e.is_temporary() => { sleep(Duration::from_secs(1)).await; retry().await } Err(e) if e.kind() == ErrorKind::NotFound => { create_default().await } Err(e) => return Err(e), Ok(v) => v,}
몇 가지 주목할 점:
ErrorKind는 출처가 아니라 대응(response)으로 분류된다. NotFound는 “대상이 존재하지 않으니 재시도하지 말라”를 뜻한다. RateLimited는 “속도를 늦추고 다시 시도하라”를 뜻한다. 호출자는 S3의 404였는지 파일시스템의 ENOENT였는지 알 필요가 없다—무엇을 해야 하는지가 필요할 뿐이다.
ErrorStatus가 명시적이다. 에러 타입에서 재시도 가능성을 추측하는 대신, 1급 필드로 제공한다. 서비스는 재시도가 도움이 될 수 있다고 판단할 때 에러를 temporary로 표시할 수 있다.
라이브러리당 Error 타입 하나. 모듈 곳곳에 에러 enum을 흩뿌리는 대신, 단 하나의 평탄한 구조가 단순함을 유지해 준다. context 필드는 타입이 증식하지 않으면서도 필요한 모든 구체성을 제공한다.
더 이상 에러 체인을 순회하지 않아도 되고, 타입에서 추측하지 않아도 된다. 에러에게 직접 물어보면 된다.
좋은 에러 컨텍스트의 최대 적은 “기능 부족”이 아니라 “마찰”이다. 컨텍스트 추가가 귀찮으면 개발자는 하지 않는다.
exn 라이브러리(294줄의 Rust, 의존성 0)는 한 가지 접근을 보여준다: 에러가 프레임(frame)들의 _트리_를 이룬다. 각 프레임은 #[track_caller]로 소스 위치를 자동 캡처한다. 선형 에러 체인과 달리 트리는 여러 원인을 표현할 수 있다—병렬 작업이 실패하거나 검증이 여러 에러를 내는 경우에 유용하다.
핵심 재료는 다음과 같다:
자동 위치 캡처. 비싼 백트레이스 대신 #[track_caller]로 파일/라인/컬럼을 비용 0으로 캡처한다. 모든 에러 프레임은 생성 위치를 알아야 한다.
인체공학적인 컨텍스트 추가. 컨텍스트를 추가하는 API는 너무 자연스러워서, 추가하지 않는 것이 이상하게 느껴질 정도여야 한다:
fetch_user(user_id) .or_raise(|| AppError(format!("failed to fetch user {user_id}")))?;
thiserror에서 같은 컨텍스트를 추가하려면, 새 variant를 정의하고 수동으로 감싸야 한다:
#[derive(thiserror::Error, Debug)]pub enum AppError { #[error("failed to fetch user {user_id}: {source}")] FetchUser { user_id: String, #[source] source: DbError, }, // ... one variant per call site that needs context}fn fetch_user(user_id: &str) -> Result<User, AppError> { db.query(user_id).map_err(|e| AppError::FetchUser { user_id: user_id.to_string(), source: e, })?}
모듈 경계에서 컨텍스트를 강제하라. 이것이 exn이 anyhow와 결정적으로 다른 지점이다. anyhow에서는 모든 에러가 anyhow::Error로 소거되므로 언제나 ?를 써서 넘겨버릴 수 있다—타입 시스템이 막지 않는다. 컨텍스트 메서드는 존재하지만, 무시해도 그만이다.
exn은 다른 접근을 취한다: Exn<E>는 최외곽(outermost) 에러 타입을 보존한다. 함수가 Result<T, Exn<ServiceError>>를 반환한다면, Result<U, Exn<DatabaseError>>를 그대로 ?로 올릴 수 없다—타입이 맞지 않는다. 컴파일러가 강제로 or_raise()를 호출하게 만들고 ServiceError를 제공하게 한다. 그리고 바로 그 순간이, 당신의 모듈이 무엇을 하려다 실패했는지에 대한 컨텍스트를 추가해야 하는 지점이다.
// 이 코드는 컴파일되지 않는다—타입 불일치가 컨텍스트 추가를 강제한다pub fn fetch_user(user_id: &str) -> Result<User, Exn<ServiceError>> { let user = db.query(user_id)?; // Error: expected Exn<ServiceError>, found Exn<DbError> Ok(user)}// 경계에서 반드시 컨텍스트를 제공해야 한다pub fn fetch_user(user_id: &str) -> Result<User, Exn<ServiceError>> { let user = db.query(user_id) .or_raise(|| ServiceError(format!("failed to fetch user {user_id}")))?; // 이제 컴파일된다 Ok(user)}
타입 시스템이 당신의 동맹이 된다: 모듈 경계에서 게을러질 수 없게 만든다.
실제로는 이렇게 된다:
pub async fn execute(&self, task: Task) -> Result<Output, ExecutorError> { let make_error = || ExecutorError(format!("failed to execute task {}", task.id)); let user = self.fetch_user(task.user_id) .await .or_raise(make_error)?; let result = self.process(user) .or_raise(make_error)?; Ok(result)}
모든 ?에 컨텍스트가 붙는다. 새벽 3시에 이게 실패하면, 모호한 serialization error 대신 다음을 보게 된다:
failed to execute task 7829, at src/executor.rs:45:12||-> failed to fetch user "John Doe", at src/executor.rs:52:10||-> connection refused, at src/client.rs:89:24
실제 시스템에서는 대개 둘 다 필요하다: 자동 복구를 위한 기계가 읽을 수 있는 에러, 그리고 디버깅을 위한 사람이 읽을 수 있는 컨텍스트. 패턴은 이렇다: 구조화된 데이터를 위해 (Apache OpenDAL처럼) 평탄한 kind 기반 에러 타입을 사용하고, 전파를 위해 컨텍스트 추적 메커니즘으로 감싼다.
// 기계 지향: status를 가진 평탄한 structpub struct StorageError { pub status: ErrorStatus, pub message: String,}// 사람 지향: 각 레이어에서 컨텍스트와 함께 전파pub async fn save_document(doc: Document) -> Result<(), Exn<StorageError>> { let data = serialize(&doc) .or_raise(|| StorageError::permanent("serialization failed"))?; storage.write(&doc.path, data) .await .or_raise(|| StorageError::temporary("write failed"))?; Ok(())}
경계에서는 에러 트리를 걸어가며 구조화된 에러를 찾아낸다:
// 트리 어디서든 타입이 지정된 에러를 추출fn find_error<T>(exn: &Exn<impl Error>) -> Option<&T> { fn walk<T>(frame: &Frame) -> Option<&T> { if let Some(e) = frame.as_any().downcast_ref::<T>() { return Some(e); } frame.children().iter().find_map(walk) } walk(exn.as_frame())}match save_document(doc).await { Ok(()) => Ok(()), Err(report) => { // 사람을 위해: 전체 컨텍스트 트리를 로그로 남긴다 log::error!("{:?}", report); // 기계를 위해: 구조화된 에러를 찾아 처리한다 if let Some(err) = find_error::<StorageError>(&report) { if err.status == ErrorStatus::Temporary { return queue_for_retry(report); } return Err(map_to_http_status(err.kind)); } Err(StatusCode::INTERNAL_SERVER_ERROR) }}
트리를 순회해야 하긴 한다—하지만 Provide/Request API와 비교해보라. 여기서는 StorageError 같은 구체 타입을 찾는다: 이름 있는 필드가 있고, 문서화할 수 있고, IDE가 자동완성을 해준다. 추측도 없고, 런타임의 놀라움도 없다—이해하고 유지보수할 수 있는 잘 정의된 struct일 뿐이다.
Rust에서 에러를 전파하는 건 쉽다. 미루게 되는 건 “설명”이다.
다음에 Result를 반환할 때 30초만 투자해서 이렇게 물어보자: “이게 프로덕션에서 실패하면, 로그에 뭐라고 적혀 있길 바랄까?” 그리고 그 말을 실제로 기록하게 만들자.
Last edited Jan 05