에러 처리 (Error Handling)

이 포스트는 Rust를 처음 공부하면서 정리한 내용입니다. 해당 포스트는 개인적으로 기억하기 위한 메모 성격에 가깝습니다. “러스트 프로그래밍 공식 가이드 (2018, 제이펍)” 서적을 참고하였으며, 러스트 공식 사이트 에서 책과 동일한 내용을 찾을 수 있습니다. 따라서 더 자세한 내용을 찾으시면 위의 링크를 참고하시면 좋습니다.

다른 프로그래밍 언어에서는 에러 처리라는 용어 보다는 예외(exception) 처리라는 용어를 더 많이 사용합니다. 예외라는 매커니즘을 통해 프로그램의 오류를 방지하도록 합니다. C++의 std::exception 또는 파이썬의 Exception은 흔히 볼 수 있는 예외 처리 메커니즘입니다. 그러나 Rust에서는 예외라는 개념은 존재하지 않습니다. 대신 에러를 아래와 같이 두 가지로 표현하고 있습니다.

  • 회복 가능한 에러: Result<T, E> 타입
  • 회복 불가능한 에러: panic! 매크로를 통해 프로그램을 종료

Rust에서는 에러를 두 가지로 나눕니다. 크게 회복 가능한(recoverable) 에러와 회복 불가능한(unrecoverable) 에러의 개념이 있습니다. 회복 가능한 에러는 존재하지 않는 파일 등의 예시가 될 수 있습니다. 즉, 사용자에게 문제를 보고하고 작업을 다시 시작하도록 요청할 수 있습니다.

반면, 회북 불가능한 에러의 경우 항상 버그의 가능성을 갖고 있는데, 존재하지 않은 배열의 인덱스에 접근하는 것이 대표적인 예시 입니다.

회복 불가능한 에러: panic!

우선 회복 불가능한 에러가 발생했을 때 프로그램을 종료시키는 panic! 매크로 부터 살펴보겠습니다.

fn main() {
    println!("Hello, world!");

    // 패닉 매크로
    panic!("Panic!");
}

scs1

그려면 위와 같이 마지막 두 줄에서 에러 메세지가 출력되는 것을 볼 수 있습니다.

panic! 역추적 사용하기

C++ 혹은 다른 프로그래밍 언어를 사용하다 보면 내가 발생시킨 오류가 아닌 다른 메세지를 띄워 주는 경우가 많습니다. 표준 라이브러리를 사용하다가 흔히 볼 수 있지요. Rust에서도 다른 곳에서 호출되는 에러 메세지를 만날 수 있습니다.

아래는 기본적인 버퍼 오버리드(Buffer Overread)의 예제입니다. 당연하게도 세 개의 값만 저장되어 있는 벡터에서 100번째 인덱스를 읽으려고 하니 오류가 발생 할 겁니다.

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

    let a = vector[99];
}

이때 RUST_BACKTRACE=1의 환경 변수를 설정해 주고 cargo run을 실행하면 아래와 같이 어느 시점에서 에러가 발생했는지 역추적 할 수 있습니다. 이러한 출력 정보를 보기 위해서는 디버그 심볼(debut symbol)이 활성화 되어 있어야 합니다. cargo buildcargo run에서 아무런 옵션을 주지 않았다면 자동적으로 활성화 되어 있으며, --release 옵션을 주면 제외됩니다.

scs1

회복 가능한 에러: Result<T, E>

앞서 보았던 Option<T>와 같이 Result<T, E> 또한 열거자로 정의되어 있습니다.

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

Result 또한 Option과 같이 std::prelude에 의해 자동으로 포함되기 때문에 OkError 앞에 Result::를 명시할 필요는 없습니다.

T와 E는 제네릭 타입의 매개변수로, 작업이 성공하면 Ok 열것값은 T 타입의 내용을 포함할 것이고 작업이 실패하는 경우에는 Error 열것값은 E 타입의 내용을 포함하게 될 것임을 추측할 수 있습니다.

이러한 Result<T, E>의 값을 확인하기 위해서는 공식 문서를 확인하거나 직접 컴파일하여 확인해도 됩니다. 예를 들어 파일을 여는 함수인 File::open 함수가 Result 타입을 리턴하는지 확인하려면 아래와 같은 코드를 돌려보면 됩니다.

fn main() {
    let a: u32 = File::open("Text.txt");
}

scs1

코드로는 u32 타입을 명시했으나 반환되는 값은 Result<File, std::io::Error> 라고 오류를 뿜어대고 있습니다. 여기에서 T의 타입은 File, E의 타입은 std::io::Error가 됨을 확인할 수 있습니다. 이와 함께 파일을 성공적으로 열었다면 aResult의 Ok(File) 열것값이 될 것이고, 실패한 경우에는 Error(std::io::Error)의 열것값을 가질 것입니다.

그렇다면 위의 Result 에러는 아래의 match 표현식을 사용하여 처리가 가능합니다.

코드로 확인 해 보겠습니다.

use std::fs::File;

fn main() {
    let file = File::open("Text.txt");

    let file = match file {
        Ok(file) => file,
        Err(error) => {
            panic!("Failed: {:?}", error);
        }
    };
}

scs1

match를 이용한 중첩 에러 처리

위의 코드는 하나의 match 표현식으로 File::open 에서의 에러를 처리하고 있습니다. 그러나 실패 원인에 따라 다르게 동작하도록 아래와 같이 수정이 가능합니다. 이를 처리하기 위해서라면 중첩된 match 표현식을 이용하면 됩니다.

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

fn main() {
    let f = File::open("Text.txt");
    let f = match f { // 첫 번째 에러
        Ok(file) => file,
        Err(ref error) => match error.kind() { // 중첩된 에러 처리
            ErrorKind::NotFound => match File::create("Text.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Could not create file")
            },
            other_error => panic!("Could not open file! {:?}", other_error)
        },
    };
}

실행 전의 디렉토리 구조는 아래와 같습니다.

scs1

Text.txt 파일이 존재하지 않으므로 File::create 함수를 이용하여 파일을 생성하도록 했습니다. 아래는 실행 결과입니다.

scs1

더 간단한 방법: unwrap과 expect

위의 예시에서는 match를 이용하기 때문에 코드가 길어지는 단점이 있습니다. 따라서 Result<T, E> 타입은 다양한 편의 메서드를 제공하는데 그 중에 소개할 것들이 unwrapexpect 입니다. unwrapexpect는 위에서 작성한 match 구문과 정확히 같은 동작을 합니다.

  • unwrap() : Result<T, E>의 반환값이 Ok(T) 라면 Ok 열것값에 저장된 값을 리턴하며, Err(E)라면 panic! 매크로를 호출합니다. 그러나 사용자 에러 메세지는 보여주지 않습니다.
  • expect() : 동작은 unwrap과 같으나 사용자 에러 메세지를 포함합니다.
use std::fs::File;

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

scs1

use std::fs::File;

fn main() {
    let f = File::open("Text.txt").expect("User's error msg!");
}

scs1

Error 전파 (Propagating Errors): 명시적 return

에러가 발생했을 때 함수 내에서 처리해도 되지만 호출부로 책임을 떠넘길 수 있습니다.

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), // 여기서 명시적 Err(e) 리턴!!
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    } // 마지막은 표현식!! (리턴)
}

함수 read_username_from_fileResult<String, io::Error> 타입을 리턴하고 있습니다. 이러한 패턴은 흔하기 때문에 Rust에서는 ? 연산자를 제공하여 더 쉽게 처리하도록 도와줍니다.

Error 전파 (Propagating Errors): ? 연산자

위의 코드를 ? 연산자를 이용하여 재작성하면 아래와 같이 됩니다.

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")?; // 여기서 Result 타입 처리
    let mut s = String::new();
    f.read_to_string(&mut s)?; // 여기서 Result 타입 처리
    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)
}

Result 뒤의 ? 연산자는 위에서의 match 표현식과 거의 동일한 방식으로 동작합니다. 아래는 ? 연산자를 사용하는 경우 match 표현식과의 차이점을 정리하고 있습니다.

  1. ? 연산자는 에러값을 from 함수를 이용하여 전달합니다. 이 함수는 표준 라이브러리에 정의된 From 트레이터에 선언되어 있습니다. from 함수가 호출되면 전달된 에러 타입은 현재 함수의 리턴 타입에 정의된 에러 타입으로 변환됩니다.
  2. ? 연산자는 무조건 Result 타입을 리턴하는 함수에서만 사용 가능합니다.

참고로, main 함수는 조금 특별한 함수입니다. main 함수는 두 가지의 타입을 리턴할 수 있는데, () 타입과 Result<T, E> 타입입니다.

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

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

태그:

카테고리:

업데이트: