Rust #9: 9장 오류처리

·

7 min read

개요

Rust는 다른 프로그래밍 언어와 다르게 복구 가능한 오류복구 불가능한 오류를 구분해서 처리합니다. 이시간을 통해 이 두가지 유형에 대해 알아보는 시간을 가져 봅시다.

복구할 수 없는 오류 panic! 매크로

복구할 수 없는 오류란 프로그램의 오동작을 최소화 하기 위해 즉시 프로그램을 종료해야 할 때 사용됩니다. 이때 panic! 매크로를 사용하게 됩니다.

패닉 시 Rust의 기본 동작

Rust는 패닉이 발생하면 스택을 백업하고 만나는 함수마다 데이터를 정리하는 행위를 합니다. 이 작업은 상당한 작업이 되기 때문에 컴파일한 실행파일을 작게 만드려면 Cargo.toml에 다음의 설정을 할 수 있습니다.

[profile.release]
panic = 'abort'

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.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

여기서 panic! 매크로 인자의 메시지가 출력됨을 볼 수 있습니다.

panic! 역추적 사용

다음의 코드를 통해,

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

    v[99];
}

배열의 배열 인덱싱이 잘못되었을 때 출력되는 런타임 오류 메시지가 panic! 매크로로 발생한 메시지와 유사함을 알 수 있습니다.

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이때 추석 기능을 사용하려면 환경변수 RUST_BACKTRACE를 0이 아닌 다른 값으로 설정 하면,

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
   1: core::panicking::panic_fmt
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
   2: core::panicking::panic_bounds_check
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
   5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
   6: panic::main
             at ./src/main.rs:4
   7: core::ops::function::FnOnce::call_once
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

추적 정보를 확인할 수 있습니다.

복구 가능한 오류 Result

가령, 파일을 열려고 했을 때 파일이 없어 실패한 경우 프로그램을 종료하는 대신 새로운 파일을 생성하는 것이 로직에 부합할 수 있습니다. 이 때 사용하는 것이 Result 열거형인데요, Option 열거형과 함께 Rust 표준 라이브러리에서 많이 사용하는 기능입니다.

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

Result 열거형은 다음처럼 사용할 수 있습니다.

use std::fs::File;

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

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

앞전 시간에 배웠던 match 문을 통해 정상 동작일 때 file 핸들을 반환하고, 오류가 발생했을 때 패닉으로 처리하는 코드입니다.

다른 오류에 대한 일치

위의 코드는 오류가 발생했을 때 패닉으로 프로그램으로 종료하게 되는데요, 우리가 원하는 것은 오류의 다양한 원인에 대한 처리라고 한다면 다음의 코드처럼 작성할 수 있습니다.

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

위의 코드는 파일을 열려고 할 때 파일이 없으면 파일을 생성하고, 파일을 생성해서 파일을 핸들을 반환하거나, 파일을 생성할 수 없으면 패닉을 발생하고, 그 이외의 오류 역시 패닉을 발생하는 코드입니다.

이 코드는 match 키워드를 이용한 정상 코드이지만, 몇 개의 처리로 인해 조금 복잡해 보입니다. 다음의 코드처럼 개선할 수 있습니다.

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

fn main() {
    let f = 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);
        }
    });
}

위의 코드는 unwrap_or_else() 메소드를 이용해 정상 반환의 경의 핸들을 반환하고 오류일 경우 클로저로 처리를 넘기는 것으로 코드를 좀 더 단순화 하였습니다. 13장에서 클로저에 대해 배울 예정입니다.

Panic on Error 바로가기: unwrap과 expect

match를 사용하면 오류 처리에 대해 잘 처리할 수 있지만 장황하고 의도를 잘 전달하지 못할 수 도 있습니다. 다음의 코드를 보시죠.

use std::fs::File;

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

unwrap() 메소드로 값만 취하고 에러는 무시하고 있습니다. 만약 오류가 발생한다면 오류가 발생했을 때 unwrap() 메소드에서 대신 panic!을 호출해 줍니다.

hread '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() 메소드가 있습니다.

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
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

오류 전파

만약 자신이 처리할 수 없는 오류라면 그것을 호출한 코드로 돌려줄 수 있습니다.

use std::fs::File;
use std::io;
use std::io::Read;

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

위의 코드는 match를 이용해 발생 오류를 그대로 반환하는 값인 Result<T, E>의 E로 반환합니다. 여기서 살펴볼 필요가 있는 코드는 Err(e) => return Err(e),인데요, 오류가 발생했을 경우 메소드 끝까지 도달하지 않고 여기서 오류를 반환하도록 합니다.

오류 전파의 지름길: ?연산자

?연산자를 사용하면 오류를 호출한 메소드로 오류를 전달합니다. ?연산자를 사용하여 아래처럼 위의 코드를 좀 더 간단하게 작성할 수 있습니다.

use std::fs::File;
use std::io;
use std::io::Read;

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

?연산자는 당연히 이어서 쓸 수 도 있습니다.

use std::fs::File;
use std::io;
use std::io::Read;

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

최종적으로 이것을 다음처럼 간단하게 할 수 있습니다.

use std::fs;
use std::io;

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

오류 전파를 위한 바로가기: ? 연산자

다음의 코드는 보시죠.

use std::fs::File;

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

? 연산자에 의해서 결정될 수 있는 유형은 Result<T, E> 또는 Option<T> 또는 여기서 다루지 않은 std::ops::Try 일 수 있습니다. 컴파일러는 이것을 결정할 수 없기 때문에 컴파일 오류가 발생합니다.

다음의 코드처럼 반환형을 캐스팅 할 수 있습니다.

use std::error::Error;
use std::fs::File;

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

    Ok(())
}

여기서 사용하는 Box<dyn Error> 유형은 17장에서 다룰 예정입니다.

panic! 하거나 하지 않거나

그렇다면 언제 panic!으로 프로그램을 종료해야 하고 하지 않아야 하는 걸까요? 프로그램의 로직에 따라 panic! 대상이거나 아닐 수 있습니다. 메소드를 설계할 때 이것을 알 수 없는 경우가 대부분이므로 반환형으로 Result<T, E>를 사용하는게 좋은 기본 선택이 됩니다.

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

프로토타입으로 전개하는 코드는 unwrap()또는 expect()메소드를 이용해 오류 처리를 특별히 하지 않고 코드를 전개하는게 효율적입니다. 또한 테스트 코드는 메서드 호출이 실패했을 때 테스트 중인 기능이 아니더라도 테스트가 실패하는게 맞습니다. 이때에도 unwra[() 또는 expect() 메소드는 유용합니다.

다음의 코드처럼 하드코딩된 코드를 테스트할 때,

    use std::net::IpAddr;

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

"127.0.0.1"에 대한 파싱으로 오류가 발생하지 않을것이란 기대를 할 수 있습니다. 이럴 때 unwarp()를 사용할 수 있습니다.

오류 처리 지침

다음의 오류 지침을 따라 봅시다.

  • 나쁜 상태는 가끔 발생할 것으로 예상되는 것이 아닙니다.
  • 이 시점 이후의 코드는 이 나쁜 상태에 있지 않아야 합니다.
  • 사용하는 유형으로 이 정보를 인코딩하는 좋은 방법은 없습니다.

이럴때 panic!을 사용할 수 있습니다.

검증을 위한 사용자 정의 유형 생성

다음의 코드를 예로 보시죠.

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

이것은 입력된 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
    }
}

정리

Rust의 오류 처리 기능은 보다 강력한 코드를 작성할 수 있도록 설계되었습니다. Rust는 panic! 매크로 및 Result<T, E>를 이용해서 즉각적으로 중지해야 하는 오류 유형과 복구할 수 있는 오류 유형을 처리하도록 합니다.