Rust 오류 처리 라이브러리 failure의 배경과 한계, std의 Error 개선 이후의 방향, 대안으로서 anyhow와 라이브러리/애플리케이션에서의 권장 접근, 그리고 #[throws], throw! 매크로를 제공해 오류 처리 문법을 가볍게 하는 새 라이브러리 fehler의 동기와 사용 소감에 대해 다룹니다.
약 2년 반 전에 나는 failure라는 Rust 라이브러리를 만들었고, 이는 곧 Rust에서 가장 인기 있는 오류 처리 라이브러리 중 하나가 되었다. 이번 주에 현재 유지관리자가 이를 사용 중단(deprecate)하기로 결정했는데, 나는 그 결정을 강하게 지지한다. 또한 이번 주에 나는 아주 다르게 접근하는 새로운 오류 처리 라이브러리인 fehler를 공개했다. 이 두 라이브러리에 대해 간단히 이야기하고 싶다.
내가 failure를 발표했을 당시 가장 인기 있던 오류 처리 라이브러리는 단연코 error-chain이었다. 그때의 또 다른 주요 라이브러리는 quick-error 정도였다. 두 라이브러리는 모두 같은 기본 API를 제공했다. 즉 macro_rules 매크로를 이용해 프로그램에서 발생할 수 있는 모든 오류 종류를 열거해 거대한 오류 enum을 만드는 방식이었다.
나에게 error-chain의 문법은 꽤 혼란스러웠고, 내가 작성하던 종류의 코드에는 장황하고 과도하게 복잡하다고 느꼈다. 내 경험으로는 애플리케이션 수준에서 대부분의 사용자는 오류를 각 경우마다 매치해서 다르게 처리해야 하는 상황이 그렇게 잦지 않았다. 대신, 대부분의 애플리케이션은 다음과 같은 공통 패턴을 따른다고 느꼈다:
Result를 반환하는 코드는 핫 경로에 있을 수 있지만 Error 분기는 그렇지 않다)failure의 핵심 아이디어는 오류 타입을 enum이 아니라 트레이트 객체로 모델링하는 것이었다. 이 아이디어를 내가 처음 낸 것은 아니다. 원래부터 std의 Error 트레이트가 그렇게 사용되도록 의도되어 있었다. 내가 한 일은 사람들이 왜 의도된 방식대로 Error 트레이트를 이미 쓰고 있지 않은지 살펴본 것이었다. 나는 다음과 같은 결론에 이르렀다:
Error::description 메서드는 실질적인 효용이 거의 없다.'static을 요구한다. 예를 들어 Error::cause 메서드는 'static 바운드가 없어 다운캐스트할 수 없는 객체를 반환한다.-> Result<(), Box<dyn Error + Send + Sync + 'static>> 같은 시그니처를 기분 좋게 쓰지 않는다.이렇게 해서 “수정된” Error 트레이트인 Fail 트레이트가 탄생했다. 동시에 failure는 본질적으로 Box<dyn Fail>에 몇 가지 특별한 기능을 더한 Error 타입을 제공했다. 이전 라이브러리들의 복잡한 매크로 문법을 피하기 위해 Fail에 대한 간편한 derive도 제공했다. 도입은 빠르고 폭넓게 이루어졌다.
failure의 큰 문제는 모두가 새로운 오류 트레이트인 failure::Fail을 받아들여야 했다는 점이다. 이는 failure를 쓰지 않는 사람들과의 호환성 문제를 낳았고, 여러 가지로 좌절스러운 경험으로 이어졌다. 내가 failure를 만들었을 때는, 언젠가 std에 failure와 동일한 “수정된” 오류 트레이트가 추가되어, 결국 failure가 그 트레이트를 failure::Fail로 재수출(re-export)할 수 있으리라 기대했다.
하지만 시간이 흐르며 상황은 달라졌다. 결국 나는 RFC 2504에 설명된 대로 Error 트레이트 자체를 변경했다. 그 결과, failure를 std 생태계와 호환되게 만들 장치가 사라지고 말았다.
결국 failure 이후로 더 많은 오류 라이브러리들이 등장했다. 예를 들어 snafu는 proc 매크로를 사용한다는 점을 제외하면 일종의 error-chain 정신적 후속작이라고 할 수 있다. failure의 API를 좋아하는 이들에게 내가 추천하고 싶은 크레이트는 anyhow다. 이는 기본적으로 failure::Error 타입(좀 더 고급스러운 트레이트 객체)을 제공하되, 기반을 failure가 아니라 std의 Error 트레이트에 둔다.
일반적으로, 대부분의 라이브러리에는 오류 타입을 수동으로 정의하고 그에 대해 Error 트레이트를 구현하는 방식을 권한다. 이것이 너무 복잡하다면, 왜 API가 그렇게 많은 종류의 오류를 던지는지, 그리고 그 라이브러리가 너무 많은 일을 하고 있는 것은 아닌지 돌아보길 바란다.
애플리케이션이라면, 다양한 오류를 다루기 위해 enum이 아니라 트레이트 객체를 사용하는 것을 추천한다. 사용하는 모든 라이브러리가 던질 수 있는 모든 오류를 열거하려 드는 것은 대개 얻는 것보다 잃는 게 더 많다고 믿는다. 그리고 대부분의 애플리케이션에서 이는 행복 경로를 덜 비관화(pessimize)함으로써 더 높은 성능으로 이어진다고 여전히 주장한다. Box<dyn Error + Send + Sync + 'static>보다 훨씬 다루기 쉬울 뿐 아니라, 그 자체로 더 나은 트레이트 객체인 anyhow::Error를 추천한다.
failure를 만들 당시, 나는 오류 처리를 더 가볍게 만들어 줄 Rust의 문법 변화에 대한 구상도 갖고 있었다. Rust의 Result 타입에서 내가 좋아하는 점은 오류를 발생시킬 수 있는 함수는 정의 측과 호출 측 모두에서 반드시 그 사실을 주석처럼 드러내야 한다는 것이다. 이는 절대 잃고 싶지 않은 훌륭한 기능인데, 사용자가 자신의 함수에서 모든 조기 반환 지점을 식별할 수 있게 해 주기 때문이다(즉, 실패 가능 함수 호출에 ?를 붙여 표시할 수 있는 점은 놀라운 기능이다).
내가 Rust의 오류 처리에서 마음에 들지 않는 점은, 실패 가능 함수로 작성했다는 사실이 실패하지 않는 모든 반환 경로에까지 미치는 영향이다. 온갖 상용구 Ok() 표현식을 잔뜩 쓰는 것이 내게 이득이 되었다고 느끼지 않는다. 나는 필요할 때까지는 함수가 오직 행복 경로에만 존재하는 것처럼 다루다가, 그 실패 가능성을 살펴봐야 할 때에만 드러나게 하는 문법을 선호한다.
이 관점은 매우 논쟁적이며, 나와 동의하지 않는 사람들과 이를 논쟁할 여력은 없다. 하지만 Rust에는 매우 강력한 매크로 시스템이 있으니, 그럴 필요도 없다.
Fehler는 두 가지 매크로를 제공하는 라이브러리다. #[throws]라는 속성과 throw!()라는 식 매크로다. throws로 주석(annotation)된 함수에서는 모든 반환이 “Ok” 행복 경로에 놓인다:
#[throws(io::Error)]
fn read_to_string(path: &impl AsRef<Path>) -> String {
let mut file = File::open(path)?:
let mut string = String::new();
file.read_to_string(&mut string)?;
string // Ok 래핑이 필요 없다
}
이 문맥에서 오류를 일으키고 싶다면, throw 매크로를 사용하면 된다:
#[throws(io::Error)]
fn read_to_string(path: &impl AsRef<Path>) -> String {
let mut file = File::open(path)?:
let mut string = String::new();
file.read_to_string(&mut string)?;
if string.len() == 0 {
throw!(io::Error::new(io::ErrorKind::Other, "empty file"));
}
string
}
이 문법은 Option을 반환하는 함수 등도 지원하며, 더 알고 싶다면 문서를 참고하라.
지난 두 달 동안 나는 개인 프로젝트에서 Fehler를 사용해 왔고, 매우 만족하고 있다. 유일한 문제라면 proc 매크로 특성상 잘 처리되지 않는 파싱 오류에서 나쁜 에러 메시지가 나올 때가 있다는 점이다. 이것이 Rust에 훌륭한 추가 요소가 될 것이라는 확신은 여전하다. 이런 문법으로 Rust를 써 보고 싶은 누구든 이 라이브러리를 사용해 작동 방식을 체감해 보길 권한다. Rust의 오류 처리에 관한 논의는 구체적 경험에 근거하지 않은 이념적 공방으로 흐른 경우가 많았고, 이 라이브러리는 그 너머의 이해를 제공해 준다.
덧붙이면, 이 기능을 개선하려는 모든 시도가 얼마나 큰 소모였는지를 생각하면, 나는 Rust의 오류 처리 문법을 더 개선하자고 밀어붙일 의사가 전혀 없다. 그래서 나는 fehler를 갖게 되어 매우 기쁘며, 남을 설득할 필요 없이 앞으로도 내 모든 프로젝트에서 계속 사용할 생각이다.