본문으로 건너뛰기

안동민 개발노트

본문 시작

Result와 복구 가능한 오류

Result의 성공·실패 값을 match로 처리하고 unwrap·expect의 위험과 물음표 연산자를 이용한 에러 전파를 익힙니다.

대부분 에러는 프로그램을 전부 중단해야 할 정도로 심각하진 않습니다.

때때로 어떤 함수가 실패할 경우는 쉽게 해석하고 대응할 수 있는 원인 때문입니다.

예를 들어 어떤 파일을 열려고 했는데 해당 파일이 존재하지 않아서 실패했다면, 프로세스를 종료해 버리는 대신 파일을 생성하는 것을 원할지도 모르죠.

1장의 Result 타입으로 잠재적 실패 다루기’절에서 Result 열거형은 다음과 같이 OkErr라는 두 개의 배리언트를 갖도록 정의되어 있음을 상기해봅시다.

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

TE는 제네릭 타입 매개변수입니다.

제네릭은 10장에서 자세히 다루겠습니다.

지금 당장은 T는 성공한 경우에 Ok 배리언트 안에 반환될 값의 타입을 나타내고 E는 실패한 경우에 Err 배리언트 안에 반환될 에러의 타입을 나타낸다는 점만 알아둡시다.

Result가 이러한 제네릭 타입 매개변수를 갖기 때문에, 반환하고자 하는 성공적인 값과 에러 값이 달라질 수 있는 다양한 상황에서 Result 타입 및 이에 정의된 함수들을 사용할 수 있습니다.

실패할 가능성이 있어서 Result 값을 반환하는 함수를 한번 호출해봅시다.

예제 8-3은 파일을 열어보는 코드입니다.

예제 8-3: 파일 열기
src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

File::open의 반환 타입은 Result<T, E>입니다.

제네릭 매개변수 TFile::open의 구현부에 성공 값인 파일 핸들 std::fs::File로 채워져 있습니다.

에러 값에 사용된 E의 타입은 std::io::Error입니다.

이 반환 타입은 File::open의 호출이 성공하여 읽을 수 있는 파일 핸들을 반환할 수도 있음을 뜻합니다.

이 함수 호출은 실패할 수도 있습니다.

예를 들면 해당 파일이 존재하지 않거나, 파일 접근을 위한 권한이 없을지도 모릅니다.

File::open 함수는 함수가 성공하거나 실패할 수 있음을 알려주면서도 파일 핸들 혹은 에러 정보를 제공할 방법이 필요합니다.

이러한 정보는 정확하게 Result 열거형이 전달하는 것입니다.

File::open이 성공한 경우에는 greeting_file_result 변수의 값이 파일 핸들을 가지고 있는 Ok 인스턴스가 될 것입니다.

실패한 경우 greeting_file_result는 발생한 에러의 종류에 관한 더 자세한 정보가 담긴 Err 인스턴스가 될 것입니다.

예제 8-3 코드에 File::open 반환 값에 따라 다르게 작동하는 코드를 추가해봅시다.

예제 8-4는 6장에서 다뤘던 match 표현식을 이용하여 Result를 처리하는 한 가지 방법을 보여줍니다.

예제 8-4: match 표현식을 사용하여 반환 가능한 Result 배리언트들을 처리하기
src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

Option 열거형과 같이 Result 열거형과 배리언트들은 프렐루드로부터 가져와진다는 점을 주의하세요.

따라서 match 갈래의 OkErr 앞에 Result::라고 지정하지 않아도 됩니다.

결과가 Ok일 때 이 코드는 Ok 배리언트 내부의 file 값을 반환하고, 그 후 이 파일 핸들 값을 변수 greeting_file에 대입합니다.

match 후에는 이 파일 핸들을 읽는 데에 사용할 수 있습니다.

match의 다른 갈래는 File::open으로부터 Err를 받은 경우를 처리합니다.

이 예제에서는 panic! 매크로를 호출하는 방법을 택했습니다.

디렉터리 내에 hello.txt라는 이름의 파일이 없는 경우 이 코드를 실행하면, panic! 매크로로부터 다음과 같은 출력을 보게 될 것입니다.

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

패닉 메시지와 보고 위치는 원인을 조사할 단서를 제공합니다. 정확한 문구와 위치 형식은 러스트 버전, 운영체제, 빌드 설정에 따라 달라질 수 있습니다.

패닉은 현재 스레드에서 시작하며 빌드의 패닉 전략에 따라 스택을 되감거나 프로세스를 중단합니다. 따라서 “패닉 한 번은 언제나 즉시 전체 프로세스를 끝낸다”고 일반화할 수는 없습니다.


서로 다른 에러에 대해 매칭하기

예제 8-4의 코드는 File::open이 실패한 원인이 무엇이든 간에 panic!을 일으킵니다.

하지만 우리는 어떠한 이유로 실패했느냐에 따라 다른 조치를 취하도록 하려고 합니다.

파일이 없어서 File::open이 실패했다면 새로운 파일을 만들어서 핸들을 반환하겠습니다.

그 밖의 이유로(예를 들어 파일을 열 권한이 없다거나 하는 이유로) 실패했다면 예제 8-4처럼 panic!을 일으키고요.

match에 내용을 추가한 예제 8-5를 살펴봅시다.

예제 8-5: 다른 종류의 에러를 다른 방식으로 처리하기
src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}

File::open이 반환하는 Err 배리언트 값의 타입은 io::Error인데, 이는 표준 라이브러리에서 제공하는 구조체입니다.

이 구조체가 제공하는 kind 메서드를 호출하여 io::ErrorKind값을 얻을 수 있습니다.

표준 라이브러리가 제공하는 io::ErrorKindio 연산으로부터 발생할 수 있는 다양한 종류의 에러를 나타내는 배리언트가 있는 열거형입니다.

여기서 사용하고자 하는 배리언트는 ErrorKind::NotFound이며, 요청한 경로의 엔트리를 찾지 못했음을 나타냅니다. 최종 파일뿐 아니라 경로 구성 요소가 없을 때도 발생할 수 있습니다.

따라서 greeting_file_result 매칭 안에 error.kind()에 대한 내부 매칭이 하나 더 생겼습니다.

내부 매치에서는 error.kind()가 반환한 값이 ErrorKind 열거형의 NotFound 배리언트가 맞는지 확인하고, 맞다면 File::create로 파일을 생성합니다.

File::create는 파일이 이미 있으면 내용을 잘라냅니다. openNotFound 확인과 생성 사이에 다른 실행 주체가 파일을 만들 수 있으므로, “존재하지 않을 때만 원자적으로 생성”해야 한다면 File::create_new를 사용하거나 OpenOptions에 쓰기와 create_new(true)를 함께 설정합니다.

하지만 File::create도 실패할 수 있으니, 내부 match 표현식의 두 번째 갈래 또한 작성해야 합니다.

파일을 생성하지 못한 경우에는 별도의 에러 메시지가 출력됩니다.

외부 match의 두 번째 갈래 또한 동일하므로, 파일을 찾을 수 없는 에러인 경우 외에는 모두 패닉이 발생합니다.


Result<T, E>match 사용에 대한 대안

match가 정말 많군요! match 표현식은 매우 유용하지만 굉장히 원시적이기도 합니다. 13장에서는 클로저에 대해서 배워볼 텐데, Result<T, E> 타입에는 클로저를 사용하는 여러 메서드가 있습니다. 이 메서드들로 Result<T, E> 값들을 처리하면 match보다 더 간결하게 만들 수 있습니다.

예를 들면, 예제 8-5와 동일한 로직을 작성한 다른 방법이 아래 있는데, 이번에는 unwrap_or_else 메서드와 클로저를 사용했습니다.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

이 코드는 예제 8-5와 같은 분기 정책을 표현하지만, match 표현식을 전혀 사용하지 않았으며 더 깔끔하게 읽힙니다. 13장을 읽고 이 예제로 돌아와서, 표준 라이브러리 문서에서 unwrap_or_else 메서드를 찾아보세요. 에러를 다룰 때 이런 메서드를 사용하면 거대하게 중첩된 match 표현식 덩어리를 제거할 수 있습니다.


에러 발생 시 패닉을 위한 숏컷: unwrapexpect

match의 사용은 충분히 잘 동작하지만, 살짝 장황하기도 하고 의도를 항상 잘 전달하는 것도 아닙니다.

Result<T, E> 타입은 다양한 특정 작업을 수행하기 위해 정의된 수많은 도우미 메서드를 가지고 있습니다.

unwrap 메서드는 예제 8-4에서 작성한 match 구문과 비슷한 구현을 한 숏컷 메서드입니다.

만일 Result 값이 Ok 배리언트라면, unwrapOk 내의 값을 반환할 것입니다.

만일 ResultErr 배리언트라면 unwrappanic! 매크로를 호출해줄 것입니다.

아래에 unwrap이 동작하는 예가 있습니다.

src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

hello.txt 파일이 없는 상태에서 이 코드를 실행시키면, unwrap 메서드에 의해 호출된 panic!으로부터의 에러 메시지를 보게 될 것입니다.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:4:49

이와 비슷한 expectErr일 때 패닉하면서, 왜 이 연산이 Ok여야 하는지 설명을 덧붙일 수 있게 해 줍니다.

패닉 숏컷이 맞는 상황이라면 unwrap 대신 expect에 기대 조건을 적어 의도를 전달하고 패닉의 근원을 추적하기 쉽게 만들 수 있습니다.

expect의 문법은 아래와 같이 생겼습니다.

src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

unwrap과 마찬가지로 Ok에서는 파일 핸들을 반환하고 Err에서는 패닉합니다.

unwrap은 기본 패닉 메시지를 사용하지만, expect는 전달한 기대 조건 설명과 원래 오류 정보를 패닉 진단에 포함합니다. 정확한 출력 형식은 러스트 버전에 따라 달라질 수 있습니다.

다음과 같은 형태로 나타납니다.

thread 'main' panicked at 'hello.txt should be included in this project: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:5:10

복구 가능한 운영 실패에는 둘 다 사용하지 않고 Result를 처리하거나 전파하는 편이 맞습니다. 이미 검증한 불변식처럼 실패가 프로그램 버그인 경우에만 패닉 숏컷을 좁게 사용하고, 그때는 expectOk여야 하는 이유를 남기면 깨진 가정을 조사하기 쉽습니다.

아래 다이어그램은 Result를 직접 처리할지, 패닉 숏컷을 쓸지, 호출자에게 전파할지 결정하는 기준을 정리합니다.

Rust Result의 Ok와 Err를 분기해 NotFound만 복구하고, 검증된 불변식에는 expect를 제한적으로 쓰며, 상위 정책이 필요하면 물음표 연산자로 오류를 전파하는 선택표

RECOVER HERE · ASSERT INVARIANT · PROPAGATE

Result 처리에서 실패 책임을 정한다

현재 함수가 복구 정책을 알면 match로 처리하고, 더 높은 맥락이 필요하면 ?로 호출자에게 돌려줍니다. unwrapexpect는 복구 전략이 아니라 Err에서 패닉하는 숏컷입니다.

recover locally · match

NotFound에만 새 파일이라는 대안이 있다

File::openOk(file)이면 사용하고, ErrNotFound일 때만 File::create를 시도합니다. 생성도 실패할 수 있으므로 그 Result까지 처리해야 하며, 권한 부족 같은 다른 오류는 별도 정책으로 보냅니다.

caller decides · ?

현재 함수에 복구 정보가 없으면 실패를 호환되는 값으로 전파한다

?는 성공 값만 다음 식으로 넘기고 실패는 필요하면 변환해 조기 반환합니다. 오류를 처리하거나 기록하는 연산이 아니며, 호출자가 재시도·대체·보고 같은 정책을 선택합니다.

open-or-create caveat

File::create는 “없을 때만 만들기”를 보장하지 않는다

File::create는 호출 시점에 파일이 이미 있으면 내용을 잘라냅니다. openNotFound 확인 뒤 다른 실행 주체가 파일을 만들 수 있으므로, 원자적인 비존재 조건과 덮어쓰기 방지가 필요하면 File::create_new를 사용하거나 OpenOptions에 쓰기와 create_new(true)를 함께 설정합니다.

복구 가능성과 책임 위치에 따른 Result 처리 방식
방식과 Ok·Err 동작맞는 사용 조건
match

Ok 값은 쓰고, Err 종류별 정책을 직접 실행합니다.

현재 함수가 각 분기의 의미와 복구·변환·반환 대안을 알고 있습니다.
unwrap

Ok 값은 반환하고, Err에서는 오류 값과 함께 패닉합니다.

짧은 예제나 실패가 버그인 검증된 불변식에만 제한합니다.
expect

Ok 값은 반환하고, Err에서는 기대 조건과 함께 패닉합니다.

복구 가능한 입력 실패가 아니라, 깨지면 버그인 가정을 검사합니다.
?

Ok 값은 이어 쓰고, Err는 변환 뒤 조기 반환합니다.

호출자가 실패 정책을 더 잘 알고 반환 타입이 호환됩니다.
match

현재 위치에서 분기

Ok 값은 쓰고, Err 종류마다 복구·변환·반환 정책을 직접 실행합니다.

unwrap

Err에서 패닉

복구 가능한 운영 실패에는 쓰지 않고, 짧은 예제나 검증된 불변식에만 제한합니다.

expect

깨진 기대 조건과 함께 패닉

Ok여야 하는지를 적되, 사용자 입력 같은 정상 실패를 불변식으로 가장하지 않습니다.

?

실패 값을 호출자에게 전파

성공 값은 이어 쓰고 실패는 변환 가능한 형태로 조기 반환합니다.

패닉은 현재 스레드에서 시작하고 빌드 설정에 따라 스택을 되감거나 프로세스를 중단할 수 있습니다. 다른 스레드의 패닉이 항상 같은 방식으로 전체 프로세스를 끝낸다고 가정하지 않으며, 패닉 문구와 위치 형식도 러스트 버전·플랫폼·빌드에 따라 달라질 수 있습니다.


에러 전파하기

함수의 구현체에서 실패할 수도 있는 무언가를 호출할 때, 이 함수에서 에러를 처리하는 대신 이 함수를 호출하는 코드 쪽으로 에러를 반환하여 그쪽에서 수행할 작업을 결정하도록 할 수 있습니다.

이를 에러 전파하기(propagating) 라고 하며 호출하는 코드 쪽에 더 많은 제어권을 주는 것인데, 호출하는 코드 쪽에는 에러를 어떻게 처리해야 하는지 결정하는 정보와 로직이 여러분의 코드 컨텍스트 내에서 활용할 수 있는 것보다 더 많이 있을 수도 있기 때문입니다.

예를 들면, 예제 8-6은 파일로부터 사용자 이름을 읽는 함수를 작성한 것입니다.

만일 파일이 존재하지 않거나 읽을 수 없다면, 이 함수는 호출하는 코드 쪽으로 해당 에러를 반환할 것입니다.

예제 8-6: match를 이용하여 호출 코드 쪽으로 에러를 반환하는 함수
src/main.rs
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

이 함수는 더 간결하게 작성할 수 있지만, 에러 처리를 배우기 위해 과정을 하나씩 직접 작성해 보고, 간결한 버전은 마지막에 살펴보도록 하겠습니다.

함수의 반환 타입인 Result<String, io::Error>부터 먼저 살펴봅시다.

함수가 Result<T, E> 타입의 값을 반환하는데 제네릭 매개변수 T는 구체 타입(concrete type) 인 String으로 채워져 있고, 제네릭 타입 E는 구체 타입인 io::Error로 채워져 있다는 뜻입니다.

만일 이 함수가 문제없이 성공하면, 함수를 호출한 코드는 String(이 함수가 파일로부터 읽어 들인 사용자 이름이겠지요)을 담은 Ok 값을 받을 것입니다.

만일 어떤 문제가 발생한다면, 이 함수를 호출한 코드는 문제가 뭐였는지에 대한 더 많은 정보를 담고 있는 io::Error의 인스턴스를 담은 Err 값을 받을 것입니다.

이 함수의 반환 타입으로 io::Error를 선택했는데, 그 이유는 이 함수 내부에서 호출하는 실패할 수 있는 연산 File::open 함수와 read_to_string 메서드 두 가지가 모두 io::Error 타입의 에러 값을 반환하기 때문입니다.

함수의 본문은 File::open 함수를 호출하면서 시작합니다.

그다음에는 예제 8-4에서 본 match와 유사하게 match를 이용하여 Result 값을 처리합니다.

만약 File::open이 성공하면 패턴 변수 file의 파일 핸들은 가변 변수 username_file의 값이 되고 함수는 계속됩니다.

Err의 경우에는 panic!을 호출하는 대신 return 키워드를 사용하여 함수 전체를 일찍 끝내고 호출한 코드 쪽에 File::open으로부터 얻은 (지금의 경우 패턴 변수 e에 있는)에러 값을 이 함수의 에러 값처럼 넘깁니다.

그래서 username_file에 파일 핸들을 얻게 되면, 함수는 username 변수에 새로운 String을 생성하고 username_file의 파일 핸들에 대해 read_to_string 메서드를 호출하여 파일의 내용물을 username으로 읽어 들입니다.

File::open이 성공하더라도 read_to_string 메서드가 실패할 수도 있으므로 Result를 반환합니다.

따라서 이 Result를 처리하기 위한 또 다른 match가 필요합니다.

read_to_string이 성공하면, 이 함수는 성공한 것이고, 이제는 username에 있는 파일로부터 읽은 사용자 이름을 Ok로 감싸서 반환합니다.

read_to_string이 실패하면, File::open의 반환 값을 처리했던 match에서의 에러 값 반환과 똑같은 방식으로 에러 값을 반환합니다.

하지만 이 함수의 마지막 표현식이기 때문에 명시적으로 return이라고 적을 필요는 없습니다.

그러면 이 코드를 호출하는 코드는 사용자 이름이 있는 Ok 값 혹은 io::Error를 담은 Err 값을 처리하게 될 것입니다.

이 값을 가지고 어떤 일을 할지에 대한 결정은 호출하는 코드 쪽에 달려 있습니다.

만일 그쪽에서 Err 값을 얻었다면, 이를테면 panic!을 호출하여 프로그램을 종료시키는 선택을 할 수도 있고, 기본 사용자 이름을 사용할 수도 있으며, 혹은 파일이 아닌 다른 어딘가에서 사용자 이름을 찾을 수도 있습니다.

호출하는 코드가 정확히 어떤 것을 시도하려 하는지에 대한 충분한 정보가 없기 때문에, 모든 성공 혹은 에러 정보를 위로 전파하여 호출하는 코드가 적절하게 처리하도록 합니다.

러스트에서는 에러를 전파하는 패턴이 너무 흔하여 이를 더 쉽게 해주는 물음표 연산자 ?를 제공합니다.


에러를 전파하기 위한 숏컷: ?

Rust 물음표 연산자가 Ok 값을 꺼내고 Err 값을 변환해 조기 반환하는 동작과, 수동 match에서 물음표 연산자와 메서드 연결을 거쳐 표준 라이브러리 함수로 축약하는 과정

UNWRAP OK · CONVERT ERR · EARLY RETURN

?는 성공 값만 이어 쓰고 실패는 호출자에게 돌려준다

?는 오류를 숨기거나 처리하는 문법이 아닙니다. Ok의 내부 값으로 현재 함수를 계속하고, Err에서는 호환되는 실패 값으로 변환한 뒤 현재 함수에서 즉시 반환합니다.

Result expansion

expression?의 핵심 동작

let value = match expression {
    Ok(value) => value,
    Err(error) => {
        return Err(From::from(error));
    }
};
Ok(value)

내부 값을 식의 결과로 사용

함수 실행은 계속됩니다. File::open(path)?라면 성공한 파일 핸들이 다음 대입이나 메서드 호출로 이어집니다.

Err(error)

변환 후 현재 함수에서 조기 반환

일반적인 Result에서는 From::from으로 현재 에러 타입에 맞춘 뒤 Err를 반환합니다. 그 지점 아래의 코드는 실행하지 않습니다.

compatible residual

둘러싼 반환 문맥이 실패 잔여값과 호환되어야 한다

Result?는 보통 오류 변환이 가능한 Result 반환 문맥에서, Option?Option 반환 문맥에서 사용합니다. 둘을 자동으로 섞지는 않으며 필요하면 ok, ok_or 같은 메서드로 의도를 드러내 변환합니다. 언어 차원에서는 FromResidual을 구현한 다른 반환 타입도 지원할 수 있습니다.

same output contract · less plumbing

파일 읽기는 네 단계로 보일러플레이트를 줄인다

  1. 수동 match

    File::openread_to_stringOk·Err를 각각 분기해 실패를 직접 반환합니다.

  2. 각 연산 뒤에 ?

    성공 값만 지역 변수로 받고 오류 변환과 조기 반환을 문법에 맡깁니다.

  3. 메서드 연결

    File::open(path)?가 만든 파일 핸들에 곧바로 read_to_string을 호출해 중간 변수를 없앱니다.

  4. fs::read_to_string(path)

    표준 라이브러리가 같은 성공 값과 io::Result<String> 반환 계약을 더 짧게 제공합니다.

네 구현의 성공 결과와 반환 타입 계약은 같지만, 내부 연산 순서와 실패 지점은 달라질 수 있습니다. 따라서 모든 오류 정보가 완전히 동일하다고 가정하지 말고, 필요한 제어와 진단 수준에 맞는 가장 단순한 구현을 고릅니다.

예제 8-7은 예제 8-6과 같은 기능을 가진 read_username_from_file의 구현체인데, 이번 단계는 ? 연산자를 이용합니다.

예제 8-7: ? 연산자를 이용하여 에러를 호출 코드 쪽으로 반환하는 함수
src/main.rs
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}

Result에 붙은 ?의 핵심은 다음 match와 같습니다.

match expression {
    Ok(value) => value,
    Err(error) => return Err(From::from(error)),
}

Ok(value)에서는 값을 꺼내 다음 코드를 계속하고, Err(error)에서는 필요하면 에러 타입을 변환한 뒤 현재 함수에서 조기 반환합니다. ? 자체가 오류를 처리하거나 기록하는 것은 아닙니다.

from 함수는 표준 라이브러리 내의 From 트레이트에 정의되어 있으며 어떤 값의 타입을 다른 타입으로 변환하는 데에 사용합니다.

Result에서 ?를 사용할 때는 현재 함수의 반환 타입이 그 실패 잔여값과 호환되어야 합니다. 일반적인 Result 경로에서는 From::from을 통해 현재 함수의 에러 타입으로 변환합니다.

이는 어떤 함수가 다양한 종류의 에러로 인해 실패할 수 있지만, 모든 에러를 하나의 에러 타입으로 반환할 때 유용합니다.

예를 들면 예제 8-7의 read_username_from_file 함수가 직접 정의한 OurError라는 이름의 커스텀 에러 타입을 반환하도록 고칠 수 있습니다.

impl From<io::Error> for OurError도 정의하여 io::Error로부터 OurError를 생성하도록 한다면, read_username_from_file 본문에 있는 ? 연산자 호출은 다른 코드를 추가할 필요 없이 from을 호출하여 에러 타입을 변환할 것입니다.

예제 8-7의 컨텍스트에서, File::open 호출 부분의 끝에 있는 ?Ok의 값을 변수 username_file에게 반환해 줄 것입니다.

만일 에러가 발생하면 ?는 함수로부터 일찍 빠져나와 호출하는 코드에게 어떤 Err 값을 줄 것입니다.

read_to_string 호출의 끝부분에 있는 ?도 같은 방식이 적용됩니다.

?는 많은 양의 보일러 플레이트를 제거해 주고 함수의 구현을 더 단순하게 만들어 줍니다.

심지어는 예제 8-8과 같이 ? 뒤에 바로 메서드 호출을 연결하는 식으로 이 코드를 더 줄일 수도 있습니다.

예제 8-8: ? 연산자 뒤에 메서드 호출을 연결하기
src/main.rs
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}

새로운 String을 만들어 username에 넣는 부분을 함수의 시작 부분으로 옮겼습니다.

이 부분은 달라진 것이 없습니다.

username_file 변수를 만드는 대신, File::open("hello.txt")?의 결과 바로 뒤에 read_to_string의 호출을 연결했습니다.

read_to_string 호출의 끝에는 여전히 ?가 남아있고, File::openread_to_string이 모두 에러를 반환하지 않고 성공했을 때 username 안의 사용자 이름을 담은 Ok를 반환하는 것도 여전합니다.

성공 결과와 io::Result<String> 반환 계약은 예제 8-6, 8-7과 같고, 임시 파일 변수 없이 더 간결하게 쓴다는 차이가 있습니다.

예제 8-9에는 fs::read_to_string을 사용하여 더 짧게 만든 예시가 있습니다.

예제 8-9: 파일을 열고, 읽는 대신 fs::read_to_string을 사용하기
src/main.rs
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

파일에서 문자열을 읽는 코드는 굉장히 흔하게 사용되기 때문에, 표준 라이브러리에서는 파일을 열고 내용을 문자열로 읽어 io::Result<String>으로 반환하는 fs::read_to_string이라는 편리한 함수를 제공합니다. 반환 계약은 같지만 내부 연산과 실패 지점이 달라질 수 있으므로 세 구현이 모든 오류 정보를 바이트 단위로 똑같이 만든다고 가정하지는 않습니다.

다만 fs::read_to_string을 사용해 버리면 에러를 다루는 법을 자세히 설명할 수 없으니 긴 코드로 먼저 설명했습니다.

같은 기능을 여러 단계로 작성했다가 점점 줄여 나가면 ?와 표준 라이브러리 도우미 함수가 어떤 보일러플레이트를 없애는지 더 분명해집니다.


? 연산자가 사용될 수 있는 곳

??가 만든 실패 잔여값과 호환 가능한 반환 문맥을 가진 함수·클로저·async 블록 등에서 사용할 수 있습니다.

이 예제에서 ?의 핵심 효과는 예제 8-6의 match처럼 실패 시 현재 함수를 일찍 끝내고 값을 반환하는 것입니다.

예제 8-6에서 matchResult 값을 사용하고 있었고, 빠른 반환 갈래는 Err(e) 값을 반환했습니다.

이 예제에서는 함수의 반환 타입이 호환되는 Result여야 이 조기 반환과 맞습니다. 언어 차원에서는 FromResidual을 구현한 다른 타입도 ?를 지원할 수 있습니다.

만일 ?가 사용된 값의 타입과 호환되지 않는 반환 타입을 가진 main 함수에서 ? 연산자를 사용하면 어떤 에러를 얻게 되는지 예제 8-10에서 살펴보겠습니다.

예제 8-10: ()를 반환하는 main에서의 ? 사용 시도는 컴파일되지 않습니다
src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

이 코드는 파일을 열고, 이는 실패할 수도 있습니다.

? 연산자는 File::open에 의해 반환되는 Result 값을 따르지만, main 함수는 반환 타입이 Result가 아니라 ()입니다.

이 코드를 컴파일하면 다음과 같은 에러 메시지를 얻게 됩니다.

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error

이 에러는 ? 연산자가 Result, Option 혹은 FromResidual을 구현한 타입과 호환되는 반환 문맥에서만 사용될 수 있음을 지적하고 있습니다.

이 에러를 고치기 위해서는 두 가지 선택지가 있습니다.

첫 번째는 ? 연산자가 사용되는 곳의 값과 호환되게 함수의 반환 타입을 수정하는 것인데, 이러한 수정을 막는 제약 사항이 없는 한에서 가능합니다.

다른 방법은 Result<T, E>를 적절한 식으로 처리하기 위해 match 혹은 Result<T, E>의 메서드 중 하나를 사용하는 것입니다.

에러 메시지는 또한 ?Option<T> 값에 대해서도 사용될 수 있음을 알려주었습니다.

Result?를 사용할 때와 마찬가지로, 함수가 Option를 반환하는 경우에는 Option에서만 ?를 사용할 수 있습니다.

Option<T>에서 호출되었을 때의 ? 연산자 동작은 Result<T, E>에서 호출되었을 때의 동작과 비슷합니다.

None 값인 경우 그 함수의 해당 지점으로부터 None 값을 일찍 반환할 것입니다.

Some 값이라면 Some 안에 있는 값이 이 표현식의 결괏값이 되면서 함수가 계속됩니다.

예제 8-11은 주어진 텍스트에서 첫 번째 줄의 마지막 문자를 찾는 함수의 예제를 보여줍니다.

예제 8-11: Option<T> 값에 대한 ? 연산자의 사용
fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

이 함수는 문자가 있을 수도, 없을 수도 있기 때문에 Option<char>를 반환합니다.

이 코드는 text 문자열 슬라이스 인수를 가져와서 lines 메서드를 호출하는데, 이는 해당 문자열의 라인에 대한 반복자를 반환합니다.

첫 번째 줄의 검사가 필요하므로, 반복자의 next를 호출하여 첫 번째 값을 얻어옵니다.

만일 text가 빈 문자열이라면 next 호출은 None을 반환하는데, 여기서 ?를 사용하여 last_char_of_first_line의 실행을 멈추고 None을 반환합니다.

만약 text가 빈 문자열이 아니라면 nexttext의 첫 번째 줄의 문자열 슬라이스를 담고 있는 Some의 값을 반환합니다.

?이 문자열 슬라이스를 추출하고, 이 문자열 슬라이스의 chars를 호출하여 문자들에 대한 반복자를 얻어올 수 있습니다.

이 첫 번째 라인의 마지막 문자에 관심이 있으므로, last를 호출하여 이 반복자의 마지막 아이템을 얻어옵니다.

"\nhi"처럼 빈 줄로 시작하지만 다른 줄에는 문자가 담겨있는 경우처럼, 첫 번째 라인이 빈 문자열일 수 있으므로 반복자의 결과는 Option입니다.

만약 첫 번째 라인에 마지막 문자가 있다면 Some 배리언트를 반환할 것입니다.

가운데의 ? 연산자가 이러한 로직을 표현할 간단한 방식을 제공하여 이 함수를 한 줄로 작성할 수 있도록 해 줍니다.

만일 Option에 대하여 ? 연산자를 이용할 수 없었다면 더 많은 메서드 호출 혹은 match 표현식을 사용하여 이 로직을 구현했어야 할 것입니다.

Result를 반환하는 함수에서는 Result에서 ? 연산자를 사용할 수 있고, Option을 반환하는 함수에서는 Option에 대해 ? 연산자를 사용할 수 있지만, 이를 섞어서 사용할 수는 없음을 주목하세요.

? 연산자는 자동으로 ResultOption으로 변환하거나 혹은 그 반대를 할 수 없습니다; 그러한 경우에는 Resultok 메서드 혹은 Optionok_or 메서드 같은 것을 통해 명시적으로 변환을 할 수 있습니다.

여태껏 다뤄본 main 함수는 모두 ()를 반환했습니다.

main 함수는 실행 프로그램의 시작점이자 종료점이기 때문에 특별하며, 프로그램이 기대한 대로 동작하려면 반환 타입의 종류에 대한 제약사항이 있습니다.

다행히도 E: Debug이면 mainResult<(), E>도 반환할 수 있습니다.

예제 8-12는 예제 8-10의 코드에서 main의 반환 타입을 Result<(), Box<dyn Error>>로 변경하고 함수 마지막에 반환 값 Ok(())를 추가한 것입니다.

이 코드는 이제 컴파일될 것입니다.

예제 8-12: mainResult<(), E>를 반환하도록 하여 Result 값에 대한 ? 사용 가능하게 하기
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Box<dyn Error> 타입은 트레이트 객체(trait object) 인데, 16장의 ‘트레이트 객체를 사용하여 다른 타입의 값 허용하기’절에서 다룰 예정입니다.

지금은 Box<dyn Error>가 여러 구체 오류 타입을 하나의 트레이트 객체로 담을 수 있는 반환 타입이라고 읽으면 됩니다.

다만 아무 Err나 자동으로 변환되는 것은 아닙니다. 해당 오류 타입에서 Box<dyn Error>로의 From 변환이 제공될 때 ?가 그 오류를 박싱해 조기 반환할 수 있습니다. 이 예제의 std::io::Error에는 필요한 변환이 있으므로 컴파일됩니다.

E: DebugResult<(), E>main이 반환하면, 실행 파일은 mainOk(())를 반환할 경우 0 값으로 종료되고, mainErr 값을 반환할 경우 0이 아닌 값으로 종료됩니다.

C로 작성된 실행파일은 종료될 때 정숫값을 반환합니다.

성공적으로 종료된 프로그램은 정수 0을 반환하고, 에러가 발생한 프로그램은 0이 아닌 어떤 정숫값을 반환합니다.

러스트 또한 이러한 규칙과 호환될 목적으로 실행파일이 정숫값을 반환합니다.

main 함수가 std::process::Termination 트레이트를 구현한 타입을 반환할 수도 있는데, 이는 ExitCode를 반환하는 report라는 함수를 가지고 있습니다.

여러분이 만든 타입에 대해 Termination 트레이트를 구현하려면 표준 라이브러리 문서에서 더 많은 정보를 찾아보세요.

panic! 호출하기와 Result 반환하기에 대한 세부 내용을 논의했으니, 어떤 경우에 어떤 방법을 사용하는 것이 적절한지 결정하는 방법에 대한 주제로 돌아갑시다.