[Chapter 9] Rust 에러처리

hwwwa·2021년 12월 1일
0

🦀 Rust

목록 보기
23/25

복구 불가능한 에러 - panic!

panic! 매크로가 실행되면 프로그램은 실패 메세지를 출력하고, 스택을 되감고 청소한 후 종료됩니다. 흔히 어떤 종류의 버그가 발견되었고 프로그래머가 해당 에러를 어떻게 처리할 지 명확하지 않을때 발생합니다.

단순한 프로그램 내에서 panic! 호출을 시도해볼 수 있습니다.

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
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

panic! 호출이 마지막 세 줄의 에러 메세지를 야기합니다. 패닉이 발생한 지점을 보여주며, 문제를 일으킨 코드 부분을 발견하기위해 panic! 호출이 발생된 함수에 대한 backtrace를 사용할 수 있습니다.

panic! backtrace 사용

직접 매크로 호출이 아닌 코드의 버그에 의해 panic! 호출이 라이브러리로부터 발생되는 경우를 봅시다. 아래의 코드는 벡터의 끝을 넘어선 요소에 대한 접근을 시도하는 코드입니다.

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

이러한 상황에서 C와 같은 다른 언어들은 원하는 것이 아닐지라도 요청한 것을 정확히 주려고 시도합니다. 설령 그 메모리 영역이 벡터 소유가 아닐지라도 벡터 내에 해당 요소와 상응하는 위치의 메모리에 들어 있는 무언가를 얻을 것입니다. 이를 buffer overread라고 부르며 보안 취약점을 발생시킬 수 있습니다.

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
100', /stable-dist-rustc/build/src/libcollections/vec.rs:1362
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

위 에러는 직접 작성한 파일이 아닌 libcollections/vec.rs를 가리키고 있습니다. 이는 표준 라이브러리 내에 있는 Vec<T>의 구현 부분입니다. 벡터 v[]를 사용할 때 실행되는 코드는 libcollections/vec.rs 안에 있으며, 그 곳이 바로 panic!이 실제 발생한 곳입니다.

RUST_BACKTRACE 환경 변수를 설정해 에러의 원인이 된 것이 무엇인지 정확하게 backtrace할 수 있습니다. 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 100', /stable-dist-rustc/build/src/libcollections/vec.rs:1392
stack backtrace:
   1:     0x560ed90ec04c - std::sys::imp::backtrace::tracing::imp::write::hf33ae72d0baa11ed
                        at /stable-dist-rustc/build/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:42
    .
	  .
	  .
	13:     0x560ed90ee926 - std::rt::lang_start::hd7c880a37a646e81
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:436
                        at /stable-dist-rustc/build/src/libstd/panic.rs:361
                        at /stable-dist-rustc/build/src/libstd/rt.rs:57
  14:     0x560ed90e7302 - main
  15:     0x7f0d53f16400 - __libc_start_main
  16:     0x560ed90e6659 - _start
  17:                0x0 - <unknown>

복구 가능한 에러 - Result

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

TE는 제네릭 타입 파라미터입니다. T는 성공한 경우에 OK variant 내에 반환될 값의 타입을 나타내며 E는 실패한 경우에 Err variant 내에 반환될 에러의 타입을 나타냅니다. Result가 이러한 제네릭 타입 파라미터를 갖기 때문에, 반환하고자 하는 성공적인 값과 에러 값이 다를 수 있는 다양한 상황 내에서 표준 라이브러리에 정의된 Result 타입과 함수들을 사용할 수 있습니다.

use std::fs::File;

fn main() {
    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`
  = note:    found type `std::result::Result<std::fs::File, std::io::Error>`

위 메세지는 File::open 함수의 반환 타입이 Result<T, E> 라는 것을 알려줍니다. 여기서 제네릭 파라미터 T는 성공값의 타입인 std::fs::File로 채워져 있는데, 이는 파일 핸들입니다. 에러에 사용되는 E의 타입은 std::io::Error입니다.

해당 반환 타입은 File::open을 호출하는 것이 성공하여 우리가 읽거나 쓸 수 있는 파일 핸들을 반환해줄 수도 있고 파일이 존재하지 않거나 접근 권한이 없어 실패할 수도 있다는 뜻입니다. 이러한 정보를 Result 열거형으로 전달합니다.

위 코드를 수정하고 반환 값에 따라 다른 행동을 취할 수 있도록 코드를 추가해봅시다.

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 값을 반환하고, Err의 경우 panic! 매크로를 호출하는 방법을 택했습니다.

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

위 코드는 어떤 에러이든 간에 panic! 매크로를 호출하였지만, 실패 이유에 따라 다른 행동을 취하도록 할 수 있습니다. 파일이 없는 경우라면 새로운 파일을 만들어 반환하고, 권한이 없는 등의 그 밖의 이유는 panic!을 일으키도록 코드를 수정해봅시다.

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

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

    let f = match f {
        Ok(file) => file,
        Err(ref error) if 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
                    )
                },
            }
        },
        Err(error) => {
            panic!(
                "There was a problem opening the file: {:?}",
                error
            )
        },
    };
}

Err variant 내에 있는 File::open이 반환하는 값의 타입은 io::Error이며 이는 표준 라이브러리에서 제공하는 구조체입니다. 이 구조체가 제공하는 kind 메소드를 호출하여 io::ErrorKind값을 얻을 수 있습니다. 우리가 사용하고자 하는 variant는 파일이 존재하지 않는 경우인 ErrorKind::NotFound 입니다. 파일 생성을 시도할 때에도 실패할 수 있기 때문에 match 구문을 추가해줍니다.

에러가 났을 때 패닉을 위한 숏컷: unwrap, expect

match는 충분히 잘 동작하지만, 살짝 장황하기도 하고 의도를 항상 잘 전달하는 것은 아닙니다. Result<T, E> 타입은 다양한 작업을 하기 위해 정의된 수많은 헬퍼 메소드를 가지고 있습니다. 그 중 unwrap 메소드는 위 코드의 match 구문과 비슷한 구현을 한 숏컷 메소드입니다. Ok 이라면 Ok 내의 값을 반환하고, Err라면 panic! 매크로를 호출합니다.

use std::fs::File;

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

또 다른 메소드인 expectunwrap과 유사한데, panic! 에러 메세지를 선택할 수 있게 해줍니다.

use std::fs::File;

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

에러 전파하기

에러 발생 가능성이 있는 무언가를 호출하는 함수를 작성할 때, 함수 내에서 에러를 처리하는 대신 에러를 호출하는 코드 쪽으로 반환하여 에러 전파 를 할 수 있습니다. 코드 내용 내에서 이용 가능한 것들보다 더 많은 정보와 로직을 가지고 있을 수도 있는 호출하는 코드 쪽에 더 많은 제어권을 줍니다.

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> 입니다. 만일 이 함수가 어떤 문제 없이 성공하면 함수를 호출한 코드는 String을 담은 값(함수가 파일로부터 읽어들인 사용자 이름)을 반환할 것입니다. 만일 문제가 발생한다면 문제에 대한 더 많은 정보를 담고있는 io::Error의 인스턴스를 담은 Err값을 반환할 것입니다.

이처럼 에러를 전파하는 패턴은 매우 흔하기 때문에 Rust에서는 이를 더 쉽게 해주는 물음표 연산자 ?를 제공합니다.

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

위의 코드를 물음표 연산자를 이용해 간결하게 바꾸어 봅시다.

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 값 뒤의 ?는 위에서 정의했던 match 표현식과 거의 같은 방식으로 동작하게끔 정의되어 있습니다. 만일 Result의 값이 Ok라면, Ok 내의 값이 이 표현식으로부터 얻어지고 프로그램이 계속됩니다. 만일 값이 Err라면,  return 키워드를 사용하여 에러 값을 호출하는 코드에게 전파하는 것과 같이 전체 함수로부터 Err 내의 값이 반환될 것입니다.

match 표현식과의 한 가지 차이점은 에러 값들이 표준 라이브러리 내에 있는 From 트레잇에 정의된 from 함수를 친다는 것입니다. from 함수의 호출이 ?가 얻게 되는 에러 타입을 ?가 사용되고 있는 현재 함수의 반환 타입에 정의된 에러 타입으로 변환합니다.

?는 많은 수의 boilerplate를 제거해주고 함수의 구현을 더 단순하게 만들어 줍니다. 아래와 같이 ? 뒤에 바로 메소드 호출을 연결하는 식(chaning)으로 코드를 더 줄일 수도 있습니다.

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)
}

또한, ?match 표현식과 동일한 방식으로 동작하도록 정의되어 있기 때문에 Result 타입을 반환하는 함수에서만 사용 가능합니다.

panic!Result 사용 결정하기

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

예제 코드 내에서는 panic!을 일으킬 수 있는 unwrap 같은 메소드를 호출하는 것이 어플리케이션이 에러를 처리하고자 하는 방법에 대한 플레이스홀더로서의 의미를 갖습니다.

비슷한 상황에서 에러를 어떻게 처리할지 결정하기 전에, unwrapexpect 메소드가 프로토타이핑을 할 때 매우 편리합니다.

만일 테스트 내에서 메소드 호출이 실패한다면, 해당 메소드가 테스트 중인 기능이 아니더라도 전체 테스트를 실패시키도록 합니다. panic!은 테스트가 어떻게 실패하는지 표시해주기 때문에 unwrap이나 expect를 사용합니다.

컴파일러보다 더 많은 정보를 가지고 있을 때 - Result

use std::net::IpAddr;

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

위 코드에서는 하드코딩된 String을 파싱하여 IpAddr 인스턴스를 만듭니다. 127.0.0.1이 유효한 주소임을 볼 수 있으므로 unwrap 사용이 허용됩니다. 하지만 이 사실이 parse 메소드의 반환 타입을 변경해주지는 않습니다. 여전히 Result 값을 갖게 되고, 이는 String이 항상 유효한 IP 주소라는 것을 알 수 있을 만큼 컴파일러가 똑똑하진 않기 때문입니다.

에러 처리를 위한 가이드라인

어떤 가정, 보장, 계약, 불변성이 깨질 가능성이 있는 때에는 panic!을 사용하는 것이 바람직합니다. 예를 들면 유효하지 않은 값이나 모순되는 값, 찾을 수 없는 값이 코드를 통과하는 경우가 있습니다.

앞선 상황과 같은 상태에 도달했지만 어떤 코드이던 간에 일어날 것으로 예상될 때라면 panic!을 호출하는 것보다 Result를 반환하는 것이 더 적합합니다. 예를 들면 기형적인 데이터가 주어지는 parser나, 속도 제한에 달했음을 나타내는 상태를 반환하는 HTTP 요청 등이 있습니다. 이러한 경우 Result를 반환하면 호출자가 문제 처리 방식을 결정하여 상태를 위로 전파할 수 있습니다.

유효성을 위한 커스텀 타입 생성

loop {
    // snip

    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) {
    // snip
}

위 코드와 같은 경우는 이상적인 유효성 보장 해결책이 아닙니다. 오직 1과 100 사이의 값에서만 동작하는 것이 전적으로 중요하고 많은 함수가 이러한 요구사항을 가진다면 잠재적으로 성능에 영향을 줄 것입니다.

이 대신 새로운 타입을 만들어, 모든 곳에서 유효성 확인을 하는 대신 해당 타입의 인스턴스를 생성하는 함수 내에 유효성 확인을 넣을 수 있습니다.

pub struct Guess {
    value: u32,
}

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

        Guess {
            value
        }
    }

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

0개의 댓글