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_fail을 panic!을 써서 다시 만들어 보면 이런 느낌일 것이다.
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!
그러면 이 두 가지의 에러 표현 방식, Result와 panic! 중에 무엇을 쓰는 것이 좋을까? 일반적으로 받아들여지는 답은 ‘상황 따라 다르다’이다.
Result를 써야 할 때
발생 가능한 에러를 기반으로 복구와 재개를 시행할 가능성이 조금이라도 있으면 Result를 사용해야 한다.
예를 들어, 네트워크에서 파일을 다운로드받는 함수를 사용하고 있다 하자. 여러 시나리오가 있겠지만 아무튼 다운로드가 실패했을 때에 무엇을 할 수 있을까?
가장 간단한 방법으로는 n번 재시도해볼 수 있을 것이다. 이런 경우에는 Result와 Error를 사용하는 것이 옳다.
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!하지만, 일반적으로 받아들여지는 쓰임새가 조금씩 다르다.
unwrap은assert!와 비슷한 의미로, 항상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)
}
calculate는 may_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))
}
이 때 원본 Result의 E와 적용할 실패 가능한 함수의 리턴형 Result의 E는 같은 타입이어야만 한다. 그래야 별다른 변환 없이 결과를 대입할 수 있기 때문이다.
이 역시 실패했을 때(즉 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에서 다뤄보고자 한다.