Rust 실전 강좌 #2 에러 설계: anyhow와 thiserror, 죽는 방법을 설계한다
기초 6편에서 예상 가능한 실패는 Result, 일어나면 안 되는 상태는 panic이라는 경계를 세웠습니다. 실전에서는 한 층이 더 필요합니다. CLI 도구의 에러 메시지는 사용자가 보는 화면, 즉 인터페이스의 일부입니다. Error: No such file or directory (os error 2)라고 죽는 도구와 에러: 로그 파일을 열 수 없습니다: access.log라고 죽는 도구의 차이는 구현이 아니라 설계에서 나옵니다. 이번 편은 그 설계를 담당하는 두 크레이트, anyhow와 thiserror입니다.
문제: 에러 타입이 제각각이다 #
loglens가 만날 실패는 벌써 여럿입니다. 파일 열기는 std::io::Error, 상태 코드 파싱은 ParseIntError, 나중에 추가할 JSON 로그는 serde_json::Error. 기초 6편의 ?로 전파하려면 함수의 반환 타입이 이들을 전부 담을 수 있어야 합니다. Box<dyn Error>가 그 답이었는데, 실무에서는 여기에 맥락(context)을 얹을 수 있는 개선판을 씁니다.
cargo add anyhowuse anyhow::{Context, Result};
fn run(cli: Cli) -> Result<()> {
// ...
Ok(())
}
fn main() -> Result<()> {
run(Cli::parse())
}anyhow::Result<T>는 Result<T, anyhow::Error>의 축약이고, anyhow::Error는 “어떤 에러든 담는” 타입입니다. Box<dyn Error>와 역할은 같지만, 결정적 차이가 context입니다.
use std::fs::File;
fn open_log(path: &Path) -> Result<File> {
File::open(path)
.with_context(|| format!("로그 파일을 열 수 없습니다: {}", path.display()))
}이제 파일이 없으면 이렇게 죽습니다.
Error: 로그 파일을 열 수 없습니다: access.log
Caused by:
No such file or directory (os error 2)위층의 설명(“무엇을 하려다 실패했나”)과 아래층의 원인(“왜 실패했나”)이 사슬로 출력됩니다. ?가 지나는 길목마다 context를 얹으면, 에러 메시지가 곧 실패 지점까지의 경로 안내가 됩니다. 클로저(|| format!(...))로 받는 이유는 성공 경로에서 문자열을 만들지 않기 위해서입니다. 성공이 대부분인 코드에서 실패용 문자열을 매번 조립하는 낭비를 지연 평가로 막는, 기초 7편과 같은 원리입니다.
thiserror: 종류로 분기해야 하는 에러 #
anyhow의 에러는 사람이 읽는 용도입니다. 코드가 에러의 종류를 보고 분기해야 한다면 이야기가 달라집니다. 3편에서 만들 로그 파서를 생각해 보면, “빈 라인"과 “형식이 다른 라인"과 “상태 코드가 숫자가 아닌 라인"은 다르게 취급하고 싶습니다(빈 라인은 조용히 스킵, 나머지는 세어서 보고). 이럴 때 구체 에러 타입을 만드는 도구가 thiserror입니다.
cargo add thiserroruse thiserror::Error;
#[derive(Debug, Error)]
pub enum ParseError {
#[error("빈 라인")]
Empty,
#[error("필드가 부족합니다 (필요 6, 실제 {found})")]
TooFewFields { found: usize },
#[error("상태 코드가 숫자가 아닙니다: {0}")]
BadStatus(#[from] std::num::ParseIntError),
}기초 5편의 열거형과 8편의 트레이트가 여기서 합류합니다. 에러의 종류는 variant로 나열하고, #[error("...")]가 Display 트레이트 구현을, #[derive(Error)]가 표준 Error 트레이트 구현을 자동 생성합니다. #[from]은 ?가 ParseIntError를 ParseError::BadStatus로 자동 변환하게 만드는 지시입니다. 손으로 쓰면 수십 줄인 상용구가 선언 몇 줄로 끝납니다.
판단 기준: 경계에서 갈린다 #
두 크레이트의 역할 분담은 한 문장으로 정리됩니다. 호출자가 에러 종류로 분기해야 하면 thiserror, 사람에게 보여 주고 끝이면 anyhow. loglens에 적용하면 이렇게 됩니다.
- 파서 모듈(3편):
Result<LogEntry, ParseError>— 호출하는 쪽이 “빈 라인이면 스킵, 그 외면 카운트"로 분기해야 하므로 thiserror - main과 커맨드 처리(애플리케이션 계층):
anyhow::Result<()>— 어떤 실패든 최종적으로는 메시지를 보여 주고 종료하므로 anyhow
두 층은 자연스럽게 이어집니다. anyhow::Error는 표준 Error 트레이트를 구현한 모든 타입을 받으므로, 파서의 ParseError도 ? 하나로 애플리케이션 계층에 흡수됩니다.
종료 코드: 스크립트 사용자와의 약속 #
CLI 도구의 마지막 인터페이스는 종료 코드입니다. main이 Err를 반환하면 Rust는 에러를 출력하고 종료 코드 1로 끝냅니다. 셸 스크립트에서 loglens stats access.log || alert처럼 엮는 사용자를 위한 기본 약속입니다. 참고로 clap이 잘못된 인자에서 내는 종료 코드는 2입니다. “실행은 됐지만 실패(1)“와 “사용법이 틀림(2)“의 구분은 Unix 도구의 오랜 관례이고, 기본 동작만으로 이미 지켜지고 있습니다.
정리 #
- CLI의 에러 메시지는 인터페이스입니다. 원인만 있는 메시지가 아니라 “무엇을 하려다 실패했는지"의 맥락을 담는 것이 설계 목표입니다.
- 애플리케이션 계층은 anyhow가 기본값입니다.
main() -> anyhow::Result<()>, 그리고?길목의with_context가 에러 사슬을 만듭니다. - 호출자가 종류로 분기해야 하는 계층은 thiserror로 구체 에러 열거형을 만듭니다.
#[error]가 Display를,#[from]이?변환을 자동 생성합니다. - 두 층은
?하나로 이어집니다. 파서의 구체 에러가 애플리케이션의 anyhow로 자연스럽게 흡수됩니다. - 다음 편에서는 이 에러 타입을 실제로 채웁니다. BufReader로 파일을 읽고, 로그 한 줄을 구조체로 바꾸는 파서를 만들겠습니다.