Rust - 에러 처리 (Error handling)

Chan Heo·2024년 11월 11일

Rust 공부

목록 보기
6/7
post-thumbnail

“소프트웨어에서 에러는 삶의 일부이므로, 러스트는 뭔가 잘못되는 상황을 처리하기 위한 기능을 몇 가지 갖추고 있습니다.” 라는 Rust 교재의 말처럼, 개발을 하는 과정에서 발생하는 수많은 에러를 우리는 해결해야 합니다. Rust의 에러 처리에 대해 공부하고, 정리해 보겠습니다.

대부분의 경우 러스트에서는 코드가 컴파일 되기 전에 에러의 가능성을 인지하고 조치를 취해야 합니다. 이러한 요구사항은 여러분의 코드를 프로덕션 환경에 배포하기 전에 에러를 발견하고 적절히 조치할 것을 보장하여 여러분의 프로그램을 더 견고하게 해 줍니다!

러스트는 에러를 복구 가능한 (recoverable) 에러와 복구 불가능한 (unrecoverable) 에러 두 가지 범주로 묶습니다. 

  • 복구 가능한 에러: Result<T, E> 타입
  • 복구 불가능한 에러: panic! 매크로 (에러가 발생했을 때 프로그램을 종료함)

panic!으로 복구 불가능한 에러 처리하기

가끔은 코드에서 나쁜 일이 일어나고, 이에 대해 여러분이 할 수 있는 것이 없을 수도 있습니다. 이런 경우를 위해 러스트에는 panic! 매크로가 있습니다. 

실제로 패닉을 일으키는 두 가지 방법이 있습니다.

  1. 코드가 패닉을 일으킬 동작을 하는 것
  2. panic! 매크로를 명시적으로 호출하는 것

패닉이 일어나면 ..

  • 실패 메시지를 출력하고
  • 되감고 (unwind)
  • 스택을 청소하고 (+ backtrace information을 만들 수도 있다.)
  • 종료합니다.

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

    v[99];
}

벡터의 유효한 범위를 넘어선 인덱스로 접근을 시도하는 예시 코드를 통해 에러를 발생시켜 보겠습니다.

$ 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
  • 첫번째 줄은 99 인덱스로 접근을 시도한 main.rs 4번째 줄을 가리키고 있습니다.
  • 그다음 줄은 RUST_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/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.
  • 백트레이스 (backtrace) 란 어떤 지점에 도달하기까지 호출한 모든 함수의 목록을 말합니다.
  • 백트레이스를 읽는 요령은 위에서부터 시작하여 여러분이 작성한 파일이 보일 때까지 읽는 것입니다. 그곳이 바로 문제를 일으킨 지점입니다.
  • 여러분의 파일이 나타난 줄보다 위에 있는 줄은 여러분의 코드가 호출한 코드이고, 아래의 코드는 여러분의 코드를 호출한 코드입니다. → panic이 발생한 지점을 전후로 더 정확한 실행 기록을 추적해볼 수 있습니다.
  • 이 전후의 줄에는 핵심 러스트 코드, 표준 라이브러리, 여러분이 이용하고 있는 크레이트가 포함될 수 있습니다.

Result로 복구 가능한 에러 처리하기

대부분 에러는 프로그램을 전부 중단해야 할 정도로 심각하진 않습니다.

때때로 어떤 함수가 실패할 경우는 쉽게 해석하고 대응할 수 있는 원인 때문입니다. 예를 들어 어떤 파일을 열려고 했는데 해당 파일이 존재하지 않아서 실패했다면, 프로세스를 종료해 버리는 대신 파일을 생성하는 것을 원할지도 모르죠.

enum Result<T, E> {
    Ok(T),
    Err(E),
}
  • Result 열거형은 OkErr라는 두 개의 배리언트를 갖고 있습니다.
  • TE는 제네릭 타입 매개변수입니다. (교재의 10장에서 자세히 다루게 되는 내용)
    T는 성공한 경우 Ok 배리언트 안에 반환될 값의 타입을 나타냅니다.
    E는 실패한 경우 Err 배리언트 안에 반환될 에러의 타입을 나타냅니다.
use std::fs::File;

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

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

→ 예제: File::open 반환 값에 따라 다르게 작동하는 코드

  • File::open의 반환 타입은 Result<T, E>입니다.
  • File::open이 성공한 경우에는 greeting_file_result 변수의 값이 파일 핸들을 가지고 있는 Ok 인스턴스가 될 것입니다.
  • 실패한 경우 greeting_file_result는 발생한 에러의 종류에 관한 더 자세한 정보가 담긴 Err 인스턴스가 될 것입니다.
  • Option 열거형과 같이 Result 열거형과 배리언트들은 프렐루드로부터 가져와진다는 점을 주의하세요. 따라서 match 갈래의 Ok와 Err 앞에 Result::라고 지정하지 않아도 됩니다.

에러 발생 시 패닉을 위한 숏컷: unwrapexpect

  • unwrap 메서드는 예제 9-4에서 작성한 match 구문과 비슷한 구현을 한 숏컷 메서드입니다.
    • 만일 Result 값이 Ok 배리언트라면, unwrap은 Ok 내의 값을 반환할 것입니다.
    • 만일 Result가 Err 배리언트라면 unwrap은 panic! 매크로를 호출해줄 것입니다.
    • 아래에 unwrap이 동작하는 예가 있습니다:
        use std::fs::File;
        
        fn main() {
            let greeting_file = File::open("hello.txt").unwrap();
        }
  • 이와 비슷한 expect는 panic! 에러 메시지도 선택할 수 있도록 해 줍니다.
    • unwrap 대신 expect를 이용하고 좋은 에러 메시지를 제공하면 여러분의 의도를 전달하면서 패닉의 근원을 추적하는 걸 쉽게 해줍니다.
    • expect의 문법은 아래와 같이 생겼습니다:
	use std::fs::File;

	fn main() {
    	let greeting_file = File::open("hello.txt")
        	.expect("hello.txt should be included in this project");
	}
  • 프로덕션급 품질의 코드에서 대부분의 러스타시안은 unwrap보다 expect를 선택하여 해당 연산이 항시 성공한다고 기대하는 이유에 대한 더 많은 맥락을 제공합니다. 이렇게 하면 가정이 틀렸다는 것이 입증될 경우 디버깅에 사용할 더 많은 정보를 확보할 수 있습니다. (추후 러스트로 개발할 때 이 내용을 참고하면 좋을 것 같습니다!)

에러 전파하기 (propagating)

함수의 구현체에서 실패할 수도 있는 무언가를 호출할 때, 이 함수에서 에러를 처리하는 대신 이 함수를 호출하는 코드 쪽으로 에러를 반환하여 그쪽에서 수행할 작업을 결정하도록 할 수 있습니다.

이를 에러 전파하기 (propagating) 라고 하며 호출하는 코드 쪽에 더 많은 제어권을 주는 것인데, 호출하는 코드 쪽에는 에러를 어떻게 처리해야 하는지 결정하는 정보와 로직이 여러분의 코드 컨텍스트 내에서 활용할 수 있는 것보다 더 많이 있을 수도 있기 때문입니다.

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,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),  // 이 함수의 마지막 표현식이기 때문에 명시적으로 return이라고 적을 필요는 없습니다.
    }
}

→ 파일로부터 사용자 이름을 읽는 함수를 작성한 것입니다. 만일 파일이 존재하지 않거나 읽을 수 없다면, 이 함수는 호출하는 코드 쪽으로 해당 에러를 반환할 것입니다.

  • 반환 타입: Result<String, io::Error> → 제네릭 매개변수인 <T, E>가 모두 concrete type으로 채워져 있습니다.
  • 만일 이 함수가 문제없이 성공하면, 함수를 호출한 코드는 String(이 함수가 파일로부터 읽어 들인 사용자 이름이겠지요)을 담은 Ok 값을 받을 것입니다.
  • 만일 어떤 문제가 발생한다면, 이 함수를 호출한 코드는 문제가 뭐였는지에 대한 더 많은 정보를 담고 있는 io::Error의 인스턴스를 담은 Err 값을 받을 것입니다.
  • 이 코드를 호출하는 코드는 사용자 이름이 있는 Ok 값 혹은 io::Error를 담은 Err 값을 처리하게 될 것입니다. 이 값을 가지고 어떤 일을 할지에 대한 결정은 호출하는 코드 쪽에 달려 있습니다.
    (만일 그쪽에서 Err 값을 얻었다면, 이를테면 panic!을 호출하여 프로그램을 종료시키는 선택을 할 수도 있고, 기본 사용자 이름을 사용할 수도 있으며, 혹은 파일이 아닌 다른 어딘가에서 사용자 이름을 찾을 수도 있습니다.)

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

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 안의 값이 얻어지고 프로그램이 계속됩니다.
  • 만일 값이 Err라면, return 키워드로 에러 값을 호출하는 코드에게 전파하는 것처럼 Err의 값이 반환될 것입니다.
  • ?는 ?이 사용된 값과 호환 가능한 반환 타입을 가진 함수에서만 사용될 수 있습니다.
    함수의 반환 타입이 Result여야 이 return과 호환 가능합니다.

읽어주셔서 감사합니다 🙇‍♂️

References

https://doc.rust-kr.org/ch09-01-unrecoverable-errors-with-panic.html

https://doc.rust-kr.org/ch09-02-recoverable-errors-with-result.html

profile
안녕하세요:)

0개의 댓글