Rust anyhow 크레이트: 애플리케이션 에러를 간편하게 처리하기

Rust anyhow 크레이트: 애플리케이션 에러를 간편하게 처리하기

Rust 에러 처리를 다루면서 라이브러리용 에러 타입을 깔끔하게 정의하는 thiserror는 살펴봤는데요. 정작 그 단짝인 anyhow는 아직 다루지 않았습니다. 둘은 보통 한 쌍으로 거론되지만 역할이 정반대거든요.

애플리케이션 코드를 짜다 보면 함수 하나에서 파일도 읽고, 문자열도 파싱하고, 네트워크도 호출합니다. 그러면 std::io::Error, ParseIntError 등 종류가 다른 에러가 마구 쏟아지는데요. 이걸 일일이 enum으로 묶어 정의하는 건 과합니다. 어차피 최종적으로는 “어디서 왜 실패했는지” 로그를 남기고 프로그램을 끝낼 테니까요. anyhow는 바로 이 상황을 위한 크레이트입니다. 이번 글에서는 anyhow로 애플리케이션 에러를 간편하게 처리하는 방법을 살펴보겠습니다.

anyhow는 애플리케이션을 위한 에러 타입

anyhow의 핵심은 anyhow::Error라는 단 하나의 타입입니다. 표준 라이브러리의 Error 트레이트를 구현한 에러라면 종류를 가리지 않고 이 안에 담을 수 있는데요. 말하자면 “아무 에러나 다 받아주는 상자”입니다.

그래서 함수 반환 타입도 단순해집니다. anyhow::Result<T>Result<T, anyhow::Error>의 별칭이라, 에러 타입 자리를 고민할 필요 없이 성공 타입만 적으면 됩니다.

Cargo.toml
[dependencies]
anyhow = "1"
use anyhow::Result;

fn do_something() -> Result<String> {
    // 에러 타입은 anyhow가 알아서 채워준다
    Ok("성공".to_string())
}

Result 메서드 정리에서 본 일반적인 Result<T, E>와 달리, E 자리를 직접 정하지 않는다는 점이 포인트입니다.

?로 서로 다른 에러를 하나로

anyhow가 진가를 발휘하는 순간은 ? 연산자와 만날 때입니다. 서로 다른 타입의 에러라도 anyhow::Error로 자동 변환되기 때문에, 한 함수 안에서 종류가 다른 실패를 그냥 ?로 흘려보낼 수 있습니다.

use anyhow::Result;

fn read_port(raw: &str) -> Result<u16> {
    // parse가 돌려주는 ParseIntError가 anyhow::Error로 자동 변환된다
    let parsed: u16 = raw.trim().parse()?;
    Ok(parsed)
}

여기서 parse()ParseIntError를 돌려주지만, 함수 반환 타입이 anyhow::Result? 한 글자로 변환과 전파가 끝납니다. 파일을 읽는 std::io::Error가 섞여도 마찬가지고요. 이전 Rust 에러 처리 글에서 잠깐 언급한 Box<dyn Error>와 비슷한 발상이지만, anyhow::Error는 백트레이스를 함께 담을 수 있고 뒤에서 볼 맥락 추가나 다운캐스트 같은 기능이 붙어 있어 훨씬 다루기 편합니다.

context로 맥락을 입히기

?로 에러를 그냥 던지기만 하면 “왜 그 일을 하다가 실패했는지” 맥락이 사라집니다. invalid digit found in string이라는 메시지만 덩그러니 남으면, 대체 어느 코드에서 난 에러인지 알 길이 없죠. 😵

anyhow는 이를 위해 Context 트레이트의 contextwith_context 메서드를 제공합니다. 에러 위에 설명을 한 겹씩 덧씌우는 방식인데요.

use anyhow::{Context, Result};

fn read_port(raw: &str) -> Result<u16> {
    let parsed: u16 = raw
        .trim()
        .parse()
        // with_context는 클로저라, 에러가 났을 때만 문자열을 만든다
        .with_context(|| format!("'{raw}'를 포트 번호로 해석하지 못했습니다"))?;
    Ok(parsed)
}

fn load_config() -> Result<u16> {
    // context는 고정 문자열을 바로 받는다
    let port = read_port("abc").context("config.toml에서 포트를 읽는 중")?;
    Ok(port)
}

context는 고정된 문자열을 받고, with_context는 클로저를 받아서 에러가 실제로 발생했을 때만 메시지를 만듭니다. 포맷팅 비용이 아까운 정상 경로에서는 with_context가 유리하죠.

이렇게 맥락을 쌓아두면 에러를 {:?}로 출력했을 때 원인이 체인으로 따라 나옵니다.

fn main() {
    if let Err(e) = load_config() {
        println!("{e:?}");
    }
}
결과
config.toml에서 포트를 읽는

Caused by:
    'abc' 포트 번호로 해석하지 못했습니다

맨 위에 가장 바깥쪽 맥락이 오고, Caused by 아래로 근본 원인이 따라옵니다. 어느 작업을 하다가, 무엇 때문에 실패했는지가 한눈에 들어오죠.

main에서 바로 ? 쓰기

anyhow의 또 다른 편의 기능은 main 함수에서 직접 ?를 쓸 수 있다는 점입니다. main의 반환 타입을 anyhow::Result<()>로 두면, 내부에서 발생한 에러가 그대로 위로 전파되어 프로그램이 에러 메시지를 찍고 종료합니다.

use anyhow::{Context, Result};

fn read_file() -> Result<String> {
    std::fs::read_to_string("/없는/경로/config.toml").context("설정 파일을 읽지 못했습니다")
}

fn load() -> Result<String> {
    read_file().context("애플리케이션 초기화 실패")
}

fn main() -> Result<()> {
    let _cfg = load()?;
    Ok(())
}
결과
Error: 애플리케이션 초기화 실패

Caused by:
    0: 설정 파일을 읽지 못했습니다
    1: No such file or directory (os error 2)

맥락이 여러 겹이면 Caused by 아래에 번호가 매겨져 안쪽으로 들어갑니다. 이때 프로그램은 0이 아닌 종료 코드(1)로 끝나기 때문에, 셸 스크립트나 CI에서 실패를 제대로 감지할 수 있습니다. 별도의 출력 코드를 짜지 않아도 이 정도 에러 리포트가 공짜로 나오는 셈이죠.

bail!과 ensure!로 조기 반환하기

조건을 검사해서 일찍 실패시키고 싶을 때가 많은데요. anyhow는 이를 간결하게 해주는 두 매크로를 제공합니다.

bail!은 그 자리에서 에러를 만들어 반환합니다. return Err(anyhow!(...))의 축약형이라고 보면 됩니다.

use anyhow::{bail, Result};

fn find_item(key: &str) -> Result<String> {
    if key.is_empty() {
        bail!("키가 비어 있습니다");
    }
    // ...
    Ok("값".to_string())
}

ensure!는 한 걸음 더 나아가, 조건이 거짓이면 에러를 반환합니다. 다른 언어의 assert와 비슷하지만 패닉 대신 Err를 돌려준다는 점이 다릅니다.

use anyhow::{ensure, Result};

fn read_port(raw: &str) -> Result<u16> {
    let parsed: u16 = raw.trim().parse()?;
    // 조건이 거짓이면 즉시 Err로 반환
    ensure!(parsed >= 1024, "{parsed}번 포트는 예약 구간(1024 미만)입니다");
    Ok(parsed)
}

ensure!(parsed >= 1024, ...)if parsed < 1024 { bail!(...) }와 똑같이 동작합니다. 검증 로직을 한 줄로 줄여주죠.

downcast로 구체 타입 되찾기

에러를 anyhow::Error로 뭉뚱그리면 종류 정보가 사라질 것 같지만, 그렇지 않습니다. downcast_ref(또는 downcast)로 원래의 구체 타입을 다시 꺼낼 수 있거든요. 대부분의 에러는 로그만 남기고 끝내지만, 특정 에러만 골라 다르게 대응해야 할 때 유용합니다.

use anyhow::Result;

#[derive(Debug, thiserror::Error)]
enum ConfigError {
    #[error("설정 항목 {0}이(가) 없습니다")]
    Missing(String),
}

fn handle(err: anyhow::Error) {
    // anyhow::Error 안에서 ConfigError를 다시 꺼내본다
    match err.downcast_ref::<ConfigError>() {
        Some(ConfigError::Missing(key)) => println!("복구 성공: Missing({key})"),
        None => println!("다른 종류의 에러"),
    }
}
결과
복구 성공: Missing(db_host)

이 예시처럼 thiserror로 정의한 도메인 에러를 anyhow로 전파했다가, 필요한 지점에서 다시 꺼내 종류별로 분기할 수 있습니다. 두 크레이트가 자연스럽게 맞물리는 지점이죠.

thiserror와 anyhow, 언제 무엇을

이제 처음의 질문으로 돌아와 보겠습니다. thiserror와 anyhow는 언제 골라 써야 할까요? 기준은 “이 코드의 호출자가 에러 종류를 구별해야 하는가”입니다.

라이브러리를 만든다면 thiserror가 맞습니다. 라이브러리는 어떤 코드가 자신을 호출할지 모르고, 호출자는 보통 에러 종류에 따라 다르게 대응하고 싶어 합니다. 그래서 도메인별로 enum을 명확히 정의해 “무엇이 잘못될 수 있는지”를 타입으로 드러내야 하죠.

반면 애플리케이션이나 바이너리, 일회성 스크립트라면 anyhow가 편합니다. 최종 소비자는 사람이고, 대부분의 에러는 종류를 세세히 구분하기보다 맥락과 함께 로그를 남기고 끝내면 충분하니까요.

실무에서는 둘을 함께 쓰는 경우가 많습니다. 프로젝트 안의 라이브러리 계층(크레이트나 모듈)은 thiserror로 에러를 정의하고, 그걸 가져다 쓰는 애플리케이션의 main 쪽에서는 anyhow로 받아 맥락을 입혀 처리하는 식이죠. 앞서 본 downcast 예제가 바로 이 조합입니다.

마치며

anyhow는 애플리케이션 코드에서 에러를 부담 없이 다루게 해주는 크레이트입니다. anyhow::Result로 에러 타입 고민을 덜고, ?로 종류가 다른 에러를 통합하고, context로 맥락을 입히고, bail!ensure!로 검증을 간결하게 줄일 수 있죠. 종류를 구분해야 할 때는 downcast로 구체 타입을 다시 꺼내면 됩니다.

기억할 한 가지는 라이브러리에는 thiserror, 애플리케이션에는 anyhow라는 역할 분담입니다. 에러 처리의 큰 그림이 더 궁금하다면 Rust 에러 처리에서 왜 Rust가 예외 대신 값으로 에러를 다루는지부터 짚어보시면 좋습니다.

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

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord