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를 강요받습니다. 대신 thiserror enum을 노출하세요.

발생할 수 있는 다른 에러들:

  • 같은 소스 타입의 두 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를 확인하는 테스트를 작성하세요.

rusterror-handlingthiserroranyhow