회복 가능한 에러: `Result`

회복 가능한 에러: Result

대부분의 에러는 프로그램을 통째로 멈추게 할 만큼 심각하진 않아요. 함수가 실패할 때 그 이유를 쉽게 해석하고 대응할 수 있는 경우도 있죠. 예를 들어 파일을 열려고 했는데 그 파일이 존재하지 않아서 실패했다면, 프로세스를 종료하는 대신 파일을 새로 만들고 싶을 거예요.

출처: Rust 공식문서

본문

Result로 회복 가능한 에러 다루기

2장의 “Handling Potential Failure with Result”에서 본 것처럼, Result 열거형은 OkErr 두 가지 변형(variant)으로 정의돼 있어요.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

여기서 TE는 제네릭 타입 매개변수예요. 제네릭에 대한 자세한 내용은 10장에서 다룰 텐데요, 지금 알아둘 것은 딱 두 가지예요. T는 성공했을 때 Ok 변형 안에 담겨 반환될 값의 타입이고, E는 실패했을 때 Err 변형 안에 담겨 반환될 에러의 타입이라는 것. Result가 이런 제네릭 타입 매개변수를 갖는 덕분에, 성공 값과 에러 값의 타입이 서로 다른 수많은 상황에서 Result 타입과 그에 정의된 함수를 그대로 쓸 수 있어요.

실패할 수 있는 함수를 하나 호출해 볼게요. 성공 값으로 Result를 반환하는 File::open을 써서 파일을 여는 리스팅 9-3을 봐요.

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 인스턴스가 들어와요. 실패하면 어떤 종류의 에러가 발생했는지에 대한 더 자세한 정보를 담은 Err 인스턴스가 들어오고요.

이제 리스팅 9-3의 코드에 더해, File::open이 반환한 값에 따라 서로 다른 동작을 하게 만들 거예요. 리스팅 9-4는 6장에서 다룬 기본 도구인 match 표현식으로 Result를 처리하는 한 가지 방법을 보여줘요.

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 열거형과 그 변형들도 프렐루드(prelude)에 포함돼 있어서, match 분기에서 OkErr 변형 앞에 Result::를 붙일 필요가 없다는 점을 기억해 두세요.

결과가 Ok면 이 코드는 Ok 변형 안의 파일 값을 꺼내서 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` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

보통 그렇듯 이 출력은 정확히 무엇이 잘못됐는지 알려줘요.

에러 종류별로 다르게 대응하기

리스팅 9-4의 코드는 File::open이 왜 실패했든 무조건 panic!을 불러요. 그런데 실패 이유에 따라 서로 다른 동작을 하고 싶다면요? 파일이 존재하지 않아서 실패한 경우에는 파일을 새로 만들고 그 새 파일의 핸들을 반환하고 싶고, 권한이 없어서 실패한 것처럼 다른 이유라면 리스팅 9-4처럼 여전히 panic!을 부르고 싶어요. 이를 위해 안쪽에 match 표현식을 하나 더 넣는데, 그게 리스팅 9-5예요.

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:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

File::openErr 변형 안에서 돌려주는 값의 타입은 io::Error인데, 이건 표준 라이브러리가 제공하는 구조체예요. 이 구조체에는 kind라는 메서드가 있어서, 호출하면 io::ErrorKind 값을 얻을 수 있죠. io::ErrorKind 열거형도 표준 라이브러리가 제공하며, io 연산에서 생길 수 있는 여러 종류의 에러를 나타내는 변형들을 담고 있어요. 우리가 쓰려는 변형은 열려는 파일이 아직 존재하지 않는다는 뜻의 ErrorKind::NotFound예요. 그래서 greeting_file_resultmatch 하되, 안쪽에서는 error.kind()를 한 번 더 match 하는 거예요.

안쪽 match에서 확인하려는 조건은 error.kind()가 반환한 값이 ErrorKind 열거형의 NotFound 변형인지 여부예요. 그렇다면 File::create로 파일을 만들어 봐요. 그런데 File::create도 실패할 수 있으니 안쪽 match에 두 번째 분기가 필요해요. 파일을 만들 수 없으면 다른 에러 메시지를 출력해요. 바깥쪽 match의 두 번째 분기는 그대로라서, 파일이 없다는 에러를 제외한 어떤 에러든 프로그램이 panic!을 부르게 돼요.

match 대신 Result<T, E> 처리하기

match가 정말 많죠? match 표현식은 아주 유용하지만 기본적인 도구에 가까워요. 13장에서는 Result<T, E>에 정의된 여러 메서드에서 쓰이는 클로저(closure)를 배울 텐데, 이런 메서드들은 코드에서 Result<T, E> 값을 다룰 때 match보다 더 간결하게 쓸 수 있어요.

예를 들어 리스팅 9-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:?}");
        }
    });
}

이 코드는 리스팅 9-5와 동일하게 동작하지만 match 표현식이 하나도 없어서 더 읽기 깔끔해요. 13장을 읽고 나서 이 예시로 돌아와 표준 라이브러리 문서에서 unwrap_or_else 메서드를 찾아봐요. 에러를 다룰 때 이런 메서드들이 얼마나 크고 중첩된 match 표현식을 정리해 주는지 알게 될 거예요.

에러 발생 시 panic!을 부르는 단축키: unwrapexpect

match는 충분히 잘 동작하지만 조금 장황하고 의도를 잘 드러내지 못할 때도 있어요. Result<T, E> 타입에는 더 구체적인 작업을 위한 헬퍼 메서드들이 여럿 정의돼 있어요. unwrap 메서드는 리스팅 9-4에서 작성한 match 표현식과 똑같이 구현된 단축 메서드예요. Result 값이 Ok 변형이면 Ok 안의 값을 돌려주고, Err 변형이면 알아서 panic! 매크로를 불러줘요. unwrap이 동작하는 예시를 볼게요.

use std::fs::File;

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

hello.txt 파일 없이 이 코드를 실행하면, unwrap 메서드가 부르는 panic! 호출의 에러 메시지가 나와요.

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

비슷하게 expect 메서드는 panic! 에러 메시지를 직접 고를 수 있게 해줘요. unwrap 대신 expect를 쓰면서 좋은 에러 메시지를 제공하면 의도를 드러내고 panic의 출처를 추적하기 쉬워져요. expect의 문법은 이렇게 생겼어요.

use std::fs::File;

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

expectunwrap과 똑같이 파일 핸들을 돌려주거나 panic! 매크로를 부르는 데 써요. 다른 점은 expectpanic!을 부를 때 쓰는 에러 메시지가 unwrap이 쓰는 기본 메시지가 아니라 우리가 expect에 넘긴 매개변수라는 거예요. 실제로 이렇게 나타나요.

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

실무 품질의 코드에서는 대부분의 러스트 프로그래머(Rustacean)가 unwrap보다 expect를 선택하고, 그 연산이 왜 항상 성공할 거라 기대하는지에 대한 맥락을 더 많이 붙여요. 그렇게 하면, 혹시 여러분의 가정이 틀렸다는 게 드러났을 때 디버깅에 쓸 정보가 더 많아지니까요.

에러 전파하기

함수 구현이 실패할 수 있는 무언가를 호출할 때, 에러를 함수 안에서 처리하는 대신 호출한 코드로 돌려줄 수도 있어요. 호출한 쪽이 어떻게 처리할지 결정하게 하는 거죠. 이걸 에러 전파(error propagation)라고 하는데, 에러를 어떻게 다뤄야 할지에 대한 정보나 로직이 여러분의 코드 맥락보다 더 많을 수 있는 호출 쪽에 더 많은 제어권을 주는 방식이에요.

예를 들어 리스팅 9-6은 파일에서 사용자 이름을 읽는 함수를 보여줘요. 파일이 존재하지 않거나 읽을 수 없으면 이 함수는 그 에러들을 함수를 호출한 코드로 돌려줘요.

#![allow(unused)]
fn main() {
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는 구체 타입 String으로, 제네릭 타입 E는 구체 타입 io::Error로 채워졌어요.

이 함수가 아무 문제 없이 성공하면, 함수를 호출한 코드는 파일에서 읽은 사용자 이름인 String을 담은 Ok 값을 받아요. 문제가 생기면 호출한 코드는 문제가 무엇인지에 대한 자세한 정보를 담은 io::Error 인스턴스를 담은 Err 값을 받아요. 반환 타입으로 io::Error를 고른 이유는, 이 함수 본문에서 호출하는 실패 가능한 두 연산, 즉 File::open 함수와 read_to_string 메서드 둘 다에서 반환되는 에러 값의 타입이 마침 그렇기 때문이에요.

함수 본문은 File::open 함수를 호출하면서 시작해요. 그다음 리스팅 9-4의 match와 비슷한 matchResult 값을 처리해요. File::open이 성공하면 패턴 변수 file의 파일 핸들이 가변 변수 username_file의 값이 되고 함수는 계속 진행돼요. Err 경우에는 panic!을 부르는 대신 return 키워드로 함수 전체에서 일찍 반환하고, 이제 패턴 변수 e에 담긴 File::open의 에러 값을 이 함수의 에러 값으로 호출한 코드에 돌려줘요.

username_file에 파일 핸들이 있다면, 함수는 변수 username에 새 String을 만들고 username_file의 파일 핸들에서 read_to_string 메서드를 호출해 파일 내용을 username으로 읽어요. read_to_string 메서드도 Result를 반환하는데, File::open이 성공했더라도 실패할 수 있기 때문이에요. 그래서 그 Result를 처리할 match가 또 필요해요. read_to_string이 성공하면 함수가 성공한 것이고, 이제 username에 담긴 파일의 사용자 이름을 Ok로 감싸 돌려줘요. 실패하면 File::open의 반환 값을 처리한 match에서 에러 값을 돌려준 것과 같은 방식으로 에러 값을 돌려줘요. 다만 여기서는 return을 명시할 필요가 없어요. 함수의 마지막 표현식이니까요.

이 함수를 호출한 코드는 사용자 이름을 담은 Ok 값이나 io::Error를 담은 Err 값을 받아서 처리하게 되죠. 그 값들을 어떻게 처리할지는 호출한 쪽이 결정할 일이에요. Err 값을 받은 호출 코드는 panic!을 불러 프로그램을 멈추게 하거나, 기본 사용자 이름을 쓰거나, 파일이 아닌 다른 곳에서 사용자 이름을 찾아볼 수도 있어요. 우리는 호출 코드가 실제로 뭘 하려는지에 대한 정보가 충분하지 않으니, 성공이든 에러든 모든 정보를 위로 전파해서 호출 쪽이 적절히 처리하게 해 주는 거예요.

이 에러 전파 패턴은 Rust에서 너무 흔해서, Rust는 이걸 더 쉽게 만들어 주는 물음표 연산자 ?를 제공해요.

? 연산자 단축키

리스팅 9-7은 리스팅 9-6과 같은 기능을 하지만 ? 연산자를 사용하는 read_username_from_file 구현을 보여줘요.

#![allow(unused)]
fn main() {
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 값 뒤에 붙는 ?는 리스팅 9-6에서 Result 값을 처리하기 위해 정의한 match 표현식과 거의 같은 방식으로 동작하도록 정의돼 있어요. Result 값이 OkOk 안의 값이 이 표현식의 결과로 반환되고 프로그램은 계속돼요. Errreturn 키워드를 쓴 것처럼 그 Err가 함수 전체에서 반환되고, 에러 값이 호출한 코드로 전파돼요.

리스팅 9-6의 match 표현식과 ? 연산자가 하는 일에는 차이가 하나 있어요. ? 연산자가 적용된 에러 값은 표준 라이브러리의 From 트레잇에 정의된 from 함수를 거치는데, 이 함수는 값을 한 타입에서 다른 타입으로 변환하는 데 쓰여요. ? 연산자가 from 함수를 호출하면 받은 에러 타입이 현재 함수의 반환 타입에 정의된 에러 타입으로 변환돼요. 이건 함수가 실패할 수 있는 여러 부분이 각각 다른 이유로 실패하더라도, 모든 실패 방식을 하나의 에러 타입으로 나타내려 할 때 유용해요.

예를 들어 리스팅 9-7의 read_username_from_file 함수가 우리가 정의한 커스텀 에러 타입 OurError를 반환하도록 바꿀 수 있을 거예요. OurError에 대해 impl From<io::Error> for OurError도 정의해서 io::Error에서 OurError 인스턴스를 만들도록 하면, read_username_from_file 본문의 ? 연산자 호출들이 함수에 코드를 더 추가하지 않아도 from을 호출해 에러 타입을 변환해 줘요.

리스팅 9-7의 맥락에서 File::open 호출 끝의 ?Ok 안의 값을 변수 username_file에 돌려줘요. 에러가 발생하면 ? 연산자가 함수 전체에서 일찍 반환하고 Err 값을 호출한 코드에 넘겨줘요. read_to_string 호출 끝의 ?에도 같은 게 적용돼요.

? 연산자는 보일러플레이트를 상당히 없애고 함수 구현을 더 단순하게 만들어 줘요. ? 바로 뒤에 메서드 호출을 이어서 체이닝하면 코드를 더 줄일 수도 있는데, 그게 리스팅 9-8이에요.

#![allow(unused)]
fn main() {
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)
}
}

username에 새 String을 만드는 작업을 함수 시작으로 옮겼어요. 그 부분은 바뀌지 않았죠. username_file 변수를 만들지 않고 File::open("hello.txt")?의 결과에 read_to_string 호출을 바로 이어 붙였어요. read_to_string 호출 끝에는 여전히 ?가 있고, File::openread_to_string이 둘 다 성공하면 에러를 반환하는 대신 username을 담은 Ok 값을 여전히 반환해요. 기능은 리스팅 9-6과 리스팅 9-7과 같아요. 이건 그걸 더 인체공학적으로 쓰는 다른 방법일 뿐이에요.

리스팅 9-9는 fs::read_to_string을 사용해서 이걸 더 짧게 만드는 방법을 보여줘요.

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

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

파일을 문자열로 읽는 건 꽤 흔한 연산이라서, 표준 라이브러리가 편리한 fs::read_to_string 함수를 제공해요. 이 함수는 파일을 열고 새 String을 만들고 파일 내용을 읽어 그 String에 담고 반환해 준답니다. 물론 fs::read_to_string을 쓰면 에러 처리를 다 설명할 기회가 없으니, 우리는 먼저 길게 설명한 거예요.

? 연산자를 쓸 수 있는 곳

? 연산자는 반환 타입이 ?가 적용된 값과 호환되는 함수에서만 쓸 수 있어요. ? 연산자가 리스팅 9-6에서 정의한 match 표현식과 같은 방식으로 함수 밖으로 값을 조기 반환하도록 정의돼 있기 때문이에요. 리스팅 9-6에서 matchResult 값을 사용했고, 조기 반환 분기는 Err(e) 값을 반환했죠. 그렇다면 함수의 반환 타입은 이 반환과 호환되도록 Result여야 해요.

리스팅 9-10에서, ?를 적용하는 값의 타입과 호환되지 않는 반환 타입을 가진 main 함수에서 ? 연산자를 쓰면 어떤 에러가 나는지 볼게요.

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: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

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

이 에러는 ? 연산자를 Result, Option, 혹은 FromResidual을 구현하는 다른 타입을 반환하는 함수에서만 쓸 수 있다고 알려줘요.

에러를 고치는 방법은 두 가지예요. 하나는, 막는 제약이 없다면 함수의 반환 타입을 ? 연산자를 적용하는 값과 호환되게 바꾸는 거예요. 다른 하나는 matchResult<T, E> 메서드 중 하나를 사용해 Result<T, E>를 상황에 맞게 처리하는 거예요.

에러 메시지는 ?Option<T> 값에도 쓸 수 있다고도 언급했어요. Result?를 쓰는 것처럼, Option을 반환하는 함수에서만 Option?를 쓸 수 있어요. Option<T>에서 ? 연산자의 동작은 Result<T, E>에서의 동작과 비슷해요. 값이 None이면 그 지점에서 None이 함수에서 조기 반환돼요. 값이 Some이면 Some 안의 값이 표현식의 결과가 되고 함수는 계속돼요. 리스팅 9-11은 주어진 텍스트에서 첫 줄의 마지막 문자를 찾는 함수의 예시예요.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

이 함수는 Option<char>를 반환하는데, 그 위치에 문자가 있을 수도 없을 수도 있기 때문이에요. 이 코드는 text 문자열 슬라이스 인자를 받아 그에 lines 메서드를 호출하는데, 이 메서드는 문자열의 줄들에 대한 반복자(iterator)를 반환해요. 이 함수는 첫 줄을 살펴보고 싶으니 반복자에서 첫 번째 값을 얻기 위해 next를 호출해요. text가 빈 문자열이면 이 next 호출은 None을 반환하고, 그 경우 ?를 써서 멈추고 last_char_of_first_line에서 None을 반환해요. text가 빈 문자열이 아니면 nexttext의 첫 줄의 문자열 슬라이스를 담은 Some 값을 반환해요.

?가 그 문자열 슬라이스를 추출하고, 그 문자열 슬라이스에 chars를 호출해 문자의 반복자를 얻어요. 우리가 관심 있는 건 이 첫 줄의 마지막 문자이므로 last를 호출해 반복자의 마지막 항목을 반환해요. 이건 Option인데, 첫 줄이 빈 문자열일 수 있기 때문이에요. 예를 들어 text가 빈 줄로 시작하면서 다른 줄에는 문자가 있는 "\nhi" 같은 경우죠. 하지만 첫 줄에 마지막 문자가 있으면 그 문자가 Some 변형에 담겨 반환돼요. 중간에 있는 ? 연산자가 이 로직을 간결하게 표현할 수 있게 해 줘서 함수를 한 줄로 구현할 수 있어요. Option? 연산자를 쓸 수 없다면 이 로직을 더 많은 메서드 호출이나 match 표현식으로 구현해야 했을 거예요.

Result를 반환하는 함수에서는 Result에, Option을 반환하는 함수에서는 Option? 연산자를 쓸 수 있지만 섞어 쓸 수는 없다는 점을 기억하세요. ? 연산자는 ResultOption으로, 혹은 그 반대로 자동 변환하지 않아요. 그런 경우에는 Resultok 메서드나 Optionok_or 메서드 같은 것을 써서 명시적으로 변환할 수 있어요.

지금까지 쓴 main 함수들은 모두 ()를 반환했어요. main 함수는 실행 가능한 프로그램의 진입점이자 종료 지점이라 특별한데, 프로그램이 기대한 대로 동작하려면 반환 타입에 제약이 있어요.

다행히 mainResult<(), E>도 반환할 수 있어요. 리스팅 9-12는 리스팅 9-10의 코드를 가져와 main의 반환 타입을 Result<(), Box<dyn Error>>로 바꾸고 마지막에 Ok(()) 반환 값을 추가한 거예요. 이제 이 코드는 컴파일돼요.

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)인데, 18장의 “Using Trait Objects to Abstract over Shared Behavior”에서 다룰 거예요. 지금은 Box<dyn Error>를 “어떤 종류의 에러든”이라고 읽으면 돼요. 에러 타입이 Box<dyn Error>main 함수에서 Result 값에 ?를 쓰는 게 허용되는 이유는, 어떤 Err 값이든 조기 반환할 수 있게 해 주기 때문이에요. 이 main 함수의 본문이 지금은 std::io::Error 타입의 에러만 반환하더라도, Box<dyn Error>로 지정하면 나중에 main 본문에 다른 에러를 반환하는 코드를 더 추가해도 이 시그니처가 계속 맞아요.

main 함수가 Result<(), E>를 반환하면, mainOk(())를 반환할 때 실행 파일은 0 값으로 종료하고 Err 값을 반환하면 0이 아닌 값으로 종료해요. C로 작성된 실행 파일도 종료할 때 정수를 반환하죠. 성공적으로 종료한 프로그램은 정수 0을 반환하고, 에러가 난 프로그램은 0이 아닌 다른 정수를 반환해요. Rust도 이 관례와 호환되도록 실행 파일에서 정수를 반환해요.

main 함수는 std::process::Termination 트레잇을 구현하는 어떤 타입이든 반환할 수 있는데, 이 트레잇에는 ExitCode를 반환하는 report 함수가 들어 있어요. 여러분이 직접 정의한 타입에 Termination 트레잇을 구현하는 방법에 대한 자세한 내용은 표준 라이브러리 문서를 참고하세요.

이제 panic!을 부를지 Result를 반환할지에 대한 세부 내용을 다뤘으니, 어떤 경우에 어떤 쪽을 쓰는 게 적절한지 결정하는 방법으로 다시 돌아가 볼게요.

더 알아보기