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
[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) 변형은 내부 오류의 Display와 Error::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::Error를 ConfigError로 자동 변환합니다.
그래서 두 속성을 함께 쓰는 경우가 많지만, 자동 변환이 필요 없다면 #[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.0