프로그램 언어들은 예외 핸들링(exception handling) 또는 반환 값(return value) 이라는 두 가지 에러 핸들링 접근 방식 중 한 가지를 사용한다. Rust는 후자를 사용한다.1 이전 글 Rust 예외 및 에러 처리에서 복구 가능한 에러를 위한 Result<T, E> 사용법을 살펴봤다.

Rust의 Result는 한가지 에러 타입만 처리가 기본적으로 가능하다. 두 가지 이상의 다른 에러 타입은 처리가 불가능할 때 사용할 수 있는 Custom Error Handling 방법을 이번 글에서 살펴본다. 에러처리 간소화를 위한 thiserror, anyhow 크레이트 예제를 소개하였다.

에러처리 실패 사례
use std::fs::File;
use std::io::Write;
use std::num::ParseIntError;

fn main() {
    println!("{:?}", square("2"));
    println!("{:?}", square("invalid"));
}

fn square(val: &str) -> Result<i32, ParseIntError> {
    let num = val.parse::<i32>()?;
    let mut f = File::open("file.txt")?;
    let string_to_write = format!("Square of {} is {}", num, i32::pow(num, 2));
    f.write(string_to_write.as_bytes())?;
    Ok(i32::pow(num, 2))
}

/*
the trait `From<std::io::Error>` is not implemented for `ParseIntError`
ParseIntError, Error : 에러 타입이 두 가지지만 하나의 에러 타입만 지정
*/
실패 사례 개선
use std::error::Error;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::fs::File;
use std::io::Write;

#[derive(Debug)]
enum MyError {
    ParseError,
    IOError,
}

impl Display for MyError {
    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
        match self {
            MyError::ParseError => write!(f, "Parse Error"),
            MyError::IOError => write!(f, "IO Error"),
        }
    }
}

impl Error for MyError {}

fn main() {
    let result1 = square("2");
    let result2 = square("invalid");

    match result1 {
        Ok(res) => println!("Result1 is {:?}", res),
        Err(e) => println!("Error1 is {:?}", e),
    }

    match result2 {
        Ok(res) => println!("Result2 is {:?}", res),
        Err(e) => println!("Error2 is {:?}", e),
    }
}

fn square(val: &str) -> Result<i32, MyError> {
    let num = val.parse::<i32>().map_err(|_| MyError::ParseError)?;
    let mut f = File::open("file.txt").map_err(|_| MyError::IOError)?;
    let string_to_write = format!("Square of {} is {}", num, i32::pow(num, 2));
    f.write(string_to_write.as_bytes()).map_err(|_| MyError::IOError)?;
    Ok(i32::pow(num, 2))
}

/*
Error1 is IOError
Error2 is ParseError
*/
use std::fmt;
use std::error::Error;

#[derive(Debug)]
struct MyError {
    message: String,
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "MyError: {}", self.message)
    }
}

impl Error for MyError {}

fn fallible_function() -> Result<(), MyError> {
    Err(MyError { message: "Something went wrong".to_string() })
}

fn main() {
    match fallible_function() {
        Ok(_) => println!("Success!"),
        Err(e) => println!("Error: {}", e),
    }
}

//Error: MyError: Something went wrong
thiserror 기본 사용법 1
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("Invalid argument: {0}")]
    InvalidArgument(String),
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    ParseError(#[from] std::num::ParseIntError),
    #[error("Custom error message")]
    CustomError,
}

fn fallible_function() -> Result<(), MyError> {
    Err(MyError::InvalidArgument("Invalid input".to_string()))
}

fn main() {
    match fallible_function() {
        Ok(_) => println!("Success!"),
        Err(e) => println!("Error: {}", e),
    }
}

// Error: Invalid argument: Invalid input
thiserror 기본 사용법 2
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("Failed to open file: {0}")]
    OpenFileError(String),
    #[error("Failed to read file: {0}")]
    ReadFileError(String),
    #[error("Invalid number format: {0}")]
    ParseIntError(#[from] std::num::ParseIntError),
    #[error("Division by zero")]
    DivisionByZero,
}

fn read_number_from_file(path: &str) -> Result<i32, MyError> {
    let file_content = std::fs::read_to_string(path).map_err(|_| MyError::OpenFileError(path.to_string()))?;
    let number = file_content.trim().parse::<i32>().map_err(MyError::from)?;
    Ok(number)
}

fn divide(x: i32, y: i32) -> Result<i32, MyError> {
    if y == 0 {
        return Err(MyError::DivisionByZero);
    }
    Ok(x / y)
}

fn main() {
    // 성공 케이스
    match read_number_from_file("number.txt") {
        Ok(number) => println!("File read successfully. Number: {}", number),
        Err(e) => eprintln!("Error reading file: {}", e),
    }

    match divide(10, 2) {
        Ok(result) => println!("Division successful. Result: {}", result),
        Err(e) => eprintln!("Error during division: {}", e),
    }

    match read_number_from_file("non_existent_file.txt") {
        Ok(number) => println!("File read successfully. Number: {}", number),
        Err(e) => eprintln!("Error reading file: {}", e),
    }

    match read_number_from_file("invalid_number.txt") {
        Ok(number) => println!("File read successfully. Number: {}", number),
        Err(e) => eprintln!("Error reading file: {}", e),
    }

    match divide(10, 0) {
        Ok(result) => println!("Division successful. Result: {}", result),
        Err(e) => eprintln!("Error during division: {}", e),
    }
}

/*
Error reading file: Failed to open file: number.txt
Division successful. Result: 5
Error reading file: Failed to open file: non_existent_file.txt
Error reading file: Failed to open file: invalid_number.txt
Error during division: Division by zero
*/
anyhow 기본 사용법
use anyhow::{anyhow, Context, Result as AnyResult};
use std::fs::File;
use std::io::Read;

fn divide(x: i32, y: i32) -> AnyResult<i32> {
    if y == 0 {
        return Err(anyhow!("Division by zero"));
    }

    Ok(x / y)
}

fn read_file(path: &str) -> AnyResult<String> {
    let mut file = File::open(path).with_context(|| format!("Failed to open file: {}", path))?;
    let mut contents = String::new();

    file.read_to_string(&mut contents).with_context(|| format!("Failed to read file: {}", path))?;
    Ok(contents)
}

fn parse_number(s: &str) -> AnyResult<i32> {
    s.parse::<i32>().map_err(|e| anyhow!(e))
}

fn main() -> AnyResult<()> {
    let result = divide(10, 2)?;
    println!("Result: {}", result);

    if let Err(e) = divide(10, 0) {
        eprintln!("Divide Error: {}", e);
    }

    match read_file("file.txt") {
        Ok(contents) => println!("{}", contents),
        Err(e) => eprintln!("Read File Error: {}", e),
    }

    let num = parse_number("123")?;
    println!("Number: {}", num);

    if let Err(e) = parse_number("abc") {
        eprintln!("Parse Number Error: {}", e);
    }

    Ok(())
}

/*
Result: 5
Divide Error: Division by zero
Read File Error: Failed to open file: file.txt
Number: 123
Parse Number Error: invalid digit found in string
*/
thiserror, anyhow 요약

라이브러리를 개발하거나 세밀한 처리가 필요한 경우에는 thiserror를 사용하고 테스트를 위해 빠른 개발이 필요하거나 간단한 에러처리에는 anyhow를 사용하는 것을 권장한다.

특징 thiserror anyhow
에러 타입 명시적인 타입(enum, struct) anyhow::Error 로 추상화
에러 생성 다소 복잡 anyhow! Macros로 간편
에러 문맥 직접 구현해야 함 context() 메서드로 간편
디스패치 정적 동적
성능 약간 더 빠름 약간 느릴 수 있음(미미한 수준)
용도 라이브러리 개발, 세밀한 에러 처리,
성능 중시
애플리케이션 개발, 빠른 개발,
간단한 에러처리

Reference

  1. 프라부 에스왈라, 러스트서버 서비스 앱만들기, 김모세, 제이펍, 2024, p130