Programming/Rust

쪼잔한 Rust 9. 오류 다루기

동건 2018. 11. 17. 20:35

Chapter 9. 오류 다루기


Rust가 안전한 언어가 되기 위한 노력은 오류 다루는 데까지 닿아있다. 오류도 소프트웨어의 일부라고 할 수 있는 만큼, Rust는 뭔가 잘못된 상황을 다루는 많은 기능을 탑재하고 있다. 많은 경우에 Rust는 오류가 날 수 있는 가능성을 사용자가 알고 있기를 요구하고, 오류 상황에서의 대처법이 마련되어있어야 컴파일이 가능하다. 이러한 깐깐함은 당신의 코드가 프로덕션에 배포된 후에 오류가 발생하는 것을 보기 전에 오류들을 발견하고 처리하도록 강제하기 때문에 당신의 프로그램을 더욱 강건하게 해주는 것이다!

Rust는 오류를 크게 두 가지로 분류하는데, 회복 가능한(recoverable) 오류와 회복 불가능한(unrecoverable) 오류가 그 두 가지이다. 파일을 못 찾는오류와 같은 회복 가능한 오류의 경우에는 그 오류를 사용자에게 알리고 재시도를 하는 것이 바람직할 것이다. 회복 불가능한 오류는 배열의 크기를 벗어나는 위치에 접근하는 것과 같이 분명한 버그의 증상이다.

대부분의 언어들은 이 두 가지 오류를 구분하지 않고 exception과 같은 개념 아래 같은 방법으로 처리한다. Rust에 exception 개념같은 것은 없다. 그 대신, Rust는 회복 가능한 오류 타입인 Result<T, E>를 가지고 있고 회복 불가능한 오류가 발생했을 때 프로그램을 중단하는 매크로인 panic!가 있다. 이번 챕터에서는 panic!에 대해 먼저 다루고, 그 다음에 Result<T, E> 값을 반환하는 것에 대해 이야기하겠다. 그리고나서 이 두 가지 방법 중에 어느 것을 택해야하는 지에 대해서도 생각해보겠다.




panic!으로 회복 불가능한 오류 다루기

간혹 당신의 코드에서 안 좋은 일들이 일어나고, 이에 대해서 우리가 마땅히 할 것이 없는 경우가 생긴다. 이럴 경우를 위해서 Rust는 panic! 매크로를 지원한다. panic! 매크로가 실행되면 프로그램이 실패 메세지를 출력해주고, stack에 쌓인 것들을 풀어준 뒤 (unwind the stack), 프로그램을 끝낸다. 어떤 버그가 발견되었지만 프로그래머가 그 오류를 어떻게 다뤄야하는지 확실하지 않을 때 종종 이런 상황이 발생한다.


Panic의 응답으로: Stack을 되감거나, Abort하거나

Panic이 일어나는 경우의 기본값으로, 프로그램은 되감기(unwinding)을 시작한다. 이는 Rust가 stack을 되감으면서 사용했던 함수들이 가지고 있는 데이터를 청소한다는 뜻이다. 다른 방법으로 즉시 abort할 수도 있는데, 이는 되감기 없이 프로그램을 끝내버리는 것이다. 이 때 프로그램이 사용했던 메모리는 운영 체제에 의해서 청소되어야 한다. 만약 당신이 최소한의 binary 프로젝트를 원한다면, 아래와 같이 Cargo.toml 파일의 [profile] 부분에서 panic = 'abort' 설정을 추가해주면 된다. 예를 들어 release 모드일 때만 abort하기 원한다면 아래와 같이 설정하면 된다.

[profile.release]
panic = 'abort'


그저 panic!을 사용하는 간단한 프로그램을 짜보자.

// Filename: src/main.rs
fn main() {
    panic!("crash and burn");
}

이 프로그램을 실행하면 아래와 같은 결과를 보게 될 것이다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25 secs
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.

panic!이 보여주는 오류 메세지는 어디서 오류가 일어났는지 등의 정보를 담고 있다.

오류 메세지가 가리키는 위치를 따라가보면, 우리가 panic! 매크로를 사용한 위치가 나올 것이다. 우리가 직접 사용한 panic! 매크로의 위치가 아닌 다른 위치를 알려준다면, panic!을 호출한 함수의 backtrace를 사용해서 오류 발생의 원인을 추적할 수 있다.


panic! Backtrace 사용하기

panic!이 우리 코드가 아닌 다른 라이브러리에서 호출될 때를 보겠다. 예제 9-1는 vector를 index로 접근하는 코드이다.

예제 9-1: vector의 사이즈 너머에 있는 원소에 접근할 때의 panic! 발생

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

크기가 3인 vector에게서 100번째 원소를 접근하고자 하는 위 예제는 panic하게 된다. []를 사용해서 원소를 반환받지만, 부적절한 index를 줄 경우에는 Rust가 올바른 원소를 줄 수가 없으므로 panic하는 것이다.

C 언어 등의 다른 언어에서는 같은 상황에서 당신이 요구한 것을 정확하게 수행한다. 비록 그것이 당신이 원하는 것이 아닐지라도 말이다. 요구하는 메모리의 위치가 vector의 원소 위치가 아니더라도 그 값을 주는 것이다. 이러한 상황을 _buffer overread_라고 부르는데, 이는 공격자가 데이터를 읽을 때의 index를 조작할 수 있을 경우에 보안 취약점이 될 수 있다.

이러한 취약점에서 프로그램을 보호하기 위해서 Rust는 더 이상의 실행을 멈추고 계속하기를 거부한다. 코드를 실행해서 확인해보자.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is
99', /checkout/src/liballoc/vec.rs:1555:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

오류는 우리가 작성하지 않은 파일인 _vec.rs_를 지적한다. 이 파일은 Vec<T>를 구현한 표준 라이브러리 파일이다. 우리가 사용한 vector v[]를 사용할 때에 vec.rs 에 있는 코드가 실행되며, 이 곳이 실제 panic!이 일어나는 곳이다.

출력된 마지막 줄에서는 RUST_BACKTRACE 환경 변수를 사용하면 어떻게 해서 오류가 일어났는지 볼 수 있는 backtrace를 얻을 수 있다고 알려준다. Backtrace는 해당 지점에 이르기까지 호출된 모든 함수를 거치는 리스트이다. Rust의 backtrace는 다른 언어에서의 backtrace와 같은 방식으로 작동한다. 처음부터 내려가면서 당신의 코드가 나오는 부분을 찾는 것이 backtrace를 읽는 키 포인트이고, 그 부분이 문제가 발생한 원인이 될 것이다. 그 부분 위로는 당신의 코드가 호출하게 된 자취이다. 그래서 Rust core 코드가 나올 수도, 표준 라이브러리 콛가 나올 수도, 당신이 사용하는 crate 코드가 나올 수도 있다. RUST_BACKTRACE 환경 변수의 값을 0이 아닌 아무 값이나 주어서 실제로 backtrace를 한 번 보자.

예제 9-2: 환경 변수 RUST_BACKTRACE를 설정하고 panic!을 호출할 때 발생하는 backtrace

$ RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', /checkout/src/liballoc/vec.rs:1555:10
stack backtrace:
   0: std::sys::imp::backtrace::tracing::imp::unwind_backtrace
             at /checkout/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:49
   1: std::sys_common::backtrace::_print
             at /checkout/src/libstd/sys_common/backtrace.rs:71
   2: std::panicking::default_hook::{{closure}}
             at /checkout/src/libstd/sys_common/backtrace.rs:60
             at /checkout/src/libstd/panicking.rs:381
   3: std::panicking::default_hook
             at /checkout/src/libstd/panicking.rs:397
   4: std::panicking::rust_panic_with_hook
             at /checkout/src/libstd/panicking.rs:611
   5: std::panicking::begin_panic
             at /checkout/src/libstd/panicking.rs:572
   6: std::panicking::begin_panic_fmt
             at /checkout/src/libstd/panicking.rs:522
   7: rust_begin_unwind
             at /checkout/src/libstd/panicking.rs:498
   8: core::panicking::panic_fmt
             at /checkout/src/libcore/panicking.rs:71
   9: core::panicking::panic_bounds_check
             at /checkout/src/libcore/panicking.rs:58
  10: <alloc::vec::Vec<T> as core::ops::index::Index<usize>>::index
             at /checkout/src/liballoc/vec.rs:1555
  11: panic::main
             at src/main.rs:4
  12: __rust_maybe_catch_panic
             at /checkout/src/libpanic_unwind/lib.rs:99
  13: std::rt::lang_start
             at /checkout/src/libstd/panicking.rs:459
             at /checkout/src/libstd/panic.rs:361
             at /checkout/src/libstd/rt.rs:61
  14: main
  15: __libc_start_main
  16: <unknown>

출력 내용이 상당히 많다! 아마도 당신이 사용하는 운영 체제의 종류와 Rust의 버전에 따라서 출력 내용이 조금씩 다를 수 있다. 이 backtrace을 얻기 위해서는 debug symbol이 활성화되어 있어야 하는데, cargo build를 사용하거나 --release 옵션 없이 cargo run을 할 때에 기본값으로 활성화 된다.

예제 9-2의 출력물에서, 11번째 줄이 우리 프로젝트에서 문제를 일으키는 부분을 가리키고 있다. 바로 src/main.rs의 4번째 줄인 것이다. 예제 9-1에서 우리는 backtrace 사용법을 소개하기 위해 고의로 panic하는 코드를 짰기 때문에, 이를 고치려면 99번째 index로 접근하지 않도록 하면 되지만, 실제로 panic을 마주치는 상황에서 당신은 어떤 코드가 panic을 일으키는지 찾아서 어떤 조취를 해야하는지 알아내야 할 것이다.




Result로 회복 가능한 오류 다루기

사실 대부분의 오류들은 프로그램 전체가 멈춰야만 하는 그런 심각한 오류일 경우가 없다. 어떤 함수가 실패할 때면, 그 실패의 원인을 쉽게 해석하고 대응할 수 있는 경우가 많다. 예를 들어 어느 파일을 열고자 하는데, 실제로 그 파일이 존재하지 않아서 작동이 실패한다면, 이로 인해 프로세스가 종료되기 보다는 그 파일을 새로 만드는 것이 바람직하게 생각될 수 있는 것이다.

챕터 2에서 Result enum이 가질 수 있는 두 결과값이 OkErr였음을 알아봤었다.

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

TE는 generic type parameter로, 챕터 10에서 generic에 대해 자세하게 알아볼 것이므로 설명은 생략한다. 일단 지금은 TOk 일 경우에 반환해야 하는 타입을, EErr 상황에서 반환해야 할 타입을 나타낸다고만 알고 있으면 된다. Result가 이렇게 generic type parameter를 가지기 때문에 이를 기반으로 표준 라이브러리에 선언되어 있는 함수들을 사용해서 성공/실패에 따라 다른 타입을 보낼 수 있는 상황을 다룰 수 있다.

실패할 수도 있는 작업을 하는 함수는 Result 값을 반환하게 해야 한다. 예제 9-3에서 우리는 어느 파일을 열어보려고 한다.

예제 9-3: 파일 열기

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

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

우리가 어떻게 File::open 함수가 Result를 반환한다는 것을 알 수 있을까? 아마도 표준 라이브러리 API 문서를 찾아볼 수도 있겠으나, 컴파일러에게 물어볼 수도 있다! 위의 함수 f의 타입을 얼토당토 않는 것으로 지정해준다면, 컴파일러는 그 타입이 맞지 않는다고, 그리고 원래 타입이 무엇인지 알려줄 것이다. 해보자! 우리는 File::open의 반환 타입이 u32는 아닐 것이라 알고 있기 때문에 이렇게 f의 타입을 지정해주겠다.

let f: u32 = File::open("hello.txt");

이제 컴파일을 시도해보면 다음과 같은 결과를 볼 것이다.

error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
  |
  = note: expected type `u32`
             found type `std::result::Result<std::fs::File, std::io::Error>`

File::open의 반환 타입이 Result<T, E>라고 알려준다. Generic parameter T는 성공값의 타입으로 file handle인 std::fs::File로 채워져 있을 것이고, E의 타입은 std::io::Error이다.

다시 설명하자면, File::open의 작업이 성공해서 file handle을 주면 이를 통해 우리는 파일을 읽고 쓸 수도 있을 것이다. 하지만 파일이 존재하지 않거나, 접근할 권한이 없어서 함수가 실패할 수도 있다. File::open 함수가 성공했는지 실패했는지를 우리에게 알려주는 통로가 있어야 하면서도 그 결과값으로 file handle이나 오류 정보도 줄 수 있어야 한다. Result enum은 정확히 이 정보를 전달해주는 통로가 된다.

File::open이 성공한다면 변수 f가 가지게 될 값은 file handle을 가지고 있는 Ok instance가 될 것이다. 실패한다면 그 값은 어떤 오류가 있었는지에 대한 정보를 담은 Err instance가 될 것이다.

이제 예제 9-3의 코드에서 File::open이 어떤 값이 되느냐에 따라 다른 조취를 취하는 코드를 추가해야 한다. 예제 9-4는 챕터 6에서 소개했던 Rust의 기본 도구인 match expression을 사용해서 Result를 다룬다.

예제 9-4: match expression을 사용해서 Result 반환값을 다루기

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

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

    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

파일을 연 결과가 Ok라면 그 안에 담겨져 있는 file handle을 받아서 f에 할당한다. match 다음에는, file handle을 이용해서 파일을 읽고 쓸 수 있다.

match의 다른 arm에서는 File::open이 실패했을 때의 Err 값을 받는다. 이 예제에서 우리는 panic! 매크로를 사용하기로 했다. hello.txt라는 파일이 없다면 이 코드는 아래와 같은 panic! 매크로의 메세지를 보게될 것이다.

thread 'main' panicked at 'There was a problem opening the file: Error { repr:
Os { code: 2, message: "No such file or directory" } }', src/main.rs:9:12

항상 그랬듯이, 이 출력물은 무엇이 어떻게 잘못됐는지 정확하게 알려준다.


여러 오류들에 Match하기

예제 9-4의 코드는 File::open이 어떻게 실패하던지 간에 panic!을 일으키고 있다. 그 대신 우리는 여러 다른 실패 원인에 따라 적절한 대처를 하고 싶다. 만약 파일이 없어서 File::open가 실패한다면, 우리는 파일을 생성해서 그 파일의 handle을 반환할 수도 있을 것이다. 파일에 접근할 권한이 없는 등의 다른 이유로 File::open이 실패한다면, 우리는 여전히 panic!을 일으킬 수도 있다. 예제 9-5에서 또 하나의 arm을 추가해보겠다.

예제 9-5: 여러 오류를 여러 방법으로 다루기

// Filename: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

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

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Tried to create file but there was a problem: {:?}", e),
            },
            other_error => panic!("There was a problem opening the file: {:?}", other_error),
        },
    };
}

File::open가 보내주는 Err 종류는 표준 라이브러리가 제공하는 struct인 io::Error이다. 이 struct는 io::ErrorKind 값을 받을 수 있는 method를 가지고 있다. io::ErrorKind enum은 표준 라이브러리가 제공하는데, io 작업을 하는 상황에서의 여러 오류를 나타내는 타입들을 제공한다. 여기서 우리가 사용하고자 하는 타입은 ErrorKind::NotFound로, 열고자 하는 파일이 없음을 의미한다. fErr에 match 했을 때, error.kind()에 대해서 다시 한 번 match하는 것도 알아두자.

Match guard에서 우리가 점검하고 싶은 조건은 error.kind()가 주는 값이 NotFound인지를 체크하는 것이다. NotFound에 match되었다면, File::create를 사용해서 파일을 생성하려고 한다. 하지만 File::create 역시 실패할 수도 있기 때문에, 또 하나의 match statement를 추가하게 된다. 파일을 열 수 없을 때면 또 다른 오류 메세지를 출력할 것이다. match error.kind()의 마지막 arm은 NotFound가 아닌 모든 경우에 panic하도록 하는 코드이다.

match가 무척 많다! match는 무척 강력하면서도 굉장히 원시적이다. 챕터 13에서 closure에 대해 다룰 것이다. Result<T, E> 타입은 closure를 받는 method를 많이 가지고 있는데, 이들은 match statement로 구현되어있다. 좀 더 숙련된 Rustacean이라면 아래처럼 코딩할 것이다.

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

fn main() {
    let f = File::open("hello.txt").map_err(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Tried to create file but there was a problem: {:?}", error);
            })
        } else {
            panic!("There was a problem opening the file: {:?}", error);
        }
    });
}

챕터 13을 읽고 난 뒤 이 예제로 다시 돌아와서, map_errunwrap_or_else method가 무슨 일을 하는지 표준 라이브러리 문서를 찾아보도록 하자. 많은 오류를 다루면서 굉장히 많이 겹쳐쓰게 되는 match구문을 깔끔하게 정리해주는 많은 method들이 있다.


오류 시 Panic으로 바로 보내는 unwrapexpect

match 자체가 작동은 잘 하지만서도, 코드가 꽤나 길어지고 항상 원하는 대로 작업이 흘러가지 않기도 한다. Result<T, E> 타입 위에 많은 helper method들이 정의되어 있는데, 그 중 하나로 unwrap은 예제 9-4에서 작성한 match statement가 했던 일을 그대로 하는 shortcut method이다. Result의 결과값이 Ok라면, unwrap는 그 Ok가 담고 있는 값을 그대로 전달해준다. 반대로 Result 결과값이 Err라면 unwrap은 우리를 위해 알아서 panic! 매크로를 실행시킨다. 아래는 실제로 unwrap을 사용하는 예제다.

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

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

이제 hello.txt 파일 없이 코드를 실행해보면, unwrap method가 만들어내는 panic! 오류 메세지를 볼 수 있게될 것이다.

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

unwrap이랑 비슷한 expect method는 panic!에 담을 오류 메세지를 선택할 수 있게 해준다. unwrap 대신에 expect을 사용해서 좋은 오류 메세지를 제공해서 당신의 의도를 명확히 전달하고 panic의 원인을 추적하기 더 쉽게 만들 수 있다. expect의 syntax는 다음과 같이 쓴다.

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

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

unwrap과 같은 방식을 동작할 것이라 짐작할 수 있을 것이다. File handle을 반환하거나 panic! 매크로를 호출하거나 말이다. 여기서 expect에 넣어준 parameter가 panic! 호출 시의 기본 오류 메세지 대신 나타나게 될 오류 메세지가 된다. 아래와 같이 말이다.

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

우리가 지정한 텍스트인 Failed to open hello.txt로 오류 메세지가 시작하기 때문에, 이 오류가 어느 코드에서 비롯된 것인지 찾기 쉬워진다. 만약 unwrap을 여러 곳에서 사용한다면, 같은 오류 메세지의 panic 출력물이 나올 것이기 때문에 어떤 unwrap이 panic을 일으킨 것인지 분간하는데 더 많은 시간이 걸릴 수 있다.


오류 전파시키기

당신이 작성하는 함수가 실패할 수도 있는 무언가를 호출한다면, 당신의 함수가 그 오류를 떠 안아 다루기 보다는, 그 외부에서 생긴 오류를 그대로 반환해서 당신의 함수를 사용하는 사람이 원하는 대로 처리할 수 있도록 할 수 있다. 이러한 방법을 에러를 전파시킨다(propagating) 고 하는데, 당신의 코드 문맥 안에서는 사용자가 어떻게 오류를 처리할 것인지에 대한 정보나 논리를 알기 어렵기 때문에 사용자에게 더 많은 권한을 주는 것이다.

아래 예제 9-6에서의 함수는 어느 파일로부터 username을 읽는다. 만일 파일이 없거나 읽을 수가 없다면, 이 함수는 그 오류를 그대로 반환해서 사용하는 쪽에서 처리할 수 있게 할 것이다.

예제 9-6: match를 이용해서 코드 사용자에게 오류를 그대로 반환하는 함수

// Filename: src/main.rs
use std::io;
use std::io::Read;
use std::fs::File;

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

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

    let mut s = String::new();

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

이 함수는 훨씬 짧게 작성할 수도 있지만, 지금은 오류 처리에 대해서 알아보는 시기이기 때문에 이렇게 손수 많은 코드를 작성하는 것으로 시작하겠다. 우선 이 함수의 반환 타입이 무엇인지부터 보면 Result<String, io::Error>이다. 이는 함수가 Result<T, E> 타입의 값을 반환할 것인데, 여기서 generic parameter T가 concrete 타입인 String으로, Eio::Error로 채워진다는 것을 의미한다. 이 함수가 아무 문제 없이 해야할 일을 끝냈다면, 이 함수의 사용자는 파일을 통해 읽어낸 username을 가진 String을 담고 있는 Ok 값을 반환받을 것이다. 반대로 그 과정에서 어떤 문제가 닥쳤다면, 사용자는 그 문제에 대한 정보를 가지고 있는 io::Error instance를 담고 있는 Err 값을 받게 될 것이다. 여기서 io::Error로 오류 타입을 선택한 이유는 해당 함수가 실패할 수 있는 작업이 File::open 함수와 read_to_string method에서 나올 수 있는 오류 종류와 일치하기 때문이다.

함수의 body는 File::open 함수를 호출하는 것으로 시작한다. 그 호출로 건네 받은 Result을 예제 9-4와 비슷하게 match를 사용하는데, Err일 경우 panic!을 호출하는 대신에 일찍이 File::open에서 줬던 그 오류 값을 해당 함수의 오류 값으로 반환하는 것이 다른 점이다. 만일 File::open이 성공했다면 f 변수는 file handle을 받아서 계속 진행하게 된다.

그리고나서 새로운 String 변수인 s를 만들고 file handle fread_to_string method를 호출해서 그 파일의 내용물을 읽어 s에 저장하려고 한다. read_to_string method 역시 File::open이 성공했다 하더라도 실패할 수 있기 때문에 Result를 반환한다. 그래서 이 Result를 다루는 또 하나의 match가 필요하다. read_to_string이 성공하면 곧 본래 함수 read_username_from_file이 성공한 것이므로 usernameOk로 감싸서 반환하면 된다. 반대로 read_to_string이 실패한다면 File::open이 실패했을 때와 똑같이 오류값을 전달한다. 이 함수의 마지막 expression이기 때문에 return을 굳이 사용하지 않아도 된다.

이 함수 read_username_from_file을 호출하면 username을 담은 Ok 값을 받거나 io::Error를 담은 Err 값을 받을 것이다. 사용자가 이 값을 받아서 무슨 일을 할지는 우리는 모른다. Err 값을 받았을 때 바로 panic!을 사용해서 프로그램을 깨뜨릴 수도 있고, 기본 username을 사용할 수도 있고, 다른 파일 어딘가에서 username을 찾아올 수도 있다. 우리가 만든 함수를 어떻게 사용될 지 충분한 정보가 없기 때문에 우리는 모든 성공값이나 실패값을 받아서 다룰 수 있도록 전파시키는 것이다.

이렇게 오류를 전파시키는 패턴은 Rust에서 꽤나 흔하게 쓰이기 때문에 ? 연산자를 통해 간편하게 쓸 수 있게 해준다.


오류를 전파시키는 단축 연산자 ?

예제 9-7는 예제 9-6의 read_username_from_file와 같은 기능을 하면서도 ?를 사용한 코드이다.

예제 9-7: ?를 사용해서 오류를 넘기는 함수

// Filename: src/main.rs
use std::io;
use std::io::Read;
use std::fs::File;

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

Result 다음에 붙은 ?는 예제 9-6에서 Result를 다루는 match expression과 거의 비슷한 일을 한다. Result 결과값이 Ok라면 그 안에 담겨진 값이 반환되고 프로그램은 계속 될 것이다. 그 값이 Err라면, 함수가 그 오류값을 전파해준다.

예제 9-6의 match expression과 ?의 차이점이 있다. ?이 취한 오류값은 표준 라이브러리의 From trait 위에 정의된 from 함수를 거쳐가는데, from 함수는 어느 타입에서 다른 타입으로 오류를 변환해준다. 함수에서 ? 호출을 통해 받은 오류 타입이 그 함수가 정의한 return 타입으로 변환되는 것이다. 이는 함수가 실패할 수 있는 모든 가능한 경우들을 한 가지 오류 타입으로 반환하고자 할 때 유용하다. 심지어 여러 부분에서 다른 이유로 실패하더라도 도움이 된다. 자기 자신을 다른 곳에서 반환될 오류 타입으로 변환하는 from 함수를 구현해놓은 오류 타입이라면 ?가 그 변환을 자동적으로 수행해준다.

예제 9-7에서 File::open 호출 끝에 붙은 ?은 변수 fOk가 담고 있는 값을 반환해 줄 것이다. 오류가 일어난다면 ?Err 값을 반환하는 것으로 그 함수를 일찍 끝내버릴 것이다. read_to_string 호출 끝에 붙은 ? 역시 마찬가지이다.

? 연산자는 boilerplate 코드를 많이 없애주고 함수의 구현을 단순하게 만들어준다. ? 바로 다음에 chaining method를 호출해서 더욱이 짧은 코드를 쓸 수도 있다. 바로 예제 9-8처럼 말이다.

예제 9-8: ? 다음에 오는 chaining method

// Filename: src/main.rs
use std::io;
use std::io::Read;
use std::fs::File;

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

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

    Ok(s)
}

새로운 String s를 생성하는 줄을 제일 위로 올렸는데, 이 코드는 변한 게 없다. 그리고 변수 f를 만드는 대신 read_to_string의 호출을 File::open("hello.txt")?의 결과물에 바로 연결시켰다. 여전히 read_to_string 호출 끝에 ?가 있으므로, File::openread_to_string 두 가지 모두 성공했을 때 susername을 성공값으로 넣을 수 있다. 결국 하는 일은 예제 9-6과 9-7과 다르지 않다. 다만 좀 더 사람이 사용하기 좋은 (ergonomic) 방법으로 코딩한 것이다.

내친 김에 우리는 더욱 짧게 코드를 작성할 수도 있다.

예제 9-9: fs::read_to_string 사용

// Filename: src/main.rs
use std::io;
use std::io::Read;
use std::fs;

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

파일을 읽어서 문자열로 저장하는 것은 꽤나 흔하게 하는 일이므로 Rust는 fs::read_to_string이라는 간편한 함수를 제공한다. 이 함수는 파일을 열고 새로운 String을 만든 다음에 그 파일의 내용을 읽어서 생성한 String에 그 내용을 넣고 그 String을 반환한다. 이 함수가 이렇게 이루어지는 과정을 알 수 없는 기회가 없기 때문에 일부러 처음에 그 과정을 일일이 다 구현한 것이다.


?Result를 반환하는 함수에서만 쓸 수 있다

?는 예제 9-6에서 사용한 match expression과 같은 방식으로 동작하기 때문에 Result 타입을 반환하는 함수에서만 쓸 수 있다. match에서 반환하고자 하는 Result의 타입을 나타내는 부분이 return Err(e)이므로, 그 함수의 반환 타입도 이에 호환하기 위해서 Result 타입이어야만 하는 것이다.

() 타입을 반환하는 main 함수에서 ?를 쓴다면 어떻게 되는지 보자.

use std::fs::File;

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

이 코드를 컴파일하고자 한다면 아래와 같은 오류 메세지를 보게 된다.

error[E0277]: the trait bound `(): std::ops::Try` is not satisfied
 --> src/main.rs:4:13
  |
4 |     let f = File::open("hello.txt")?;
  |             ------------------------
  |             |
  |             the `?` operator can only be used in a function that returns
  `Result` (or another type that implements `std::ops::Try`)
  |             in this macro invocation
  |
  = help: the trait `std::ops::Try` is not implemented for `()`
  = note: required by `std::ops::Try::from_error`

위 오류 메세지는 ?Result를 반환하는 함수에서만 사용할 수 있다고 지적한다. Result를 반환하지 않는 함수 안에서 Result를 반환하는 다른 함수를 사용한다면, ? 대신 matchResult를 다루는 method를 사용해서 오류를 전파시킬 수 있는 방안을 마련해놓아야 한다.

지금까지 panic!을 호출하는 것과 Result를 반환하는 것에 대해 알아보갔는데, 이제 어느 상황에서 이 둘 중 어느 방법을 사용하는 것이 좋은지 이야기해보자.




panic! 할 것인가 말 것인가

언제는 panic!을 사용하고, 언제는 Result를 반환해야 하는지 어떻게 결정해야 할까? 코드가 panic하면 더 이상 복구할 방법이 없다. 오류 시에 이를 복구할 방법이 있든지 없든지 간에 panic!을 사용하는 것은 자유지만, 이는 사용자에게 오류 상황이 회복 불가능한 상황 오류 상황을 주도록 결정을 내린 것이다. Result 값을 반환하기로 선택한다면, 이는 그 결정을 당신이 내리기보다는 사용자가 선택권을 갖도록 하는 것이다. 사용자는 오류 상황이 복구 가능한 상황이라고 판단한다면 복구 시도를 할 수도 있고, 복구가 불가능한 Err라고 판단한다면 panic! 호출을 통해 복구 불가능한 상황으로 바꿀 수도 있을 것이다. 따라서 Result를 반환하도록 하는 것이 괜찮은 기본적인 선택이 될 것이다.

드물지만 Result를 반환하는 것보다 panic! 호출을 하는 것이 더 바람직할 때도 있다. 예제, 프로토타입 코드, 테스트 코드를 쓸 때에 왜 그런지 살펴보자. 컴파일러가 실패할 수는 없지만 사람은 실패할 수 있는 상황들을 이야기할 것이다. 그리고 라이브러리 코드를 작성 시에 panic을 선택해야 하는 상황에 대한 일반적인 가이드라인을 제공하면서 마치려고 한다.


예제, 프로토타입 코드, 테스트 코드

어떤 개념을 설명하기 위한 예제 코드를 작성할 경우에 굉장하게 오류를 처리하는 코드를 쓰는 것은 오히려 그 예제를 더 복잡하게 만들 수가 있다. 이를 테면 unwrap과 같은 method를 써 놓으면서, 사실상 그 부분은 실제 상황에서는 오류를 제대로 다룰 것이라는 표식과 같이 이해될 수 있는 것이다.

비슷한 논리로, unwrapexpect method는 아직 오류를 어떻게 다룰 지 명확하게 결정하지 못하는 단계인 프로토타입을 만들 때 굉장히 편리하다. 추후에 오류를 확실하게 처리해야 하는 부분들을 알려주는 표식으로 남겨두는 의미도 가질 수 있다.

어느 method 호출이 테스트 코드에서 실패할 때, 게다가 그 method가 테스트 대상이 아닐 경우라도 테스트 전체가 실패해버리길 바랄 수 있다. panic!은 테스트가 실패했다는 확실한 표식이기 때문에 unwrap이나 expect를 사용하는 것이 그 의도에 정확하게 맞는 것이다.


컴파일러보다 당신이 더 많이 알 경우

당신이 Result가 반드시 Ok 값을 가지리라고 확신하는 논리를 가지고 있지만, 그 논리가 컴파일러가 알고 있는 범위가 아닐 때 unwrap을 사용할 수도 있다. 여전히 당신은 처리해야 할 Result를 받게 된다. 당신이 생각하기에 절대 실패할 리 없다 하더라도, 프로그램은 언제든지 실패할 수 있는 가능성이 있다. 손수 코드를 조사해서 절대로 Err를 받을 리 없다고 확신한다면, unwrap을 사용하기에 완벽한 상황이다. 아래는 그 예제이다.

use std::net::IpAddr;

let home: IpAddr = "127.0.0.1".parse().unwrap();

이 예제는 하드코딩된 문자열을 파싱해서 IpAddr instance을 만든다. 127.0.0.1은 분명히 올바른 IP 주소이므로 여기서는 unwrap을 사용해도 된다. 하지만 이렇게 하드코딩한 올바른 문자열이 parse method의 반환 타입을 바꿔주지는 않는다. 여전히 우리는 Result 값을 받을 것이며 컴파일러는 그 ResultErr를 보내는 가능성을 무시하지 않기 때문에, 그리고 그 문자열이 항상 올바른 IP 주소인지 알만큼 똑똑하지 않기 때문에 우리가 Result를 처리하도록 강제할 것이다. 만일 하드코딩되지 않고 사용자가 입력하는 IP 주소라서 실패할 가능성이 확실히 있다면 우리는 더욱 철저하게 Result를 처리하고자 할 것이다.


오류 처리 가이드라인

당신의 코드가 나쁜 상태로 끝날 가능성이 있는 경우에 panic하는 것이 바람직할 수 있다. 여기서 나쁜 상태 란 어떤 가정이나 보장되어야 하는 것, 약속, 불변해야 하는 것들이 망가지는 것을 의미하는데, 코드가 올바르지 않거나 모순되는 값을 받거나 값을 받지 못하는 경우 등을 들 수 있다. 또는 아래와 같은 경우들도 있다.

  • 일어날 것이라 예상되는 상황은 나쁜 상태가 아니다.
  • 나쁜 상태에 빠진 코드는 그 상황을 벗어나려고 해야 한다 (Your code after this point needs to rely on not being in this bad state).
  • 당신이 사용하는 타입에 이러한 정보를 새길 수 있는 좋은 방법 같은 것은 없다.

만일 누군가가 당신의 코드를 호출하는데 말도 안 되는 값을 전달하려 한다면, 당신이 할 수 있는 최선의 방법은 panic!을 호출하고 사용자가 개발하는 동안 그 버그를 스스로 고치도록 하는 것이다. 마찬가지로 panic!은 또한 당신이 사용하는 외부 코드에서 문제가 발생해서 당신이 고칠 수가 없는 올바르지 않은 상태를 받게 될 때 적절하게 쓸 수도 있다.

하지만 실패를 예상할 수 있는 상황이라면, panic! 호출보다는 Result를 반환하는 것이 더 적합하다. 그 예시로 잘못된 형식의 데이터를 받은 parser라든지 접속량이 과도하다는 상태를 반환하는 HTTP request 등을 들 수 있다. 이러한 경우에 Result를 반환하도록 하는 것은 그 실패가 예상 가능한 것이고 이를 사용자가 처리해야 함을 의미한다.

어떤 값에 대해 어떤 작업을 할 때, 코드는 그 값이 올바른지 먼저 검증을 하고 올바르지 않다면 panic해야 한다. 이는 보안 상의 이유가 크다. 올바르지 않은 데이터에 대해서 작업을 시도함으로써 코드의 취약점을 노출할 수 있기 때문이다. 이는 할당받지 않은 메모리 영역에 접근하고자 할 때 표준 라이브러리가 panic! 호출을 하는 가장 주요한 이유이다. 현재 데이터 구조에 속하지 않는 메모리에 접근하려 하는 것은 기본적인 보안의 문제이기 때문이다. 함수는 종종 어떤 약속들을 지켜야 할 때도 있다. 입력값이 특정 요구 사항을 만족할 때에만 함수의 행동이 보장되는 것이다. 그 약속이 위반되었다는 것은 사용자가 원인을 제공한 버그(caller-side bug)임을 의미하고 이 경우 사용자가 받아서 처리할 수 있는 오류가 아니기 때문에 panic하는 것은 정당하다. 사용자가 코드를 잘 못 쓴 것이다. 특히나 약속 위반 시에 panic하는 함수는 API 문서에 그 약속에 대해서 설명을 해놓아야 한다.

하지만 당신의 모든 함수마다 수 많은 오류 체크를 해야 한다면 이는 매우 긴 코드를 작성하는 일이 되고 귀찮은 일이 될 수 있다. 다행스럽게도 Rust의 타입 시스템 (이에 더해서 컴파일러가 수행하는 타입 검사도)이 우리를 대신해서 많은 검사를 해줄 수 있다. 어떤 함수가 특정 타입을 parameter로 받는다면, 이미 컴파일러가 그 타입에 맞는 값을 보장한다고 생각하면서 함수 작성을 할 수 있는 것이다. 예를 들어 Option을 받는 함수에서 Option이 아닌 다른 타입을 받았다면, 프로그램은 nothing 이 아니라 something 을 받을 것이라 예상한다. 즉 우리의 코드는 Some일 때와 None일 때를 모두 신경 쓸 필요가 없다는 것이다. 왜냐하면 분명히 어떤 값을 가지는 경우에만 쓸모가 있을 것이기 때문이다. 함수에 nothing 을 건네려고 하는 것은 애초에 컴파일러가 허락하지 않는 행동이다. 따라서 함수는 그런 경우를 런타임에서 검사할 필요가 없다. 또 다른 예를 들어보자면, u32와 같은 unsigned integer 타입을 parameter로 사용하면 절대 음수일 리가 없음을 보장할 수도 있다.

Rust의 타입 시스템을 통해 올바른 값을 받았는지 보장하는 아이디어를 바탕으로 한 걸음 더 나아가 검증(validation)을 위한 커스텀 타입을 새로 만드는 것을 생각해보자. 챕터 2에서의 guessing game을 떠올려보면 우리는 1부터 100 사이의 값을 사용자로부터 입력 받으려고 했었다. 그 값을 받아서 정답과 같은지 검사하기 전에 우리는 그 숫자가 1부터 100 사이의 값인지 전혀 검사하지 않았었다. 단지 그 값이 양수인지만 확인했었다. 사실 그렇게 검사를 하지 않더라도 "Too high"나 "Too low"라고 출력해주는 것은 여전히 맞는 말이므로 그 결과가 마냥 참담하진 않았다. 그래도 사용자에게 올바른 범위의 숫자를 입력하도록 안내하고 그렇지 않을 경우 (숫자가 아닌 글자를 입력할 경우 등) 대응을 하도록 하여 프로그램을 더욱 개선시킬 수 있을 것이다.

이를 구현하기 위한 방법 중 하나로 u32 대신 i32 타입을 입력값으로 받아 음수 입력도 가능하게 한 뒤에, 그 숫자가 원하는 범위에 속하는 지 검사하는 코드를 추가할 수 있다.

loop {
    // --중략--

    let guess: i32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    if guess < 1 || guess > 100 {
        println!("The secret number will be between 1 and 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
    // --중략--
}

위의 if expression은 입력값이 원하는 범위 밖에 있는지 확인한 뒤 문제가 있다면 사용자에게 알려주고 다시 입력값을 받을 수 있도록 continue를 호출한다. 이 검사가 잘 넘어간 후에 입력값과 정답을 비교할 수 있는 것이다.

하지만 이는 이상적인 해법이 아니다. 만약 입력값이 1과 100 사이에 있어야 된다는 점이 극히 중요한 사안이라면, 그리고 많은 함수들이 이러한 제약을 필요로 한다면, 모든 함수마다 이러한 검사를 쓰는 것은 매우 귀찮은 일이 될 것이다 (또한 성능에 안 좋은 영향을 미칠 수도 있다).

대신 새로운 타입을 만들어서 검증 기능을 instance 생성 단계에 넣을 수 있다. 그리하면 검증 코드를 반복적으로 쓰지 않아도 되고, 이 타입을 사용하는 모든 함수들이 그 값의 범위에 대한 확신을 가지고 안전하게 사용할 수 있게 된다. 예제 9-9는 Guess 타입을 정의해서 new 함수가 1과 100 사이의 값을 받을 때만 그 instance를 생성할 수 있도록 하는 한 가지 방안을 보여준다.

예제 9-10: 1과 100 사이의 값만을 받는 Guess 타입

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value
        }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}

우선적으로 우리는 Guess라는 이름의 struct가 value라는 이름의 i32 필드를 가지도록 정의해서 숫자값이 저장되도록 했다.

그리고 Guess 타입 위에 연계된 new라는 이름의 함수는 그 instance를 생성한다. new 함수는 value라는 i32 parameter 하나를 받아서 Guess 하나를 반환하도록 정의했다. new 함수는 value의 값이 1과 100 사이에 있는지를 테스트한다. 테스트를 통과하지 못하면 panic! 호출을 해서 사용자가 고쳐야 하는 버그가 있음을 경고할 것인데, 그 범위 밖의 값을 Guess에 넣는 것은 Guess::new가 지켜야 하는 약속을 위반하기 때문이다. Guess::new가 panic할 수 있는 경우는 대중에게 공개되는 API 문서에 필히 쓰여있어야 한다. API 문서에 panic!의 가능성을 문서화하는 규칙은 챕터 14에서 소개할 것이다. 이제 value가 테스트를 통과한다면 value 필드에 그 값을 받아서 새로운 Guess를 생성해서 반환하게 된다.

그 다음으로 self를 borrow하는 value라는 이름의 method를 구현하는데, 여타 parameter를 받지 않고 i32 타입을 반환하도록 정의한다. 이런 종류의 method를 종종 getter 라고 부르는데, 그 목적이 어떤 필드의 데이터를 얻어와서(get) 반환하기 때문이다. 이 public method는 Guess struct의 value 필드가 숨겨져 있기(private) 때문에 필요하다. value 필드가 숨겨져 있어야 Guess struct를 사용하는 코드가 value를 직접 바꾸려는 것을 금지할 수 있기 때문에 중요하다. 외부 코드는 반드시 Guess::new 함수를 사용해서 Guess instance를 생성해야만 하므로, 이 때 value가 특정 조건을 만족하는 지 검사받아야만 하도록 하는 것이다.

이제 1과 100 사이의 숫자를 parameter로 받거나, 반환값으로 주는 함수는 그 signature에 i32 대신에 Guess 타입을 주면 더 이상 함수의 body에 추가적인 검사를 할 필요가 없게 된다.




정리하는 말

Rust의 오류 처리 기능은 우리가 더욱 철저한 코드를 쓰는 것을 도와주도록 설계되었다. panic! 매크로는 프로그램이 해결 불가능한 상황에 있다는 신호를 주고, 올바르지 않은 값을 가지고 뭔가 더 하기 전에 프로세스를 멈추라고 요청하도록 한다. Result enum은 Rust의 타입 시스템을 활용해서 어떤 동작의 실패를 사용자의 코드가 처리해서 회복시킬 수 있다고 알려줄 수 있다. Result를 사용함으로써 당신의 코드를 사용하는 쪽에게 그 결과가 잠재적으로 성공일 수도 실패일 수도 있고 이에 따라 알맞게 처리해야 한다고 말해주는 것이다. 상황에 맞게 panic!Result를 사용하면 피할 수 없는 문제들을 맞닥뜨릴 때 우리 코드를 좀 더 안정적으로 만들어 줄 것이다.

지금까지 표준 라이브러리의 OptionResult enum이 generic을 유용하게 쓰는 방법들을 보았다. 다음 챕터에서 generic이 어떻게 작동하고 우리가 이를 어떻게 사용할 수 있는지에 대해 이야기해보자.

반응형