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에서 다뤄보고자 한다.