Rust에서 어떻게 에러를 처리하고 복구하는지에 대해 실전 위주로 다뤄보고자 한다.

enum Result<T>

Rust 에러 처리의 중추는 Result 열거형이다. 이 열거형의 정의는 매우 단순하다.

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result is a type that represents either success (Ok) or failure (Err).

정의에서 볼 수 있듯이, 이는 ‘실패 가능한’ 값을 표현하는 데 주로 쓰인다. 성공 시 Ok를, 실패 시 Err를 가진다. 열거형이므로, 안에서 성공 또는 실패한 값을 꺼내려면 패턴 매칭을 사용해야 한다.

let x = Ok::<u32, String>(3);
/* if x == 3 { } */ // 불가능하다. 실패했을 수도 있으니까.
match x {
    Ok(x) => if x == 3 { /* ... */ }
    Err(e) => panic!("oops: {e}")
}

이를 사용하면 ‘실패 가능한 함수’를 표현할 수 있다.

fn may_fail(a: u32, minus_b: u32) -> Result<u32, &'static str>{
    if a > minus_b {
        Ok(a - minus_b)
    } else {
        Err("would overflow!")
    }
}

panic!

깜짝 놀라서 모든 것을 다 내던지고 싶을 때, panic!을 외치면 된다.

panic!("망했어요"):
thread 'main' panicked at '망했어요', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

panic!은 다른 언어의 exception과 동일하게 구현되어 있다. panic!을 ‘던지면’, 바깥의 핸들러가 ‘잡아줄’ 때 까지 계속해서 호출 스택을 더듬어 올라가게 된다. 그리고 그 기록이 backtrace로 남지만, RUST_BACKTRACE 환경 변수가 1이거나 full인 경우에만 표시되므로 주의가 필요하다. (full의 경우 1에서 쓸 데 없어서 숨겨진 일부 스택도 보여준다)

이러한 기능에는 비용이 들기 때문에, 필요 없는 경우에는 Cargo profile에서 panic = "abort"를 주어 panic이 발생하는 그 즉시 프로그램이 종료되게 할 수도 있다. 관해서는 Cargo의 레퍼런스 문서를 참고하기 바란다.

위의 may_failpanic!을 써서 다시 만들어 보면 이런 느낌일 것이다.

fn may_fail(a: u32, minus_b: u32) -> u32 {
    if a > minus_b {
        a - minus_b
    } else {
        panic!("would overflow!")
    }
}

이 함수는 리턴값이 성공/실패 여부를 표현하지 않는다. 대신에 실패하면 panic!을 통해 실행을 중단하고 함수 밖으로 탈출하기만 한다.

To panic!, or not to panic!

그러면 이 두 가지의 에러 표현 방식, Resultpanic! 중에 무엇을 쓰는 것이 좋을까? 일반적으로 받아들여지는 답은 ‘상황 따라 다르다’이다.

Result를 써야 할 때

발생 가능한 에러를 기반으로 복구와 재개를 시행할 가능성이 조금이라도 있으면 Result를 사용해야 한다. 예를 들어, 네트워크에서 파일을 다운로드받는 함수를 사용하고 있다 하자. 여러 시나리오가 있겠지만 아무튼 다운로드가 실패했을 때에 무엇을 할 수 있을까? 가장 간단한 방법으로는 n번 재시도해볼 수 있을 것이다. 이런 경우에는 ResultError를 사용하는 것이 옳다.

fn download_file(url: &str) -> Result<String, Box<dyn Error>> {
    todo!();
}

let mut tries = 0;
let result = loop {
    tries += 1;
    match download_file("https://blog.cro.sh/index.xml") {
        Err(e) if tries < 3 => {
            warn!("download failed: {}", e);
            continue;
        }
        other => break other,
    }
}

panic!을 사용한다면 장황한 catch_unwind를 사용한 코드를 작성해야 하기도 하거니와, exception 처리의 높은 비용을 감수해야만 할 것이다. (혹은, panic = "abort"로 인해 애초에 catch_unwind가 불가능할 수도 있다. 또한 일부 에러는 이에 관계없이 무조건 abort하므로 잡을 수 없다.)

panic!을 써야 할 때

반대로 복구의 가능성이 전혀 없는, 애초에 빠지면 안 되는 상황으로 들어갔을 때에는 panic!이 적절하다. 예를 들어 Vec을 인덱싱할 때 out of bounds 접근을 시도했다면, 처음부터 중대한 실수를 범한 것이기 때문에 Result::Err를 내뱉는다고 딱히 할 일이 없을 것이다. (애초에 이런 상황이 발생하지 않도록 인덱스를 사전에 검사했어야 할 것이다). 그래서 Vec<T>이나 [T]를 인덱싱할 때의 결과값은 T이고 실패 시 panic!을 낸다.

(만약에 인덱스 체크를 통해 ‘실패 가능한 인덱싱’을 하고 싶다면 .get()을 쓸 수 있다. 이 메서드는 Option<T>를 반환한다.)

똑똑하게 Result 다루기

unwrap & expect

둘 모두 Ok를 만나면 값을 꺼내오고 Err를 만나면 panic!하지만, 일반적으로 받아들여지는 쓰임새가 조금씩 다르다.

  • unwrapassert!와 비슷한 의미로, 항상 Ok여야 해서 안의 값을 꺼내오려 하지만 Err가 발생하는 상황도 버그로 간주하여 고려하겠다는 의도로 사용하곤 한다.
  • expect는 이와 조금 다르게, Err를 만나도 처리할 방법이 도저히 없어 panic!으로 선회하겠다는 의미로 사용하고는 한다. 그래서 panic!에 사용할 추가적인 메시지를 적을 수 있다.

try

Result를 쓰더라도, 다른 언어의 exception과 비슷하게 오류를 전파하고 싶을 때가 자주 있을 것이다. 예를 들면,

fn download_files() -> Result<Vec<String>> {
    let file1 = match download_file("foo") {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    let file2 = match download_file("bar") {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    let file3 = match download_file("baz") {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    vec![file1, file2, file3]
}

이는 try 연산자라 불리는, ?을 통해 쉽게 달성할 수 있다. 위 예시를 다시 써보면,

fn download_files() -> Result<Vec<String>> {
    let file1 = download_file("foo")?;
    let file2 = download_file("bar")?;
    let file3 = download_file("baz")?;
    vec![file1, file2, file3]
}

훨씬 간결해진 것을 볼 수 있다. Result를 반환하는 함수 위에서 Result를 반환하는 함수를 쓸 때는 대부분 호출한 뒤 ?를 쓰는 패턴을 사용하게 될 것이다.

try 연산자는 위 예시보다 조금 더 일반적이라, 반환받은 에러가 반환할 에러 타입으로 변환 가능하기만 하면 사용 가능하다. 즉 다음 코드와 같은 역할을 한다(실제 정의는 조금 다르므로 std::ops::Try를 참고하면 된다).

// foo()?
match foo() {
    Ok(x) => x,
    Err(e) => return Err(e.into())
}

map & and_then

map(FnOnce(T) -> U)Result<T, E>Result<U, E>로 바꿔준다. 즉 에러가 발생했다면 아무 일도 하지 않고, 성공했다면 성공한 결과를 다른 타입으로 변환시킨다.

map의 주목할 점은 Result의 에러 여부에 관계없이 성공한 값에 대해 처리를 수행하고, 오류 검사를 나중으로 ‘미룰’ 수 있다는 점이다.

fn calculate(a: u32, minus_b: u32, bitor_c: u32) -> Result<u32, &'static str> {
    may_fail(a, minus_b).map(|x| x | bitor_c)
}

calculatemay_fail에서 실패할 수도 있지만, 실패를 검사할 책임은 호출자에게 떠넘겨지고 ‘성공했다면’ bitor_c와 binary OR을 수행한 결과를 반환한다.

map_err도 자주 쓰이는데, map과 동일한 기능을 Err(E)에 대고 수행해준다. ?의 암시적인 .into() 대신에 수동으로 E를 변환하고 싶을때 주로 같이 쓰인다.

만약 변환이 아니라 또다른 실패 가능한 함수를 적용하고 싶다면 and_then을 쓰면 된다.

fn minus_twice(a: u32, minus_b: u32) -> Result<u32, &'static str> {
    may_fail(a, minus_b).and_then(|x| x.may_fail(a, minus_b))
}

이 때 원본 ResultE와 적용할 실패 가능한 함수의 리턴형 ResultE는 같은 타입이어야만 한다. 그래야 별다른 변환 없이 결과를 대입할 수 있기 때문이다.

이 역시 실패했을 때(즉 Err일 때) 적용 가능한 버전인 or_else가 있으니 참고하기 바란다.

transpose

Option<Result>Result<Option>간의 상호 변환이다. transpose라는 이름답게 두 타입의 의미를 표로 나열해서 보면 이해가 더 쉬워진다.

Ok Err
Some Some(Ok) Some(Err)
None None None

여기서 가로와 세로를 ‘뒤집으면’, 아래처럼 바꿀 수 있을 것이다:

Some None
Ok Ok(Some) Ok(None)
Err Err Err

반대 방향도 동일하게 처리된다.

flatten

Result<Result<T, E>, E>Result<T, E>로 ‘평평하게 다듬어’ 준다. 바깥쪽이 성공했으면 안쪽을 쓰고, 실패했으면 실패한 것이다.

사실은, 간단한 구현으로 이를 모사할 수 있다:
result.and_then(|x| x) // 혹은 std::convert::identity

collect (Result as FromIterator)

?를 쓰면 1개의 Result에 대해 검사와 조기 반환을 수행할 수 있다. 근데 임의 개수의 Result에 대해서도 그렇게 할 수 있을까?

Result<T, E>T: FromIterator<U>면 자신도 FromIterator<Result<U, E>>를 구현한다. 즉 U의 모임을 .collect::<T>()할 수 있다면, 실패 가능한 U, 즉 Result<U, E>의 모임도 .collect::<Result<T, E>>할 수 있다는 것이다. 이는 반복자를 순회하다 첫 번째 에러가 발생할 때 Err(E)가 되어 멈추고, 한 번도 에러가 발생하지 않았다면 Ok(T)가 되는 식으로 구현된다. 이를 사용하면 임의 개수의 Result에 동시에 ?를 쓰는 효과를 유도할 수 있다.

let files = (1..10).map(|_| download_file("foo")).collect::<Result<Vec<_>>>()?;

Infallible

trait 구현 등에서 Result<T, E>E에 해당하는 타입을 지정해야 하는데, 절대 실패하지 않을 것을 알고 있다면 타입 레벨에서 이를 표현할 수 있을까?

Infallible은 해당하는 값이 단 한개도 없는 ‘빈 자료형’으로, 이런 상황에서 에러가 ‘절대 발생하지 않는다’는 것을 표현하는 데에 쓸 수 있다.

enum Infallible {}

즉 다음 함수는 무조건 성공한다: 어떤 경우에도 Err를 반환하지 않는다. Err(E)에 넣을 값 자체가 존재하지 않기 때문이다.

fn always_succeeds() -> Result<String, Infallible>;

아쉽게도 지금은 Result<T, Infallible>에서 String을 즉시 꺼낼 방법이 없다(Nightly에는 존재한다: into_ok를 쓰면 된다). 대신에 match infallible_value {} 를 이용하자.

let x = match always_succeeds() {
    Ok(x) => x,
    Err(e) => match e {}
}

trait Error

지금까지 Result에 대해 장황하게 떠들었지만, 아직 Error 그 자체에 대해서는 이야기하지 않았다. 이에 대해서는 Part 2에서 다뤄보고자 한다.