비욘드 JS: 러스트 - error handling

dante Yoon·2022년 12월 27일
0

beyond js

목록 보기
9/20
post-thumbnail

글을 시작하며

에러 없는 프로그램을 만드는 것은 논리적으로 불가능합니다.
에러가 없는 프로그램은 없습니다.

에러가 적은 프로그램을 만드는 것은 가능하며 에러에 유연한 프로그래밍을 하는 것은 가능합니다.
중요한 점은 에러를 어떻게 다루느냐입니다.

오늘은 러스트의 에러 핸들링에 대해 알아보겠습니다.

러스트는 에러를 크게 두 가지로 나눕니다. recoverable - 복구 가능한 에러와 unrecoverable errors 복구 불가능한 에러 입니다.

대부분의 프로그래밍 언어는 두 가지를 구분하지 않고 모두 동일한 에러로 뭉뚱그려서 처리합니다(exception과 같은). 러스트에는 exception이 없습니다. 대신 Result<T,E> 타입을 통해 recoverable한 에러를 표현하며 panic! 매크로를 사용해 실행을 중지하고 복구 불가능한 에러를 표현합니다.

단테와 함께하면 에러도 문제없습니다!

Unrecoverable error

러스트에서 unrecoverable error은 일반적으로 pnaic을 의미합니다. 패닉은 프로그램의 비정상적인 종료를 의미하며 assertion failure 혹은 프로그램을 더 이상 진행하기 어려운 상황에서 발생합니다.

패닉이 발생하면 러스트 런타임은 현재의 실행을 중단하고 사용 중인 객체들의 destructor들을 실행하여 자원을 해제하고 스택을 해제합니다. 만약 패닉을 중간에 처리해주지 않으면, 전체 프로그램으로 패닉이 전파되고 종국에는 패닉에 의해 프로그램이 종료됩니다.

러스트에서 패닉은 프로그래밍 에러에 의해 발생합니다. 배열의 indexing을 잘못 참조하는 out of boundary와 같은 에러나 null reference를 사용한 메소드 호출등 (다른 언어의 null point dereference)에 의해 발생합니다. 때로는 프로그래머의 의도에 따라 panic! 매크로에 의해 의도적으로 발생되기도 합니다.

좀더 자세히 알아봅시다.

패닉이 발생하면

러스트 런타임은 패닉이 발생할 당시의 콜 스택에 대한 backtrace 메시지를 콘솔에 출력합니다. 이를 패닉 메시지라고 합니다. 이 패닉 메시지는 디버깅할 때 유용하게 사용됩니다.

패닉 또한 reverable 하게 다룰 수 있습니다.

std::panic::catch_unwind를 사용한다거나 closure의 catch_unwind를 사용한다면 패닉으로 부터 프로그램을 복구시킬 수 있습니다. 이 메소드들의 활용은 패닉으로부터 프로그램을 중단하지 않고 계속 진행할 수 있는 가능성을 제시하나 항상 가능하거나 프로그램이 예상치 못한 상태에 빠질 수 있으니 주의해서 사용해야 합니다.

use std::panic;

fn divide(x: i32, y: i32) -> i32 {
    if y == 0 {
        panic!("division by zero");
    }
    x / y
}

fn main() {
    let result = panic::catch_unwind(|| divide(10, 0));
    match result {
        Ok(val) => println!("Result: {}", val),
        Err(err) => println!("Error: {}", err),
    }
}

위의 예제에서 x를 y로 나눌 때 분모가 0일 경우 divide 함수에서 패닉이 발생합니다.
메인 함수의 catch_unwind 함수는 패닉을 처리하고 Result 타입을 반환함으로 정상일 때와 패닉이 발생했을때 모두를 처리할 수 있습니다. 만약 divide 함수가 패닉을 발생시킨다면 match statementErr 브랜치가 사용되며 그렇지 않을 경우 Ok 브랜치가 실행됩니다.

use std::panic;

#[panic::catch_unwind]
fn run_division(x: i32, y: i32) -> Result<i32, &'static str> {
    if y == 0 {
        panic!("division by zero");
    }
    Ok(x / y)
}

fn main() {
    let result = run_division(10, 0);
    match result {
        Ok(val) => println!("Result: {}", val),
        Err(err) => println!("Error: {}", err),
    }
}

위의 예제에서 run_division 함수는 catch_unwind attribute를 사용하고 있습니다.
이 attribute를 사용함으로 인해 함수 내부에서 패닉이 발생할 때 Result 타입을 반환하게 할 수 있습니다.

run_division 함수는 이전 예제에서의 동작과 완전히 동일한 기능을 하지만 패닉을 반환하는 것이 아닌 Result 타입을 반환하는 것이 그 차이점입니다. 이것은 에러 핸들링을 좀 더 우아하게 할 수 있는 방법이며 프로그램 전체로 에러가 전달되게 하지 않게 합니다.

갑자기 클로저가 왜 나오죠?

러스트의 클로저는 자바스크립트의 클로저와 비숫하게 해당 함수가 선언된 주변의 스코프의 변수에 접근할 수 있습니다.

러스트에서 클로저는 함수와 유사한 객체로 변수 내부에 저장되거나 함수의 인자로 전달되거나 함수에서 값으로 반환될 수 있습니다. 이거 뭔가 어디서 본거 같은데요, 그렇습니다. 일급 객체입니다.

클로저는 || 문법을 사용해 정의할 수 있으며 선택적으로 argument를 받아들이거나 값을 반환할 수 있습니다.

let closure = || {
    println!("This is a closure");
};

위 클로저는 어떤 것도 반환하지 않습니다.

let closure = |x: i32| -> i32 {
    x + 1
};

위 클로저는 i32 타입을 받아들이고 i32 타입을 반환합니다.

위 두 예제에서 클로저는 모두 closure라고 하는 변수에 저장됩니다. 이 변수는 함수를 호출하는 형식과 동일하게 호출할 수 있습니다.

closure(); // prints "This is a closure"
let y = closure(10); // y is 11

러스트의 클로저는 주변 스코프의 변수나 클로저 body 의 변수를 포착할 수 있어 유용합니다

let x = 10;
let closure = |y: i32| -> i32 {
    x + y
};

다시 Panic으로 돌아와서

기본적으로 패닉이 발생하면 프로그램이 unwinding 이라는 것을 시작합니다. 러스트가 스택을 거슬러올라가 마주하는 함수로 부터 데이터들을 정리하는 것입니다. 하지만 이러한 작업은 러스트에게 있어 많은 이을 하게 합니다. 따라서 이에 대한 대안으로 즉각적으로 프로그램을 종료하는 방법을 취할 수 있습니다.

이후에 운영체제에 의해 프로그램이 사용했던 메모리는 정리됩니다. 만약 실제 배포할 떄도 이러한 panic을 의도적으로 유지하고 싶다면 Cargo.toml 파일에 이렇게 명시합니다.

[profile.release]
panic = 'abort'

panic!

앞서 봤던 예제들에 나와있었지만 패닉을 유발시키기 위해 다음처럼 할 수 있습니다.

fn main() {
  panic!("crash"); 
}

back trace

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

c언어에서는 위와 같이 범위 안에 없는 메모리를 참조할 수 있습니다. 그리고 이는 예상치 못한 보안적 결함을 유발합니다. c언어를 배우신 분들은 프로그램을 작성하다가 예상치 못한 꺠진 문자열이 콘솔 창에 출력되는 것을 꽤 자주 경험해보셨을 것입니다.

러스트에서는 out of boundary reading을 허용하지 않고 이러한 상황이 벌어질 때 러스트는 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

에러가 출력된 것을 보면 main.rs 의 4번째 줄을 가르키고 있습니다. 그리고 이는 index99 번째를 참조하려다가 발생한 에러라는 정보를 알려줍니다.

backtrace는 해당 지점까지 갈때까지 호출된 모든 함수들의 호출 기록을 보여주는 것입니다. 이는 자바스크립트나 다른 언어에서 break point를 찍고 디버깅을 하는 것과 유사한데요, RUST_BACKTRACE 세팅을 통해 이러한 디버깅을 진행할 수 있습니다.

$ 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/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
   2: core::panicking::panic_bounds_check
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
   6: panic::main
             at ./src/main.rs:4:5
   7: core::ops::function::FnOnce::call_once
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Recoverable Errors with Result

대부분의 에러는 프로그램을 멈추게 하지만 가끔 함수 실행이 중단했을 때에 의도적으로 특정 행동을 취해야 하는 상황이 있습니다. 자바스크립트의 try catch와 같은 상황이 바로 그런 상황입니다.

(파일을 읽으려고 할 때 해당 파일이 존재하지 않는다고 해서 프로그램을 종료해버리는 것이 아니라 없는 파일을 해당 경로에 생성하고 싶을 수 있습니다.)

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

위의 Result는 두 variants Ok, Err 타입을 가지고 있습니다. TE는 두 제너릭 타입입니다. T는 성공했을 때의 타입, E는 에러 상황일 떄의 타입입니다.

use std::fs::File;

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

File::openResult<T,E> 타입입니다. 제너릭 T 타입은 File::open이 성공했을 때의 std::fs::File 타입이며, E는 std::io::Error 타입입니다.

리눅스 환경에서 흔히 마주하는 상황이 파일을 생성할 권한이 없는 사용자가 특정 스크립트를 실행해 파일을 생성하거나 읽으려고 하는 경우인데요, 이렇게 권한 문제가 있을 때에도 Err 타입이 반환될 수 있습니다.

File::open이 성공했을 때 variable greeting_file_result의 value는 Ok 인스턴스가 될 것이며 해당 인스턴스 내부에는 file handle이, 실패했을 때는 greeting_file_result에 Err 인스턴스가 발생한 에러 정보와 함께 들어가 있을 것입니다.

use std::fs:File;

fn main() {
  let greeeting_file = match greeting_file_result {
    Ok(file) => file,
    Err(error) => panic!("Problem opening the file: {:?}", error),
  }
}

파일 읽기가 실패했다면 다음처럼 panic! 매크로에 의해 에러 발생 정보가 콘솔에 출력됩니다.

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

여러 가지 에러 정보 다루기

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

fn main() {
  let greeting_file_result = File::open("hello.txt");
  
  let greeting_file = match greeting_file_result {
    Ok(file) => file,
    Err(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);
      }
    }
  }
}

파일이 존재하지 않을 때 생성하기 위하여 에러 발생 상황에 좀 더 세부적인 패턴 매칭을 수행했습니다.

File::open은 에러 상황에 io:Error를 Err variant에 반환합니다. 이 struct는 kind라는 메소드를 가지고 있고 이 메소드는 io:ErrorKind 값을 반환합니다.

io:ErrorKind는 io operation에 의해 다양한 종류의 에러를 표현할 수 있습니다. 파일이 없을 때 생성하고 싶기에 패턴 매칭을 사용한 것이기에 ErrorKind:NotFound variant인 경우 파일을 생성합니다.

꼭 match를 사용해야 하는 것은 아닙니다.

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

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

shortcut

match 를 사용하지 않고 Result<T,E> 타입에서 제공하는 헬퍼 메소드를 사용할 수 있습니다.

unwrap

use std::fs::File;

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

hello.txt 파일이 없는 상태에서 위 코드를 실행시켰습니다. Result가 Err Variant면 unwrap이 panic! 매크로를 호출합니다.

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

expect

expect 또한 unwrap과 동일하게 Result에서 제공하는 헬퍼 메소드로 원하는 에러 메세지를 전달할 수 있게 해줍니다. 좋은 에러 메세지를 전달하는 것은 소스코드에서 추적하는 패닉을 좀 더 쉽게 분석할 수 있게 해줍니다.

use std::fs::File;

fn main() {
  let greeting_file = File::open("hello.txt");
  	.expect("hello.txt should be included in this project");
}

에러 전파

함수 내부에서 에러가 발생할 때 함수에서 자체적으로 이 에러를 처리하지 않는다면 함수 호출부로 에러를 전달해 해당 에러를 처리할 수 있게 할 수 있습니다.

에러 처리의 책임을 호출부로 전가 하는 것입니다.

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io:Error> {
  let username_file_result = File::open("hello.txt");
  
  let mut username_file = match username_file_result {
    Ok(file) => file, 
    Error(e) => return Err(e),
  };
  
  let mut username = String::new();
  
  match username_file.read_to_string(&mut username) {
    Ok(_) => Ok(username),
    Err(e) => Err(e),
  }
}

쪼개서 보겠습니다.

let user_name_file_reseult = File::open("hello.txt");
존재하지 않는 파일을 읽으려고 시도합니다.

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

파일을 읽는데 있어 에러가 발생하면 io:Error 인스턴스를 값으로 갖는 Err value를 호출부로 반환하고 함수를 종료 합니다.

let mut username = String::new();

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

윗줄에서 에러가 발생하지 않으면 새로운 String을 변수 username에 담고 read_to_string 메소드를 호출하고 내용을 username에 담으려고 합니다. read_to_string은 실패할 수 있는 메소드이기에 실패한다면 함수 호출부로 Err를 반환합니다. 이때 return 키워드가 없는 이유는 해당 구문이 함수 바디에 마지막 expression이기 때문입니다.

read_username_from_file함수를 호출하는 곳에서는 Ok, Err 둘 중 하나의 값을 받을 것입니다.
Err를 받으면 panic!을 호출할 것입니다. 호출부의 상황을 좀 더 자세히 알기 위해서는 이렇게 에러를 호출부로 전파하는 것이 더 좋을 수도 있습니다.

에러를 간단히 전파하는 ? operator

use std::fs:File;
use std::io::{self, Read};

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

Result의 값이 Ok라면 Ok 내부에 있는 값이 해당 expression으로 부터 반환되게 될 것이며, 프로그램은 계속 진행됩니다. 만약 값이 Err이라면 Err가 반환되며 이것은 마치 return 키워드를 이용해서 호출부로 에러를 전파하는 것과 동일한 역할을 합니다.

?match와 다른 점이 있습니다.
? 를 호출하는 error 값은 standard library에 정의되어있는 From trait의 from 함수를 거칩니다. from 함수는 한 타입의 값을 다른 타입의 값으로 변환하는데 사용됩니다. from 함수에게 전달된 에러 타입은 현재 함수의 리턴 타입으로 정의된 에러 타입으로 변환됩니다. 이것은 함수 바디에 선언된 여러 부분에서 각기 다른 에러가 발생하더라도 에러 전파시 하나의 오류 유형을 반환할 수 있습니다.

use std::fs::File;
use std::io::{self, Read};

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

아까 봤던 코드를 다시 살펴보면, File::open 뒤에 있는 ? operator는 Ok 내부에 있는 값을 username_file 변수에 반환합니다. 만약 에러가 발생하면 ? operator는 전체 함수 진행을 중단하고 Err 값을 중간에 호출부로 반환합니다.

? 연산자는 간단하게 함수 구현을 할 때 유용하게 사용될 수 있습니다.
앞서 봤던 코드를 ? 연산자를 활용해 좀 더 간단하게 바꾸어 봅시다.

use std::fs::File;
use std::io::{self, Read};

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

? 연산자를 chaining 하여 에러 발생 가능성이 있는 곳의 에러 전파를 한줄의 코드로 해결했습니다.

좀 더 간단하게 만들 수 있다면 어떨까요?

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

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

fs::read_to_string은 앞서 작성했던 파일을 열고 읽는 기능을 제공하는 메소드입니다.
새로운 String을 만들고, 파일을 읽고, String에 해당 콘텐츠를 넣고 반환합니다.

?는 어디서 사용할 수 있을까

? 연산자는 함수 반환 타입이 ?가 쓰인 곳과 호환되는 함수 내부에서 사용될 수 있습니다. ? 연산자가 early return 패턴을 제공하기 때문에 이 함수가 early return을 하며 반환하는 타입은 Err 타입 아니면 Ok 타입입니다. 따라서 Result 타입을 사용해야 합니다.

use std::fs::File;

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

위 메인 함수는 컴파일 에러가 발생합니다. 그 이유는 메인 함수의 리턴 타입이 Result 타입이 아니기 때문입니다.

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | / fn main() {
4 | |     let greeting_file = File::open("hello.txt")?;
  | |                                                ^ cannot use the `?` operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error

메인 함수는 다행히도 Result<(), E> 타입을 반환할 수 있습니다.

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

fn main() -> result<(), Box<dyn Error>> {
  let greeting_file = File::open("hello.txt")?;
  
  Ok(())
}

Ok value 내부에는 unit type이 들어있죠?

Box<dyn Error> 타입은 trait object 라고 하는 것인데 본 강의의 후반부에서 다룰 예정입니다.

현재로서는 Box<dyn Error> 은 여러가지 모든 에러를 표현하는 타입이라고 이해하면 됩니다.

현재 메인 함수의 반환 타입이 모든 에러를 표현하는 타입이기 때문에 early return을 하는 ? 연산자를 사용하는 것이 허용됩니다.

위의 메인 함수처럼 함수처럼 Result<(),E> 타입을 반환할 떄 Ok(())를 반환할 때는 값 0과 함께 프로그램이 종료되고 Err value를 반환할 때는 0이 아닌 값과 함께 프로그램이 종료됩니다.
프로그램이 정상적으로 종료되면 0, 아니면 0이 아닌 값이 반환되는 것입니다.

글을 마치며

오늘은 러스트의 에러 핸들링에 대해 알아보았습니다.
수고 많으셨습니다 :)

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글