Rust의 전통적인 에러 처리 방식이 놓치는, 실패하면서도 작업을 계속해야 하는 상황을 다루는 방법을 살펴보고 Go식 접근과 에러 수집 파라미터 패턴을 비교합니다.
여기에는Rust의에러처리에 대해 많은 이야기가 있었다. 에러를 어떻게 구조화할지, 언제 panic을 사용할지, 그리고 에러 처리에서 proc-macros의 역할이 무엇인지에 대해서는 매우 다양한 의견이 존재한다. Rust의 에러 처리에 대한 대부분의 논의는 에러가 발생했을 때 호출자에게 그 에러를 전달하거나, 아니면 완전히 복구해서 계속 진행한다고 가정한다. 이런 설명은 많은 상황을 포괄하지만, 모든 경우를 다루지는 못한다. 나는 에러 처리와 그것에 접근하는 방식을 다른 각도에서 보고 싶다. 이것은 주류적인 에러 전파의 혼합체를 대체하려는 것이 아니라, 단지 도구 상자에 새로운 도구 하나를 더하려는 것이다.
Result를 반환하는 함수를 호출하는 함수가 있다면, Rust에서는 이를 처리하는 몇 가지 서로 다른 방법이 있다. 이를 보여주기 위해, 문자열 2개를 받아 u32로 파싱한 뒤 더하는 함수가 있다고 하자. 문자열 파싱에 실패하는 상황을 처리하는 몇 가지 다른 방법은 다음과 같다.
그 함수는 그냥 panic할 수도 있다. 이 경우에는 함수 문서에서 이를 설명하는 일이 꽤 흔하다.
/// Parse the 2 strings into a `u32`s and add them
///
/// # Panics
///
/// Panics if the provided string can not be parsed
fn add_strs_panic(s1: &str, s2: &str) -> u32 {
let u1 = s1.parse().expect("s1 should be a valid `u32`");
let u2 = s2.parse().expect("s2 should be a valid `u32`");
u1 + u2
}
그 함수는 Option을 반환할 수도 있다. 이런 접근은 표준 라이브러리가 제공하는 조합자, 예를 들어 물음표 연산자와 함께 사용하는 Option::ok 같은 것을 자주 활용한다.
/// Parse the 2 strings into `u32`s and add the results
///
/// returns `None` if parsing fails
fn add_strs_option(s1: &str, s2: &str) -> Option<u32> {
let u1 = s1.parse().ok()?;
let u2 = s2.parse().ok()?;
Some(u1 + u2)
}
그 함수는 result 타입을 반환할 수도 있다. 이 경우는 종종 에러를 어떤 식으로든 감싸는 작업을 포함한다.
enum AddStrsError {
// variants that wrap it nicely (skipped for brevity)
}
/// parse the 2 strings into `u32`s and add the results
///
/// return an error if parsing fails
fn add_strs_result(s1: &str, s2: &str) -> Result<u32, AddStrsError> {
let u1 = s1.parse().map_err(/* provide more context for the error */)?;
let u2 = s2.parse().map_err(/* provide more context for the error */)?;
Ok(u1 + u2)
}
심지어 아예 실패하지 않고 대신 실패에서 복구하는 좋은 방법도 있다. 함수가 잘못된 입력을 0이나 다른 임의의 값으로 취급할 수 있다.
(여담이지만, 나는 “값으로서의 에러”의 가장 큰 장점 중 하나가 에러에서 복구하는 것 을 쉽고 깔끔하게 할 수 있다는 점이라고 생각한다.)
/// parse the 2 strings into `u32`s and add the results
///
/// if parsing fails, defaults the value to 0
fn add_strs_default(s1: &str, s2: &str) -> u32 {
// we could use `unwrap_or_default` but I think 0 is more clear here
let u1 = s1.parse().unwrap_or(0);
let u2 = s2.parse().unwrap_or(0);
u1 + u2
}
마지막 예시는 다소 인위적이고 그 예시에는 아마 잘 맞지 않을 수 있지만, 나는 실제로 저 코드와 어느 정도 비슷한 코드를 작성해 본 적이 있다. 이렇게 “기본값으로 대체하는” 코드는 한 가지 문제를 가진다. 함수 호출자는 그것이 수행되었는지 알 방법이 없다. 함수가 에러에서 복구하고 계속 작업을 수행하는 것은 완전히 타당하다. 동시에 함수 호출자가 함수 내부에서 무언가 실패했다는 사실을 알고 싶어 하는 것도 타당하다. 문제는 이 두 가지가 함께 일어날 때를 위한 해법이 부족하다는 점이다.
실패했지만 계속 진행하는 이 문제는 꽤 흔하다. 몇 가지 (아마 더 현실적인) 예시는 다음과 같다.
나는 go programming language가 에러를 처리하는 방식에 이 문제가 없다는 점이 꽤 흥미롭다고 생각한다. 익숙하지 않은 사람들을 위해 아주 짧게 개요를 설명하겠다.
Go는 Rust와 마찬가지로 “값으로서의 에러”를 사용한다. Go에서 에러는 Error 인터페이스를 구현하는 아무 타입이나 될 수 있다. Rust와 달리 Go에는 사실상 sum type이 없다. 그래서 sum type을 사용하는 대신, 실패할 수 있는 함수는 정상 경로 값과 error 타입을 함께 담은 튜플을 다음처럼 반환한다.
func AddStrsDefault(s1 string, s2 string) (uint64, error) {
var err error
i1, err := strconv.ParseUint(s1, 10, 32)
// note: if both functions return an error, only the second error will be seen
i2, err := strconv.ParseUint(s2, 10, 32)
return i1 + i2, err
}
Go에서는 어떤 포인터든 nil이 될 수 있기 때문에, 에러가 없다면 반환되는 값은 nil로 설정된다.
Rust에서 이 문제를 만났을 때 이것도 하나의 실용적인 선택지가 될 수 있다. 계산은 항상 완료되지만 에러도 함께 발생할 수 있다면, 선택적인 에러도 같이 반환할 수 있다.
fn add_strs_default(s1: &str, s2: &str) -> (u32, Option<AddStrsError>) {
let mut ret_err: Option<AddStrsError> = None;
// we could use `unwrap_or_default` but I think 0 is more clear here
let u1 = match s1.parse() {
Ok(val) => val,
Err(err) => {
ret_err = Some(/* map the error */);
0
}
};
let u2 = match s2.parse() {
Ok(val) => val,
Err(err) => {
ret_err = Some(/* map the error */);
0
}
};
(u1 + u2, ret_err)
}
그러면 이 함수를 호출하는 쪽은 에러를 전파할지, 아니면 제공된 값을 그냥 사용할지 선택할 수 있다.
여기서 몇 가지 짚고 넘어갈 점이 있다.
나는 이 해결책이 비교적 단순한 일회성 함수에는 잘 맞는다고 생각한다. 하지만 이런 함수가 코드베이스에 많이 있거나, 코드의 로직이 지나치게 복잡하다면, 이것이 문제를 해결한다고 보지는 않는다.
해결책을 생각해 내기 위해서는, 무엇을 원하는지 나열해 보는 것이 가치 있다고 생각한다. 이것은 하나 이상의 해결책이 필요한 경우를 식별하는 데에도 도움이 된다. 이 문제에 대해 내가 원하는 것은 다음과 같다.
내가 제안할 첫 번째 해결책은 “에러 파라미터”라고 부르는 것이다. 핵심 아이디어는, 오류가 날 수 있는 함수에 에러를 여러 방식으로 수집할 수 있는 파라미터를 넘기는 것이다. 이것은 특히 수집하려는 에러들이 꽤 전체론적인 성격을 띨 때, 즉 애플리케이션의 큰 부분에서 나온 에러를 모으고 싶을 때 유용하다.
아주 적은 노력으로도 이를 위한 무언가를 만드는 것은 충분히 가능하지만, 나는 anyhow 같은 crate가 이 접근을 돕는 더 포괄적인 api를 제공할 여지가 있다고 생각한다.3
간단한 버전은 다음과 비슷할 수 있다.
// You could also create a variant that only holds a specific error.
#[derive(Default)]
struct MultiErrors(Vec<Box<dyn Error>>);
impl MultiErrors {
/// handle a `Result`, adding any errors internally
///
/// This is very similar to the `Result::ok` function in the standard library
// A more ergonomic version of this api would probably want to use interior mutability in some form
fn handle_result<T, E>(&mut self, res: Result<T, E>) -> Option<T>
where E: Error
{
match res {
Ok(val) => Some(val),
Err(err) => self.0.push(
Box::new(err) as Box<dyn Error>
),
}
}
/// Check if there are any errors inside here
fn is_ok(&self) -> bool {
self.0.is_empty()
}
/// Get all the errors encountered
fn get_errs(&self) -> &[Box<dyn Error>] {
&self.0
}
/// Convert to a `Result`
fn as_result(&self) -> Result<(), &[Box<dyn Error>]> {
if self.is_ok() {
Ok(())
} else {
Err(self.get_errs())
}
}
}
이 타입은 다음처럼 사용할 수 있다.
fn add_strs_result(
s1: &str,
s2: &str,
errs: &mut MultiErrors
) -> u32 {
let u1 = errs.handle_result(s1.parse()).unwrap_or(0);
let u2 = errs.handle_result(s2.parse()).unwrap_or(0);
u1 + u2
}
나는 이것이 꽤 좋은 접근이라고 생각한다. 처음에 정리했던 요구 사항 대부분을 충족한다. 자기 자신과도, “전통적인 Rust 에러 처리”와도 잘 조합된다. 개별 에러를 처리하는 데 필요한 코드 양도 크게 줄여 준다. 그리고 매우 다양한 사용 사례를 허용한다. 나는 이것이 이 문제에 대한 매우 좋은 해결책이라고 생각한다.
그렇다고 해도, 이 접근이 완벽하다고는 생각하지 않는다. 이 접근은 실패를 반드시 처리하게 만들지 않으며(혹은 적어도 인지하게 만들지 않으며), 세상에서 가장 “idiomatic”한 접근이라고 하기도 어렵고, 반환 타입 역시 이 함수가 실패할 수 있음을 전달하지 않는다(함수 전체의 타입은 그렇다 해도 말이다). 다른 절충점을 택하는 해결책들도 분명 있을 것이다4. 특히 불안정한 Try trait를 활용해 이 문제를 어떻게 풀 수 있을지 보는 것은 흥미로울 것 같다.
Rust의 전통적인 에러 처리는 매우 훌륭하다. 하지만 특정 종류의 작업에는 충분하지 않다. 구체적으로 말하면, 작업을 계속 수행하면서 동시에 에러를 호출자에게 보고하고 싶은 경우에는 잘 맞지 않는다. Go식 에러 처리 접근은 동작하긴 하지만 전통적인 에러 처리와 조합되지 않는다. 나는 더 나은 대안으로 MultiErrors 구조체를 호출자에게 전달하고, 함수 실행 중 마주친 에러를 그것이 추적하게 하는 방식을 제안한다. 이것이 Rust 에러 처리와 관련된 흥미로운 문제를 조금이나마 비춰 주고, Rust에서 에러를 어떻게 다뤄야 하는지에 대한 더 많은 논의로 이어지길 바란다.
사실 나는 어떤 경우에는 이것을 그냥 NaN이 되게 두는 편이 맞다고 생각한다. 가장 큰 이유는 NaN이 일종의 에러 경로처럼 동작하지만 코드에 분기를 추가할 필요는 없기 때문이다. 때로는 NaN의 가능한 모든 발생을 처리하지 않는 편이 더 낫다(더 빠르고, 더 명확하며, 여전히 올바르다).↩
나는 이것이 가치 있을 수 있다고 생각하며, 거의 항상 올바른 기본값 이라고도 생각한다. 하지만 한 테스트의 실패가 다른 테스트의 실패를 항상 의미하고, 두 번째 테스트를 실행하는 것이 그저 잡음만 만드는 경우도 있다고 생각한다. 흔히 이런 종류의 테스트 의존성은 복잡한 DAG를 만들고, 그래서 모든 테스트 프레임워크가(기본 Rust 테스트 프레임워크를 포함해) 이것을 지원하지 않는 이유도 이해된다.↩
나는 이런 crate를 만들 계획이 있긴 하지만, 완성 정도가 제각각인 아마 10개쯤 되는 다른 프로젝트 계획도 있다. 그래도 블로그 글 하나를 썼다는 것만으로도 만족한다.↩
예를 들어, 내가 이것을 Haskell로 한다면, 파라미터를 명시적으로 넘기는 대신 Writer Monad의 변형을 사용할 것이다. 그러면 개별 함수 호출들을 더 쉽게 조합할 수 있다.↩