Rust의 thiserror와 anyhow로 에러 핸들링하기
1. 개요
Rust는 Result<T, E>를 통해 에러 처리를 명시적으로 강제합니다. 이런 방식은 안전하지만, 에러 타입을 직접 작성하려면 보일러플레이트 코드가 많이 필요합니다. 각 경우마다 Display, Error, From을 직접 구현해야 합니다.
이 글에서는 이 문제를 해결하는 가장 인기 있는 두 crate를 소개합니다. thiserror는 구조화된 에러 타입을 정의하는 데 도움이 됩니다. anyhow는 모든 에러를 하나의 타입으로 통합하고 문맥을 추가합니다. 이 글을 읽고 나면, 각 crate를 어디서 사용할지, 어떻게 조합할지, 에러를 테스트하는 방법을 알게 될 것입니다.
2. 수동으로 에러 작성할 때의 문제점
파일에서 주문의 수량을 읽는다고 가정해봅시다. 두 가지 에러가 발생할 수 있습니다: I/O 에러와 파싱 에러. 라이브러리를 사용하지 않으면 다음과 같이 작성해야 합니다:
use std::fmt;
#[derive(Debug)]
pub enum OrderError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::Io(_) => write!(f, "주문 파일을 읽을 수 없습니다"),
OrderError::Parse(_) => write!(f, "주문 데이터의 형식이 잘못되었습니다"),
}
}
}
impl std::error::Error for OrderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
OrderError::Io(e) => Some(e),
OrderError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for OrderError {
fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}
impl From<std::num::ParseIntError> for OrderError {
fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}
단 두 개의 variant에만 30줄 정도의 보일러플레이트 코드가 필요합니다. variant를 추가할 때마다 네 곳을 수정해야 합니다. 이것이 바로 thiserror가 해주는 일입니다.
또 다른 방법은 Box<dyn std::error::Error>를 사용하는 것입니다. 이 방식은 간결하지만 타입 정보를 잃고 문맥을 추가할 수 없습니다. anyhow는 이 방법보다 개선된 버전입니다.
3. 설치
Cargo.toml에 두 개의 의존성을 추가합니다:
[dependencies]
thiserror = "2"
anyhow = "1"
이 두 crate는 서로 독립적입니다. 라이브러리는 보통 thiserror만 필요합니다. 바이너리는 anyhow를 사용하고, 때로 둘 다 사용합니다.
4. thiserror: 라이브러리를 위한 구조화된 에러 타입
4.1. 에러 enum 정의
thiserror를 사용하면 2번 섹션의 코드가 다음과 같이 줄어듭니다:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OrderError {
#[error("주문 #{0}을(를) 찾을 수 없습니다")]
NotFound(u64),
#[error("수량이 유효하지 않습니다: {quantity} (0보다 커야 함)")]
InvalidQuantity { quantity: i32 },
#[error("주문 파일을 읽을 수 없습니다")]
Io(#[from] std::io::Error),
#[error("주문 데이터의 형식이 잘못되었습니다")]
Parse(#[from] std::num::ParseIntError),
}
각 부분의 역할:
#[derive(Error)]는std::error::Error의 구현을 생성합니다.#[error("...")]는Display를 생성합니다.{0}은 tuple variant의 첫 번째 필드를 참조합니다.{quantity}는 이름이 있는 필드를 참조합니다.#[from]은impl From<std::io::Error> for OrderError를 생성합니다. 덕분에?연산자가 자동으로 에러를 변환합니다.#[from]이 있는 필드는 자동으로source()의 대상이 됩니다.
이제 이 에러 타입을 사용하는 함수를 작성해봅시다:
use std::fs;
pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
let quantity: i32 = raw.trim().parse()?;
if quantity <= 0 {
return Err(OrderError::InvalidQuantity { quantity });
}
Ok(quantity)
}
pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
let content = fs::read_to_string(path)?;
parse_quantity(&content)
}
fs::read_to_string은 io::Error를 반환합니다. ? 연산자는 From을 호출하여 이를 OrderError::Io로 변환합니다. 마찬가지로 ParseIntError는 OrderError::Parse로 변환됩니다. 호출자는 각 variant에 대해 match로 개별 처리할 수 있습니다.
4.2. #[source]로 원인 체인 순회하기
때로는 에러를 유지하지만 From으로 자동 변환하고 싶지 않을 수 있습니다. 이 경우 #[from] 대신 #[source]를 사용합니다:
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("SKU {sku}에 대해 재고를 업데이트할 수 없습니다")]
UpdateFailed {
sku: String,
#[source]
cause: std::io::Error,
},
}
이 variant는 sku 필드가 추가되어 있어서 From을 자동으로 생성할 수 없습니다. 이 경우 #[source]는 cause가 원인임을 선언할 뿐입니다. 다음과 같이 원인 체인을 순회할 수 있습니다:
use std::error::Error as _;
fn print_chain(err: &dyn std::error::Error) {
eprintln!("에러: {err}");
let mut current = err.source();
while let Some(cause) = current {
eprintln!(" 원인: {cause}");
current = cause.source();
}
}
UpdateFailed를 “permission denied” 에러로 감싸서 print_chain을 호출하면 다음을 보게 됩니다:
에러: SKU SKU-42에 대해 재고를 업데이트할 수 없습니다
원인: Permission denied (os error 13)
4.3. #[error(transparent)]
transparent는 Display와 source()를 내부 에러로 직접 전달합니다. variant가 단순히 래퍼이고 추가 정보가 없을 때 사용합니다:
#[derive(Debug, Error)]
pub enum PaymentError {
#[error("카드가 거부되었습니다")]
CardDeclined,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
Other variant는 “기타 에러”의 역할을 합니다. 호출자가 처리해야 하는 구체적인 에러는 자체 variant를 갖습니다. 그 외의 모든 에러는 Other로 통합됩니다.
5. anyhow: 전체 애플리케이션을 위한 단일 에러 타입
애플리케이션 레이어에서는 각 에러 타입으로 match하는 경우가 드뭅니다. 보통 에러를 로깅하거나 사용자에게 출력하면서 무엇이 일어났는지 알 수 있는 충분한 정보를 제공합니다. anyhow::Error는 이 정확한 필요를 충족합니다.
5.1. anyhow::Result와 문맥
use anyhow::{Context, Result};
fn read_port(path: &str) -> Result<u16> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("설정 파일 {path}을(를) 읽을 수 없습니다"))?;
let port: u16 = raw
.trim()
.parse()
.context("포트는 0–65535 범위의 정수여야 합니다")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port("app.conf")?;
println!("서버가 포트 {port}에서 실행 중입니다");
Ok(())
}
코드는 다음과 같이 동작합니다:
anyhow::Result<T>는Result<T, anyhow::Error>의 별칭입니다.std::error::Error + Send + Sync + 'static을 구현하는 모든 타입은?를 통해anyhow::Error로 변환됩니다..context(...)는 에러를 새 메시지로 감싸고, 원래 에러는 원인으로 유지됩니다..with_context(|| ...)는 클로저를 받습니다. 클로저는 에러가 있을 때만 실행되므로 happy path에서 불필요한format!호출을 피합니다.
main이 파일이 없어서 Err를 반환하면, 프로그램은 다음을 출력합니다:
Error: 설정 파일 app.conf을(를) 읽을 수 없습니다
Caused by:
No such file or directory (os error 2)
anyhow::Error를 포맷하는 방법은 세 가지입니다:
{}는 가장 바깥쪽 메시지만 출력합니다.{:#}는 전체 체인을 한 줄로 출력하며,:로 구분합니다.{:?}는 위와 같은 여러 줄 형식으로 출력합니다.
한 줄 로깅에는 {:#}를 사용하는 것이 가장 편합니다.
Context는 Option과도 함께 작동합니다. 이는 None을 의미 있는 에러로 변환하는 간결한 방법입니다:
use anyhow::{Context, Result};
use std::collections::HashMap;
fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
customers.get(&id).with_context(|| format!("고객 #{id}이(가) 없습니다"))
}
5.2. bail!, ensure! 및 anyhow! 매크로
이 세 매크로는 타입을 정의하지 않고 ad-hoc 에러를 만드는 데 도움이 됩니다:
use anyhow::{anyhow, bail, ensure, Result};
fn apply_discount(total: u64, percent: u8) -> Result<u64> {
ensure!(percent <= 100, "할인율이 유효하지 않습니다: {percent}");
if total == 0 {
bail!("0원 주문에 할인을 적용할 수 없습니다");
}
total
.checked_sub(total * percent as u64 / 100)
.ok_or_else(|| anyhow!("할인 계산 중 오버플로우 발생"))
}
ensure!(cond, ...)는 조건이 거짓이면 에러를 반환합니다.bail!(...)는return Err(anyhow!(...))와 동일합니다.anyhow!(...)는anyhow::Error값을 만들지만 반환하지는 않습니다.
예를 들어, apply_discount(500_000, 120)은 에러 할인율이 유효하지 않습니다: 120을 반환합니다.
5.3. 여전히 에러 분류가 필요한 경우 downcast하기
anyhow::Error는 원래 타입을 유지합니다. 특별한 경우를 처리하기 위해 downcast할 수 있습니다:
fn handle(err: &anyhow::Error) {
match err.downcast_ref::<OrderError>() {
Some(OrderError::NotFound(id)) => println!("주문 #{id}에 대해 404 페이지 표시"),
Some(other) => println!("주문 에러: {other}"),
None => println!("시스템 에러: {err:#}"),
}
}
.context(...)로 감싸졌을 때도 downcast_ref는 작동합니다. 하지만 자주 downcast하는 것은 anyhow 대신 thiserror enum을 사용해야 한다는 신호입니다.
6. 조합: 아래는 thiserror, 위는 anyhow
간단하면서도 효과적인 규칙:
- 라이브러리 또는 도메인 모듈은
thiserror를 사용합니다. 호출자는 정확히 어떤 에러인지 알아야 조치를 결정할 수 있습니다. - 바이너리, CLI, 가장 바깥쪽 핸들러는
anyhow를 사용합니다. 여기서는 주로 에러를 보고합니다.
use anyhow::{Context, Result};
fn process_order_file(path: &str) -> Result<()> {
let quantity = load_quantity(path)
.with_context(|| format!("주문 파일 {path} 처리 실패"))?;
println!("주문이 {} 상품을 포함합니다", quantity);
Ok(())
}
load_quantity 함수는 구조화된 OrderError를 반환합니다. 위의 레이어는 해당 파일을 처리 중이라는 문맥을 추가합니다. 파일에 -3이 포함되어 있을 때 다음을 보게 됩니다:
Error: 주문 파일 orders/1001.txt 처리 실패
Caused by:
수량이 유효하지 않습니다: -3 (0보다 커야 함)
7. 에러 동작 테스트하기
구조화된 에러 타입은 테스트를 정확하게 합니다. 문자열을 모호하게 비교하는 대신 올바른 variant를 확인할 수 있습니다:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_quantity_returns_invalid_quantity() {
let err = parse_quantity("-3").unwrap_err();
assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
assert_eq!(err.to_string(), "수량이 유효하지 않습니다: -3 (0보다 커야 함)");
}
#[test]
fn non_numeric_input_returns_parse_error() {
let err = parse_quantity("abc").unwrap_err();
assert!(matches!(err, OrderError::Parse(_)));
}
#[test]
fn missing_config_keeps_io_error_as_root_cause() {
let err = read_port("/없는/경로/conf").unwrap_err();
assert_eq!(
err.to_string(),
"설정 파일 /없는/경로/conf을(를) 읽을 수 없습니다"
);
assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
}
}
다음 명령으로 테스트를 실행합니다:
cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok
test result: ok. 3 passed; 0 failed
세 번째 테스트는 root_cause()가 체인의 맨 끝에 있는 에러로 이동한다는 것을 보여줍니다. 이를 통해 문맥이 원래 에러를 손실하지 않았음을 확인할 수 있습니다.
8. 흔한 에러 및 사용하지 말아야 할 경우
주의:
#[error]에서 원래 에러의 메시지를 반복하지 마세요.#[error("io error: {0}")] Io(#[from] io::Error)라고 작성하면,{:?}또는{:#}로 전체 체인을 인쇄할 때 메시지가 두 번 나타납니다. 둘 중 하나를 선택하세요:{0}을 포함하고 source를 선언하지 않거나, source를 선언하고 메시지를 간결하게 유지합니다.
주의: 라이브러리의 공개 API에서
anyhow::Error를 사용하지 마세요. 호출자가 에러를match할 수 없고 downcast를 강요받습니다. 대신thiserrorenum을 노출하세요.
발생할 수 있는 다른 에러들:
- 같은 소스 타입의 두 variant가
#[from]을 갖고 있습니다. 예를 들어, 두 variant가 모두io::Error를 감싸고#[from]을 가지면, 동일한 두impl From을 생성하여 컴파일 에러가 발생합니다. 한 variant에 대해#[source]와map_err을 함께 사용하세요. #[from]남용. 모든io::Error가OrderError::Io가 되면, 어떤 작업이 실패했는지에 대한 정보를 잃게 됩니다.UpdateFailed { sku, cause }처럼 문맥 필드가 있는 variant가 보통 더 유용합니다.anyhow::Error가std::error::Error를 구현하지 않는다는 점 잊기.dyn Error로 deref되지만 해당 trait을 구현하지는 않습니다.E: std::error::Error가 필요한 곳에 전달할 수 없습니다. 필요하면#[error(transparent)]variant로 감싸세요..context(format!(...))를 호출. 에러가 없어도 문자열이 즉시 포맷됩니다..with_context(|| ...)를 사용하세요.- 매우 성능에 민감하거나
no_std코드.anyhow::Error는 힙에 할당합니다. hot path나 embedded 환경에서는 작고 간결한 enum이 더 좋은 선택입니다.
backtrace를 원한다면 실행 시 RUST_BACKTRACE=1 환경 변수를 설정하세요. anyhow가 에러와 함께 backtrace를 출력합니다.
9. 결론
thiserror와 anyhow는 서로 다른 두 가지 필요를 해결합니다. thiserror는 에러 타입을 정의할 때 보일러플레이트를 제거하며, 호출자가 에러를 분류해야 하는 라이브러리와 도메인 레이어에 적합합니다. anyhow는 모든 에러를 하나의 타입으로 통합하고 단계별로 문맥을 추가할 수 있으며, 바이너리와 가장 바깥쪽 레이어에 적합합니다.
둘을 조합하는 것이 가장 좋습니다: 아래에서는 구조화된 에러를 정의하고, 위에서는 문맥을 추가합니다. 마지막으로 메시지를 간결하게 유지하고, 원래 에러를 반복하지 않으며, 문자열을 단순히 비교하는 대신 올바른 variant를 확인하는 테스트를 작성하세요.