#11 오류에 대한 처리

Pt J·2020년 8월 22일
0

[完] Rust Programming

목록 보기
14/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

소프트웨어를 개발하다보면 어딘가에 버그가 있어 오류가 발생하기 마련이다.
이것을 어떻게 잘 처리할 수 있는가 하는 것은 매우 중요한 일이다.
따라서 Rust는 컴파일 시점에 오류 발생 가능성을 인지하고
개발자가 이에 대한 처리를 할 수 있도록 유도한다.
Rust의 오류는 회복 가능한 오류와 회복 불가능한 오류로 나눌 수 있다.
회복 가능한 오류는 존재하지 않는 파일에 대한 접근과 같이
사용자에게 다시 시도하라고 요청함으로써 해결될 수도 있는 오류다.
반면 회복 불가능한 오는 잘못된 배열 인덱스에 대한 접근과 같이
다시 시도하더라도 문제가 되는 오류다.
회복 불가능한 오류에 대한 것부터 좀 더 자세히 알아보도록 하자.

회복 불가능한 오류 Unrecoverable error

회복 불가능한 오류를 만난다면 우리는 프로그램을 그대로 종료해버릴 수 있다.
어떤 오류가 발생했는지 띄워주고 종료하는 것이다.
이것은 panic! 매크로를 통해 작성할 수 있는데
따로 설정해주지 않는다면 그 동안 쌓인 스택을 풀어주고 종료된다.

여담: 스택 풀기 Unwind
스택을 푼다는 건 프로그램의 흐름을 추적하여
스택 메모리에 쌓여 있는 함수들의 자료를 처리하는 작업을 말한다.
이것은 상당히 비용이 드는 작업인데
따라서 프로그램의 크기를 최소화해야 하는 경우에는
Rust 프로그램에서 이걸 직접 하지 않고 운영체제에게 떠넘길 수도 있다.
이를 위해서는 Cargo.toml 파일에 다음과 같은 내용을 추가한다.

[profile]
panic = 'abort'

배포 시에만 이러한 설정을 주고자 한다면
[profile] 대신 [profile.release]로 작성할 수 있다.

panic! 매크로는 인자로 하나의 문자열을 받는데 이것이 오류 메시지로 작용한다.
그리고 panic! 매크로가 호출되면 그 메시지가 출력되며
어느 파일의 몇 번째 줄 어디에서 panic!이 호출되었는지 띄워준다.

개발자가 직접 panic! 매크로를 호출한 경우 외에도
라이브러리 내부적으로 panic! 매크로를 호출하기도 한다.
예를 들어, 배열의 범위를 벗어난 원소에 접근하고자 한다면
Rust 라이브러리에서 panic!을 호출에 프로그램을 종료하고 오류 메시지를 띄운다.

역추적 Backtrace

직접적으로든 내부적으로든 panic! 함수가 호출되면
프로그램이 종료되고 오류 메시지와 오류 지점이 출력된 후
다음과 같은 문장이 뜨는 것을 볼 수 있다.

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

이것은 환경변수를 설정하여 역추적을 하는 방법이다.
맨 처음 프로그램의 시작지점부터
가장 최근에 실행되어 스택의 맨 위에 존재하는 함수,
즉 오류가 발생한 함수의 panic! 호출 지점까지의 함수 호출 과정을 띄워준다.
1로 설정하라고 하지만 환경변수 RUST_BACKTRACE를 0이 아닌 값으로 설정하면 된다.
cargo run을 할 때 다음과 같이 작성하면
panic!이 발생했을 때 함수의 호출 과정을 역추적할 수 있다.

~$ RUST_BACKTRACE=1 cargo run

이러한 역추적 정보는 디버그 심볼이 활성화되어 있어야만 확인 가능한데
이는 build 또는 run을 할 때 디버그 모드로 실행하면 설정된다.
--release를 통해 배포 모드임을 명시하지 않으면
기본적으로는 디버그 모드로 실행되므로 크게 신경쓰지 않아도 된다.

회복 가능한 오류 Recoverable error

회복 가능한 오류를 만난다면 우리는 굳이 프로그램을 종료시킬 필요가 없다.
그저 그 오류에 대한 적절한 처리를 해주는 것으로 충분하다.
예를 들어, 어떤 파일을 열고자 하는데 그 이름을 가진 파일이 없다면
프로그램을 종료시키기 보다는 그 이름의 파일을 새로 생성하는 쪽이 합리적이다.
우리는 Result<T, E> 열거형을 통해 회복 가능한 오류를 처리할 수 있다.
OkErr 두 가지 열것값을 가지고 있는데 T, E는 제네릭으로,
T는 작업이 성공했을 때 Ok가 갖는 값의 자료형을 의미하며
E는 작업이 실패했을 때 Err가 갖는 값의 자료형을 의미한다.

회복 가능한 오류를 야기할 수 있는 함수는 Result<T, E> 열거형을 반환한다.
우리는 match 표현식과 같은 방법으로 이를 처리해주어야 한다.
Result 열거형도 Option 열거형과 마찬가지로 Prelude에 포함되어 있다.
즉, OkErrResult::를 생략할 수 있다.

File::open과 같은 메서드는 Err 열거값에 io::Error 값을 넣어 반환한다.
io::Error는 표준 라이브러리에 정의된 구조체로,
io:ErrorKind 열거형의 값을 반환하는 kind 메서드를 지원한다.
이것을 통해 이 오류가 io 작업 중 만날 수 있는 어떤 오류인지 알 수 있다.
그리고 우리는 이것과 중첩 match 표현식을 통해 오류 종류에 따른 처리를 할 수 있다.

peter@hp-laptop:~/rust-practice$ mkdir chapter09
peter@hp-laptop:~/rust-practice$ cd chapter09
peter@hp-laptop:~/rust-practice/chapter09$ cargo new error_kind
     Created binary (application) `error_kind` package
peter@hp-laptop:~/rust-practice/chapter09$ cd error_kind/
peter@hp-laptop:~/rust-practice/chapter09/error_kind$ vi src/main.rs

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(ref error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error)
            },
        },
    };
}
peter@hp-laptop:~/rust-practice/chapter09/error_kind$ cargo run
   Compiling error_kind v0.1.0 (/home/peter/rust-practice/chapter09/error_kind)
warning: unused variable: `f`
 --> src/main.rs:7:9
  |
7 |     let f = match f {
  |         ^ help: if this is intentional, prefix it with an underscore: `_f`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: 1 warning emitted

    Finished dev [unoptimized + debuginfo] target(s) in 0.20s
     Running `target/debug/error_kind`
peter@hp-laptop:~/rust-practice/chapter09/error_kind$ 

사용하지 않는 변수에 대한 경고는 무시하도록 하자.
파일을 열 수 없는 이유가 그것이 존재하지 않아서라면 새로 생성하고,
생성에 실패했거나 다른 이유로 열지 못할 경우 panic!을 호출하도록 하였다.
실행해보면 없던 hello.txt가 생성되는 것을 확인할 수 있다.

peter@hp-laptop:~/rust-practice/chapter09/error_kind$ ls
Cargo.lock  Cargo.toml  hello.txt  src  target
peter@hp-laptop:~/rust-practice/chapter09/error_kind$ 

그런데 이렇게 match 표현식을 중첩적으로 사용하니 가독성이 떨어진다.
이 문제를 해결하기 위한 Result 열거형의 메서드 중 하나가 unwrap이다.
이것은 Ok일 경우 그것이 가진 값을 반환하고, Err일 경우 panic!을 호출한다.
우리가 방금 작성했던 코드처럼 말이다.
다음과 같은 코드 대신

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

다음과 같이 작성할 수 있다.

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

unwrap은 우리가 따로 오류 메시지를 지정해줄 수는 없고
Err가 가진 오류 정보를 출력해준다.
오류 메시지를 지정해주고 싶다면 expect 메서드를 사용할 수 있다.

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

우리가 앞서 작성했던 것보다 훨씬 간결하고 가독성이 높은 것을 확인할 수 있다.

전파 Propagation

때로는 오류가 발생했을 때 그 자리에서 처리해주지 않고
그 함수를 호출한 쪽에서 처리하도록 넘겨줄 필요가 있다.
이런 식의 처리를 오류의 전파라고 한다.
이렇게 함으로써 라이브러리 함수를 만들었을 때
거기서 발생할 수 있는 오류에 대한 처리를 우리가 미리 구현해두는 게 아니라
함수를 호출한 쪽에서 원하는 방식으로 처리할 수 있도록 할 수 있다.

Result 열것값을 반환함으로써 오류를 전파할 수 있는데
오류를 전파하는 가장 단순한 방법은
작업의 성공 여부에 따라 Ok 또는 Err를 직접 반환하는 것이다.

peter@hp-laptop:~/rust-practice/chapter09/error_kind$ cd ..
peter@hp-laptop:~/rust-practice/chapter09$ cargo new propagation
     Created binary (application) `propagation` package
peter@hp-laptop:~/rust-practice/chapter09$ cd propagation/
peter@hp-laptop:~/rust-practice/chapter09/propagation$ vi src/main.rs

src/main.rs

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

fn main() {
    match read_username_from_file() {
        Ok(name) => println!("username: {}", name),
        Err(err) => println!("ERROR: {:?}", err),
    }
}

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

오류에 대한 처리를 함수를 호출한 쪽에서 수행하는 것을 확인할 수 있다.
파일이 존재하지 않을 경우

peter@hp-laptop:~/rust-practice/chapter09/propagation$ cargo run
   Compiling propagation v0.1.0 (/home/peter/rust-practice/chapter09/propagation)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/propagation`
ERROR: Os { code: 2, kind: NotFound, message: "No such file or directory" }
peter@hp-laptop:~/rust-practice/chapter09/propagation$ 

존재할 경우

hello.txt

Peter
peter@hp-laptop:~/rust-practice/chapter09/propagation$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/propagation`
username: Peter
peter@hp-laptop:~/rust-practice/chapter09/propagation$ 

와 같이 결과가 나오는 것도 알 수 있다.

그런데 이렇게 매번 오류가 발생할 수 있는 지점마다 match 표현식을 사용하면
코드가 길고 복잡해지며 가독성이 떨어질 수 있다.
따라서 Rust는 이런 상황에서 사용할 수 있는 연산자를 지원하는데 그것이 ?다.
오류가 발생할 수 있는 코드 뒤에 ?를 붙이면
오류가 발생할 경우 그것을 반환하고 그렇지 않을 경우 계속 진행한다.

앞서 작성했던 코드를 이 방식으로 바꾸어보면

peter@hp-laptop:~/rust-practice/chapter09/propagation$ vi src/main.rs

src/main.rs

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

fn main() {
    match read_username_from_file() {
        Ok(name) => println!("username: {}", name),
        Err(err) => println!("ERROR: {:?}", err),
    }
}

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

훨씬 간결해졌으며, 실행해보면 위와 같은 결과를 확인할 수 있다.
? 연산자 뒤에 메서드 호출을 이어갈 수 있는데
마찬가지로 작업을 성공한 경우만 다음 메서드가 호출되고 그렇지 않은 경우엔 오류를 반환한다.

앞서 작성했던 코드를 이 방식으로 바꾸어보면

peter@hp-laptop:~/rust-practice/chapter09/propagation$ vi src/main.rs

src/main.rs

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

fn main() {
    match read_username_from_file() {
        Ok(name) => println!("username: {}", name),
        Err(err) => println!("ERROR: {:?}", err),
    }
}

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

간결하고 가독성이 높으면서도 오류에 대한 처리가 직접 하진 않지만 되어 있는 코드가 되었다.
사실 우리가 작성한 코드는 fs의 메서드를 사용하면 더 쉽게 작성할 수 있다.

fs::read_to_string("hello.txt")?;

이 한 줄이면 충분하다.

? 연산자는 상황에 따라 Err를 반환하는 연산자이므로
Result 값을 반환하는 함수에서만 사용할 수 있다.

main 함수에서 ? 연산자를 사용하고 싶다면 다음과 같이 작성할 수 있다.

peter@hp-laptop:~/rust-practice/chapter09/propagation$ cd ..
peter@hp-laptop:~/rust-practice/chapter09$ cargo new main_result
     Created binary (application) `main_result` package
peter@hp-laptop:~/rust-practice/chapter09$ cd main_result/
peter@hp-laptop:~/rust-practice/chapter09/main_result$ vi src/main.rs

src/main.rs

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

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

    Ok(())
}
peter@hp-laptop:~/rust-practice/chapter09/main_result$ cargo run   Compiling main_result v0.1.0 (/home/peter/rust-practice/chapter09/main_result)
warning: unused variable: `f`
 --> src/main.rs:5:9
  |
5 |     let f = File::open("hello.txt");
  |         ^ help: if this is intentional, prefix it with an underscore: `_f`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: 1 warning emitted

    Finished dev [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/main_result`
peter@hp-laptop:~/rust-practice/chapter09/main_result$ 

오류 발생 지점에 Err를 반환하며 main 함수가 종료되었으며
main을 호출한 운영체제는 Err에 대한 처리를 해주지 않아 그냥 종료된 것으로 보인다.

Box<dyn Error>는 일단 모든 종류의 오류를 의미한다는 정도만 이해하고 넘어가자.
Box<T>에 대한 건 추후에 자세히 다루도록 하겠다.

panic!Result<T, E>

그렇다면 언제 panic!을 호출하고 언제 Result<T, E>를 반환할 것인가.
그것은 상황에 따라 개발자가 판단해야 한다.

panic!은 복구할 수 없지만
Result<T, E>를 반환할 경우 필요에 따라 panic!할 수 있으니
판단이 잘 안선다면 Result<T, E>를 반환하는 함수로 작성을 하는 게 나을 수 있다.

프로토타입이나 테스트 코드를 작성할 때는 즉각적으로 panic!을 하는 것이 유리하다.

이 포스트의 내용은 공식문서의 9장 Error Handling에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글