에러 핸들링

에러 핸들링

복구 불가능한 에러 핸들링 (panic! 매크로)

Rust에서 복구불가능한 에러에 대해 프로그램을 중지시키기 위해 panic!() 매크로를 사용한다. panic!() 매크로는 (디폴트로) 콜스택을 Unwind 하면서 Cleanup을 수행하고 프로그램을 종료시킨다.

아래 예제는 프로그램 실행시 2개의 아규먼트를 받아들여 나누기를 수행하는 것으로, 나누기 수행전 두번째 아규먼트가 0 인지 체크하여 panic!() 매크로를 호출하는 것이다.

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let a = &args[1];
    let b = &args[2];
    println!("a={}, b={}", a, b);

    if b == "0" {
        panic!("divide by zero");
    }

    // ...나누기 코드 생략...
}

cargo run에서 Binary 프로그램에 아규먼트를 지정하기 위해 아래와 같이 1과 0을 순서대로 지정한다. (참고: Rust 기초/Cargo 사용법)

$ cargo run 1 0
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
    Running `target/debug/exam 1 0`
    a=1, b=0
    thread 'main' panicked at 'divide by zero', src/main.rs:8:9
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

마지막 2개의 라인이 panic!() 매크로와 관련된 것으로, main 쓰레드에서 'divide by zero'라는 메시지를 내며 src/main.rs 소스파일의 라인 8 컬럼 9에서 패닉이 수행되었음을 표시하고 있다.

위 실행의 마지막 라인에는 현재 실행까지의 콜스택 정보를 출력해 주는 backtrace 기능을 사용하기 위해서는 RUST_BACKTRACE=1 라는 환경변수를 설정하라는 메시지가 있다. 리눅스에서 아래와 같이 환경변수를 설명하면서 cargo를 실행할 수 있다. 여기서는 고의로 아규먼트를 주지 않았서 let a = &args[1]; 라인에서 에러가 발생하게 하였으며, 아래는 그 에러에 대한 backtrace 정보를 표시하고 있다.

$ RUST_BACKTRACE=1 cargo run
   Compiling exam v0.1.0 (/home/aroot/rust/exam)
    Finished dev [unoptimized + debuginfo] target(s) in 0.96s
     Running `target/debug/exam`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:3:14
stack backtrace:
   0: rust_begin_unwind
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/std/src/panicking.rs:517:5
   1: core::panicking::panic_fmt
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/core/src/panicking.rs:100:14
   2: core::panicking::panic_bounds_check
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/core/src/panicking.rs:76:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/core/src/slice/index.rs:184:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/core/src/slice/index.rs:15:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/alloc/src/vec/mod.rs:2496:9
   6: exam::main
             at ./src/main.rs:3:14
   7: core::ops::function::FnOnce::call_once
             at /rustc/f1edd0429582dd29cccacaf50fd134b05593bd9c/library/core/src/ops/function.rs:227:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

복구 가능한 에러 핸들링

프로그램에서 어떤 에러가 발생했을 때, 이를 복구할 수 있는 경우가 종종 있다. 예를 들어, 어떤 파일을 가져오려 할 때, 만약 그 파일이 없는 경우가 에러가 발생하는데, 이 경우 프로그램을 종료하는 대신 동일한 이름의 새로운 파일을 생성할 수도 있다. 이러한 파일 처리 에러 복구 케이스를 코딩해 보면 아래와 같이 작성할 수 있다.

use std::fs::File;

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

    let f = match f {
        Ok(file) => file,
        Err(err) => panic!("Error: {}", err)
    };
}

위의 예제에서는 일단 파일을 오픈했을 때 정상적으로 오픈되는지 아니면 어떤 에러가 있는지를 나누어 처리하고 있다 (새 파일 생성 부분은 아래 예제에). File::open() 함수는 여기서 std::io::Result<T, E> 타입을 리턴하는데, 이 Result 타입은 정상적으로 수행되었을 T 타입의 값을 리턴하고, 에러가 났을 때 E 타입의 값을 리턴한다.

Result 타입은 자주 사용되기 때문에 별도로 std::io::Result을 include 하지 않아도 (즉, use를 사용하지 않아도) 직접 사용할 수 있다. 아래는 Result 열거형 타입의 정의인데, 기본적으로 정상 실행된 경우 Ok(T)를, 에러인 경우 Err(E)를 가진다. 예를 들어, 위 예제에서 파일이 정상적으로 오픈된 경우 match 표현식의 Ok(file) 케이스가 실행되고 여기서 file 변수는 std::fs::File 타입의 오픈된 파일 핸들을 갖는다. 그리고 만약 에러가 난 경우 Err(err) 케이스가 되고 std::io::Error 타입의 변수 err를 사용하게 된다.

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

std::io::Result<T, E> 타입에는 여러 보조 기능들이 추가되어 있다. 예를 들어, 위의 파일 오픈 예제는 아래와 같이 간략히 표현할 수 있다. unwrap()은 std::io::Result 타입에서 사용할 수 있는 함수로서, 정상적인 경우 파일핸들을 리턴하고 에러인 경우 panic을 호출한다.

    let f = File::open("aa.txt").unwrap();

unwrap()과 동일한 기능을 하지만 더 나아가 panic 메시지를 지정할 수 있는 함수로 expect()도 많이 사용된다. 예를 들어, 아래는 panic 메시지를 더 상세히 지정하는 expect()의 예이다.

    let f = File::open("aa.txt").expect("Cannot open file");

다음으로 파일이 없는 경우 파일을 생성하기 위해서는, 에러를 표시하는 match arm인 Err(err) 에서 에러의 종류를 체크해서 NotFound 인 경우 파일을 생성하면 된다. std::io::Error 타입에는 에러 종류를 표시하는 kind()를 사용할 수 있고 따라서 err.kind() 를 체크해서 ErrorKind::NotFound 열거형 variant인지를 체크하고, 이 경우 File::create() 함수를 사용하여 파일을 생성하면 된다. 이러한 케이스들을 다시 표현해 보면 아래와 같이 코딩할 수 있다.

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

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

    let mut f = match f {
        Ok(file) => file,
        Err(err) => match err.kind() {
            ErrorKind::NotFound => {
                match File::create("a.txt") {
                    Ok(handle) => handle,
                    Err(ex) => panic!("{}", ex)
                }
            },
            other => panic!("Error: {:?}", other)
        }
    };

    //let mut buf = Vec::new();
    //f.read_to_end(&mut buf).expect("read error");
    //println!("{:?}", buf);
}

호출자에게 에러 전달 (Propagating)

Rust에서 에러를 자신이 처리하는 대신 상위 호출자(caller)에게 에러를 전달할 수 있는데, 이를 Error Propagating 이라 한다. 상위 호출자에게 에러가 전달되면, 호출자는 필요에 따른 다른 처리를 하거나 panic 에러를 낼 수도 있다.

예를 들어, 아래 예제에서 File::open()함수와 read_to_string() 메서드는 에러가 발생했을 때, 상위 호출자에게 에러를 리턴하고 있다. 이어 상위 호출자 main()은 panic을 쓰지 않고, 단순히 에러 메시지를 화면에 출력하고 있다.

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

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

    let f = File::open("a.txt");
    let mut f = match f {
        Ok(file) => file,
        Err(err) => return Err(err)  // 호출자에게 에러 return
    };

    match f.read_to_string(&mut str) {
        Ok(s) => s,
        Err(e) => return Err(e) // 호출자에게 에러 return
    };

    Ok(str)
}

fn main() {
    match read_file() {
        Ok(s) => println!("{}", &s),
        Err(e) => println!("{}", e)
    }
}

이렇게 에러를 호출자에 리턴하는 코드는 매우 흔한 경우이기 때문에, 매번 match 를 써서 표현하는 대신, Rust에서는 ? 연산자를 많이 사용한다. ? 연산자를 Result 타입에 사용되는 연산자로, Result::Ok의 경우 Ok 값을 변수에 리턴하고, Result::Err 의 경우 에러를 상위 호출자에게 리턴한다.

예를 들어, ? 연산자를 위의 read_file() 함수를 간력히 표현하면 아래와 같이 될 것이다. 만약 File::open()함수나 read_to_string() 메서드에서 에러가 발생하면, 에러를 상위 호출자인 main 함수에 리턴하게 된다.

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

    let mut f = File::open("a.txt")?; //에러인 경우 호출자에게 에러 return
    f.read_to_string(&mut str)?; //에러인 경우 호출자에게 에러 return
    
    Ok(str)
}

위와 동일한 표현이지만, 좀 더 축약하여 아래와 같이 표현할 수도 있다.

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

    File::open("a.txt")?.read_to_string(&mut str)?;

    Ok(str)
}

? 연산자를 사용할 때 한가지 주의할 점은 함수의 리턴 타입이 ? 연산자의 리턴 타입과 매칭되어야 한다는 것이다. 일반적으로 ? 연산자는 Result, Option, FromResidual 타입을 리턴하는 함수에 사용된다. 예를 들어, File::open("a.txt")? 코드를 위의 main() 함수에 사용하면 에러가 발생하는데, 이는 main() 함수가 unit 타입인 ()을 리턴하는 함수이기 때문에 ? 연산자가 io::Error를 리턴할 때 타입 불일치가 일어나기 때문이다.

This site is not affiliated with or endorsed by the Rust Foundation or Rust Project.