Rust 오류 자료형: thiserror 라이브러리 사용법

Rust 오류 자료형: thiserror 라이브러리 사용법

Rust는 명시적이고 안전한 오류 처리를 중시하는 프로그래밍 언어입니다. 대표적으로 Result 타입과 ? 연산자를 통해 다양한 오류 상황을 타입 시스템으로 포착할 수 있죠. 하지만 실무에서 직접 오류 자료형을 정의하고 Error 트레이트(trait)를 구현하다 보면, 반복적인 보일러플레이트 코드 작성에 지치는 경우가 많습니다.

이럴 때 thiserror 라이브러리가 여러분의 구세주가 될 수 있습니다.

Error 트레이트

우선 표준 라이브러리의 Error 트레이트를 직접 구현하는 데 필요한 최소한의 코드를 보여드리겠습니다. Rust std::error::Error 트레이트source(), dyn Error, 다운캐스팅 같은 기능은 별도의 글에서 자세히 다룹니다.

아래 ValidationError 열거형은 세 개의 변형(variant)으로 이루어져 있습니다. 우선 Debug 트레이트를 파생하고, Display 트레이트를 구현하고, 마지막으로 Error 트레이트를 구현해야 합니다.

use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum ValidationError {
    InvalidEmail,
    RequiredField(&'static str),
    OutOfRange { min: i32, max: i32, value: i32 },
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ValidationError::InvalidEmail => write!(f, "Invalid email format"),
            ValidationError::RequiredField(field) => write!(f, "Field '{}' is required", field),
            ValidationError::OutOfRange { min, max, value } => {
                write!(
                    f,
                    "Value must be between {} and {}, got {}",
                    min, max, value
                )
            }
        }
    }
}

impl Error for ValidationError {}

위 예제는 최소한의 구현이고 실제로는 Error 트레이트의 메서드까지 구현하게 되는데요. 매번 새로운 오류 자료형을 정의할 때마다 이렇게 코드를 많이 작성하려면 개발 생산성이 떨어질 수 있겠죠?

thiserror란?

thiserror는 개발자가 오류 자료형을 쉽게 정의할 수 있도록 도와주는 Rust 라이브러리입니다. Error 트레이트 구현을 자동화하기 위해서 #[derive(Error)] 매크로를 제공하는데요. 덕분에 오류 메시지, 변환, 체인 구성 등을 매우 간단하게 처리할 수 있습니다.

thiserror 크레이트(crate)는 터미널에서 cargo add 명령어를 사용하거나 Cargo.toml 파일을 편집하여 설치할 수 있습니다.

$ cargo add thiserror
Cargo.toml
[dependencies]
thiserror = "2"

오류 정의

자, 그럼 지금부터 thiserror를 사용하여 동일한 오류 자료형을 정의해 보겠습니다.

use thiserror::Error;

#[derive(Error, Debug)]
enum ValidationError {
    #[error("Invalid email format")]
    InvalidEmail,

    #[error("Field '{0}' is required")]
    RequiredField(&'static str),

    #[error("Value must be between {min} and {max}, got {value}")]
    OutOfRange { min: i32, max: i32, value: i32 },
}

어떤가요? 코드 양이 확 줄고 읽기도 편해졌죠?

변경 사항을 간단히 살펴보면 다음과 같습니다.

  • std::error::Error 대신에 thiserror::Error를 불러옵니다.
  • 오류 자료형 위에 #[derive(Error)] 매크로를 붙여줍니다.
  • 열거형의 각 변형 위에 #[error("...")] 속성으로 오류 메시지를 지정합니다.
  • 필드 값을 {} 또는 {0}, {message} 같은 형식으로 메시지에 삽입할 수 있습니다.

오류 메시지

위와 같이 오류 자료형을 정의하면 Display 트레이트가 자동으로 구현된 효과가 납니다.

fn main() {
    let invalid_email = ValidationError::InvalidEmail;
    let required_field = ValidationError::RequiredField("password");
    let out_of_range = ValidationError::OutOfRange {
        min: 1,
        max: 100,
        value: 101,
    };

    println!("{}", invalid_email);
    println!("{}", required_field);
    println!("{}", out_of_range);
}

필드 값을 메시지 안에 자연스럽게 녹여 사용자에게 명확한 피드백을 제공할 수 있게 되었습니다.

결과
Invalid email format
Field 'password' is required
Value must be between 1 and 100, got 101

오류 변환

다른 오류 자료형을 감싸는 코드를 직접 작성하면 번거롭지만, #[from]을 사용하면 자동으로 From 트레이트가 구현되어 손쉽게 변환할 수 있습니다.

use std::fs::File;
use std::io;
use thiserror::Error;

#[derive(Error, Debug)]
enum FileError {
    #[error("File not found: {filename}")]
    NotFound { filename: String },

    #[error("Permission denied")]
    PermissionDenied,

    #[error("IO error")]
    Io(#[from] io::Error),
}

fn read_config_file(filename: &str) -> Result<String, FileError> {
    use std::io::Read;

    match File::open(filename) {
        Ok(mut file) => {
            let mut contents = String::new();
            file.read_to_string(&mut contents)?; // io::Error → FileError::Io로 자동 변환됨
            Ok(contents)
        }
        Err(e) => match e.kind() {
            io::ErrorKind::NotFound => Err(FileError::NotFound {
                filename: filename.to_string(),
            }),
            io::ErrorKind::PermissionDenied => Err(FileError::PermissionDenied),
            _ => Err(FileError::Io(e)),
        },
    }
}

오류 체인

#[source] 속성으로 오류의 원인을 명시할 수 있습니다. Error::source() 메서드를 통해 체인을 순차적으로 추적할 수 있습니다.

use std::io;
use thiserror::Error;

#[derive(Error, Debug)]
enum DatabaseError {
    #[error("Connection failed")]
    ConnectionFailed(#[source] io::Error),

    #[error("Query failed: {query}")]
    QueryFailed {
        query: String,
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },
}

내부 오류를 투명하게 전달하기

오류를 감쌌다는 사실까지 숨기고 싶을 때가 있습니다. 새로운 메시지나 오류 체인의 단계를 덧붙이지 않고 내부 오류처럼 보이게 하려면 #[error(transparent)]를 사용합니다. 투명 오류(transparent error) 변형은 내부 오류의 DisplayError::source() 구현을 그대로 위임합니다.

use std::fs;
use std::io;
use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("Invalid config value: {0}")]
    InvalidValue(String),

    #[error(transparent)]
    Io(#[from] io::Error),
}

fn read_config(path: &str) -> Result<String, ConfigError> {
    Ok(fs::read_to_string(path)?)
}

ConfigError::Io를 출력해도 ConfigError가 만든 접두어나 설명은 나오지 않고 내부 io::Error의 메시지가 그대로 표시됩니다. 원인 체인도 마찬가지인데요. ConfigError::source()는 감싼 io::Error 자체가 아니라 io::Error::source()의 결과를 반환합니다. 래퍼 타입(wrapper type)이 오류 체인에 별도 단계로 끼어들지 않는 셈입니다.

헷갈리기 쉽지만 #[error(transparent)]#[from]은 역할이 다릅니다. transparent는 오류 메시지와 원인 조회를 위임하고, #[from]io::ErrorConfigError로 자동 변환합니다. 그래서 두 속성을 함께 쓰는 경우가 많지만, 자동 변환이 필요 없다면 #[from]은 생략해도 됩니다. 한 가지 제약도 있습니다. transparent를 붙인 변형에는 Error 트레이트를 구현한 필드가 정확히 하나만 있어야 합니다.

오류를 감싼 이유를 알려야 한다면 #[error("Failed to read config")]처럼 자체 메시지를 지정하는 편이 낫습니다. 이때 #[from]이나 #[source]로 내부 오류를 원인에 연결하면 Error::source()가 내부 오류를 반환하므로 두 오류를 모두 추적할 수 있습니다.

마치며

지금까지 Rust에서 오류 자료형을 깔끔하게 정의하도록 도와주는 thiserror를 알아보았습니다. thiserror를 활용하면 보일러플레이트 코드를 줄이고 오류 처리에 더 집중할 수 있습니다. 반대로 애플리케이션에서 오류를 종류별로 구분하지 않고 간편하게 모아서 처리하고 싶다면 anyhow가 그 짝이 되는 크레이트입니다. reqwest로 HTTP 요청을 보낼 때 네트워크 오류를 처리하는 경우에도 thiserror가 유용하게 쓰입니다.

더 자세한 내용은 thiserror 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord