Rust anyhow와 thiserror로 에러 처리하기
Result와 사용자 정의 에러 타입 페이지에서는 에러 타입을 손으로 직접 썼어요. 열거형을 만들고, impl Display를 쓰고, ?가 동작하도록 impl From도 썼을 거예요. 배울 때는 그게 올바른 방법이에요. 하지만 실제 프로젝트에서는 똑같은 반복 코드(boilerplate)를 계속, 또 계속 쓰게 돼요.
같은 사람이 만든 작은 크레이트 두 개가 이 반복 코드를 없애 줘요. 둘은 서로 다른 문제를 해결해요.
thiserror: 에러 타입을 정의하는 걸 도와줘요. 열거형은 여전히 직접 쓰지만,Display,Error,From은#[derive]가 대신 써 줘요.anyhow: 에러를 위로 넘기는 걸 도와줘요. 어떤 에러든 담을 수 있는 에러 타입 하나와, “설정 파일을 불러오는 중에” 같은 메시지를 쉽게 덧붙이는 방법을 줘요.
라이브러리와 애플리케이션
섹션 제목: “라이브러리와 애플리케이션”어느 쪽을 고를지는 누가 그 에러를 처리하느냐에 달려 있어요.
라이브러리는 다른 사람이 호출하는 코드예요. 호출하는 쪽은 실패마다 다르게 대응하고 싶을 수 있어요. 시간 초과라면 다시 시도하고, 입력이 잘못됐다면 입력 폼에 에러를 보여 주는 식으로요. 그래서 라이브러리는 호출하는 쪽이 match로 나눠 볼 수 있는 정확한 열거형을 돌려줘야 해요. 그게 thiserror예요.
애플리케이션(여러분의 main.rs)은 보통 마지막 종착지예요. 대부분의 에러는 사람에게 보여 줄 메시지와, 경우에 따라 종료 코드로 끝나요. 에러를 match로 나눠 볼 일은 드물고, ?로 쉽게 위로 넘기고 쉽게 읽을 수 있으면 돼요. 그게 anyhow예요.
간단한 기준을 하나 기억해 두세요. 남이 호출하는 코드에는 thiserror, 프로그램의 맨 위에는 anyhow. 이 페이지의 끝에서 보겠지만, 둘 다 쓰는 프로젝트도 많아요.
준비하기
섹션 제목: “준비하기”cargo add thiserror anyhow[dependencies]anyhow = "1"thiserror = "2"Cargo.toml이 처음이라면 Cargo와 크레이트 페이지를 보세요. 두 크레이트 모두 Rust Playground에서 쓸 수 있어서, 이 페이지의 “직접 실행해 보기” 버튼은 전부 동작해요.
thiserror: Error를 derive하고 메시지 쓰기
섹션 제목: “thiserror: Error를 derive하고 메시지 쓰기”열거형에 #[derive(Debug, Error)]를 붙이고, 배리언트마다 #[error("...")]로 메시지를 달아 주세요. 이 메시지가 그 배리언트의 Display 텍스트가 돼요. 메시지 안에서 {0}은 첫 번째 튜플 필드를, {balance}는 이름 있는 필드를 뜻해요.
use thiserror::Error;
#[derive(Debug, Error)]enum BankError { #[error("계좌 `{0}`을(를) 찾을 수 없어요")] NotFound(String), #[error("잔액이 부족해요: 잔액 {balance}, 출금 요청 {amount}")] InsufficientFunds { balance: u32, amount: u32 }, #[error("금액은 0보다 커야 해요")] ZeroAmount,}
fn main() { let errors = vec![ BankError::NotFound(String::from("alice")), BankError::InsufficientFunds { balance: 50, amount: 80 }, BankError::ZeroAmount, ];
for e in &errors { println!("{e}"); } println!("{:?}", errors[1]);}실행 결과:
계좌 `alice`을(를) 찾을 수 없어요잔액이 부족해요: 잔액 50, 출금 요청 80금액은 0보다 커야 해요InsufficientFunds { balance: 50, amount: 80 }사용자 정의 에러 타입 페이지의 열거형과 똑같지만, impl Display 블록이 통째로 사라졌어요. 실행할 때 숨겨진 마법은 없어요. thiserror는 여러분이 손으로 썼을 코드를 대신 써 줄 뿐이고, 호출하는 쪽에는 평범한 열거형으로 보여요.
thiserror: #[from]과 #[source]
섹션 제목: “thiserror: #[from]과 #[source]”에러에는 원인이 있는 경우가 많아요. “포트가 올바르지 않다”는 에러는 “문자열에서 잘못된 숫자를 발견했다”는 에러 때문에 생기죠. 표준 Error 트레이트에는 이런 원인을 위한 .source() 메서드가 있고, thiserror가 이것도 채워 줘요.
#[from]은 두 가지 일을 해요. 그 필드를 원인(source)으로 표시하고, impl From을 써 줘요. 그래서 ?가 안쪽 에러를 여러분의 배리언트로 자동 변환해 줘요.
use std::error::Error; // .source() 메서드를 스코프로 가져와요use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]enum ConfigError { #[error("{0:?} 줄에 `=`가 없어요")] NoEquals(String), #[error("포트가 올바른 숫자가 아니에요")] BadPort(#[from] ParseIntError),}
fn parse_port(line: &str) -> Result<u16, ConfigError> { let (_, value) = line .split_once('=') .ok_or_else(|| ConfigError::NoEquals(line.to_string()))?; let port = value.trim().parse::<u16>()?; // ParseIntError가 ConfigError::BadPort로 바뀌어요 Ok(port)}
fn main() { for line in ["port = 8080", "port 8080", "port = eighty"] { match parse_port(line) { Ok(port) => println!("성공: {port}"), Err(e) => { println!("에러: {e}"); if let Some(cause) = e.source() { println!(" 원인: {cause}"); } } } }}실행 결과:
성공: 8080에러: "port 8080" 줄에 `=`가 없어요에러: 포트가 올바른 숫자가 아니에요 원인: invalid digit found in string마지막 줄의 invalid digit found in string(“문자열에 잘못된 숫자가 있다”)은 Rust 표준 라이브러리의 ParseIntError가 내는 메시지라서 영어로 나와요.
원인은 남기고 싶지만 자동 From은 원하지 않을 때도 있어요. 줄 번호처럼 추가 데이터를 넣어야 할 때가 그래요. 그럴 때는 필드에 #[source]를 붙이고, .map_err()로 에러를 직접 만들어요. (이름이 그냥 source인 필드는 자동으로 원인으로 취급돼요.) thiserror는 열거형뿐 아니라 구조체에도 쓸 수 있어요.
use std::error::Error;use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]#[error("{line}번째 줄에 숫자가 없어요")]struct LineError { line: usize, #[source] cause: ParseIntError,}
fn sum_lines(text: &str) -> Result<i32, LineError> { let mut total = 0; for (i, row) in text.lines().enumerate() { let n: i32 = row .trim() .parse() .map_err(|cause| LineError { line: i + 1, cause })?; total += n; } Ok(total)}
fn main() { println!("합계: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\ntwenty\n30").unwrap_err(); println!("{err}"); println!("원인: {}", err.source().unwrap());}실행 결과:
합계: 602번째 줄에 숫자가 없어요원인: invalid digit found in stringanyhow: 모든 에러를 위한 Result 타입 하나
섹션 제목: “anyhow: 모든 에러를 위한 Result 타입 하나”anyhow::Result<T>는 Result<T, anyhow::Error>의 줄임말이에요. anyhow::Error에는 표준 Error 트레이트를 구현한 에러라면 어떤 타입이든 담을 수 있어요. 그래서 함수 하나에서 여러 종류의 에러가 나와도 ?가 모두에게 동작해요.
use anyhow::Result;
// 서로 다른 두 에러 타입: ParseIntError와 ParseFloatError.// anyhow::Result는 `?`를 통해 둘 다 받아요.fn total_price(quantity: &str, price: &str) -> Result<f64> { let quantity: u32 = quantity.trim().parse()?; let price: f64 = price.trim().parse()?; Ok(quantity as f64 * price)}
fn main() { for (quantity, price) in [("3", "2.50"), ("three", "2.50"), ("3", "cheap")] { match total_price(quantity, price) { Ok(total) => println!("합계: {total}"), Err(e) => println!("에러: {e}"), } }}실행 결과:
합계: 7.5에러: invalid digit found in string에러: invalid float literalanyhow가 없다면 배리언트 두 개짜리 열거형을 직접 만들거나 Box<dyn Error>를 써야 해요. anyhow는 더 편리한 Box<dyn Error>라고 생각하면 돼요. 그런데 문제가 하나 보이죠. “invalid digit found in string”(문자열에 잘못된 숫자가 있다)이라는 메시지만으로는 어떤 값이 잘못됐는지 알 수 없어요. 이럴 때 필요한 게 컨텍스트(context)예요.
anyhow: 컨텍스트 추가하고 체인 출력하기
섹션 제목: “anyhow: 컨텍스트 추가하고 체인 출력하기”Context 트레이트를 가져오면, 모든 Result에 새 메서드 두 개가 생겨요.
.context("메시지")는 정해진 메시지로 에러를 감싸요..with_context(|| format!(...))는 클로저를 받아요. 정말로 에러가 났을 때만 메시지를 만들기 때문에, 메시지에format!이 필요할 때 쓰세요.
컨텍스트를 한 겹 추가할 때마다 에러 체인(error chain)에 고리가 하나씩 늘어나요. 에러를 어떻게 출력하느냐에 따라 체인이 얼마나 보이는지가 달라져요.
use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> { let port: u16 = text .parse() .with_context(|| format!("`{text}`은(는) 올바른 포트가 아니에요"))?; Ok(port)}
fn load_settings(port_text: &str) -> Result<u16> { let port = parse_port(port_text).context("설정을 불러오지 못했어요")?; Ok(port)}
fn main() { let err = load_settings("99999").unwrap_err();
println!("{err}"); // 가장 바깥쪽 메시지만 println!("{err:#}"); // 체인 전체를 한 줄로
for (i, cause) in err.chain().enumerate() { println!("{i}: {cause}"); }}실행 결과:
설정을 불러오지 못했어요설정을 불러오지 못했어요: `99999`은(는) 올바른 포트가 아니에요: number too large to fit in target type0: 설정을 불러오지 못했어요1: `99999`은(는) 올바른 포트가 아니에요2: number too large to fit in target typenumber too large to fit in target type는 “숫자가 너무 커서 목표 타입에 들어가지 않는다”는 표준 라이브러리의 메시지예요. u16은 65535까지만 담을 수 있거든요.
세 번째 형식은 {:?}예요. 여러 줄짜리 보고서를 출력하는데, main이 에러를 돌려줄 때 보게 되는 게 바로 이 형식이에요.
println!("{err:?}");설정을 불러오지 못했어요
Caused by: 0: `99999`은(는) 올바른 포트가 아니에요 1: number too large to fit in target typeCaused by:는 “원인:“이라는 뜻으로, anyhow가 붙이는 제목이에요.
환경 변수 RUST_BACKTRACE=1이 설정되어 있으면 {:?}는 에러가 만들어진 위치를 보여 주는 스택 백트레이스도 함께 출력해요. 환경 변수와 프로세스 페이지를 참고하세요.
실제로 흔히 쓰는 곳은 파일 읽기예요. 운영체제가 주는 메시지(“No such file or directory”)에는 어떤 파일인지가 나오지 않거든요.
let text = std::fs::read_to_string(path) .with_context(|| format!("설정 파일 {path}을(를) 읽지 못했어요"))?;anyhow: bail!, ensure!, anyhow!
섹션 제목: “anyhow: bail!, ensure!, anyhow!”밑에 깔린 원래 에러가 없을 때도 있어요. 그냥 여러분이 뭔가 잘못됐다고 판단하는 경우죠. anyhow에는 이럴 때 쓰는 매크로가 세 개 있어요.
bail!("...")은 지금 바로 에러를 돌려줘요.return Err(anyhow!("..."))의 줄임말이에요.ensure!(조건, "...")는 조건이 거짓이면 에러를 돌려줘요.anyhow!("...")는 돌려주지 않고 에러 값만 만들어요.
셋 다 format! 형식의 메시지를 받아요.
use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> { ensure!(amount > 0, "금액은 0보다 커야 해요"); if amount > balance { bail!("잔액이 부족해요: 잔액 {balance}, 출금 요청 {amount}"); } Ok(balance - amount)}
fn find_user(id: u32) -> Result<&'static str> { match id { 1 => Ok("Ferris"), _ => Err(anyhow!("id가 {id}인 사용자가 없어요")), }}
fn main() { println!("{:?}", withdraw(100, 30).ok()); for (balance, amount) in [(100, 0), (100, 250)] { if let Err(e) = withdraw(balance, amount) { println!("에러: {e}"); } } println!("{}", find_user(1).unwrap()); println!("에러: {}", find_user(7).unwrap_err());}실행 결과:
Some(70)에러: 금액은 0보다 커야 해요에러: 잔액이 부족해요: 잔액 100, 출금 요청 250Ferris에러: id가 7인 사용자가 없어요main에서 anyhow::Result 돌려주기
섹션 제목: “main에서 anyhow::Result 돌려주기”anyhow를 가장 많이 보게 되는 곳이 여기예요. main이 anyhow::Result<()>를 돌려주고, 곳곳에서 ?를 써요. 에러가 main까지 올라오면 Rust는 그 에러를 {:?}로 stderr에 출력하고 종료 코드 1로 끝내요.
use anyhow::{Context, Result};
fn main() -> Result<()> { let text = "forty-two"; let n: i32 = text .parse() .with_context(|| format!("{text:?}을(를) 숫자로 읽을 수 없어요"))?; println!("{n}"); Ok(())}stdout에는 아무것도 출력되지 않아요. stderr에는 이렇게 나와요.
Error: "forty-two"을(를) 숫자로 읽을 수 없어요
Caused by: invalid digit found in string맨 앞의 Error:는 Rust가 붙여 주는 말이에요.
둘을 함께 쓰기
섹션 제목: “둘을 함께 쓰기”실제 프로젝트에서 흔히 보는 모양이에요. inventory 모듈은 라이브러리 역할을 해요. thiserror로 만든 정확한 InventoryError를 돌려주죠. 애플리케이션 코드는 anyhow를 쓰고, 컨텍스트를 덧붙이고, 정말 신경 써야 하는 곳에서만 에러 속을 들여다봐요. downcast_ref는 anyhow::Error에게 “너 사실 이 타입이지?” 하고 물어보는 메서드인데, 컨텍스트를 덧붙인 뒤에도 동작해요.
// ----- "라이브러리" 부분: thiserror로 만든 정확한 에러 -----mod inventory { use thiserror::Error;
#[derive(Debug, Error)] pub enum InventoryError { #[error("알 수 없는 상품 `{0}`")] UnknownItem(String), #[error("{available}개밖에 없어서 {wanted}개를 가져갈 수 없어요")] NotEnough { available: u32, wanted: u32 }, }
pub struct Store { pub apples: u32, }
impl Store { pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> { if item != "apple" { return Err(InventoryError::UnknownItem(item.to_string())); } if wanted > self.apples { return Err(InventoryError::NotEnough { available: self.apples, wanted }); } self.apples -= wanted; Ok(()) } }}
// ----- "애플리케이션" 부분: anyhow + 컨텍스트 -----use anyhow::{Context, Result};use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> { store .take(item, amount) .with_context(|| format!("주문 #{id} 실패"))?; println!("주문 #{id} 완료"); Ok(())}
fn main() { let mut store = Store { apples: 5 }; let orders = [(1, "apple", 2), (2, "plum", 1), (3, "apple", 9)];
for (id, item, amount) in orders { if let Err(e) = process_order(&mut store, id, item, amount) { println!("에러: {e:#}"); // 원래의 thiserror 타입을 여전히 들여다볼 수 있어요: if let Some(InventoryError::NotEnough { available, .. }) = e.downcast_ref::<InventoryError>() { println!(" 힌트: {available}개 이하로 주문해 보세요"); } } } println!("남은 사과: {}개", store.apples);}실행 결과:
주문 #1 완료에러: 주문 #2 실패: 알 수 없는 상품 `plum`에러: 주문 #3 실패: 3개밖에 없어서 9개를 가져갈 수 없어요 힌트: 3개 이하로 주문해 보세요남은 사과: 3개자주 하는 실수
섹션 제목: “자주 하는 실수”Context를 가져오는 걸 잊어버리기. .context()는 anyhow::Context 트레이트의 메서드이고, 트레이트 메서드는 그 트레이트가 스코프 안에 있을 때만 쓸 수 있어요.
use anyhow::Result;
fn parse(text: &str) -> Result<i32> { let n: i32 = text.parse().context("숫자가 아니에요")?; Ok(n)}error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope --> src/main.rs:4:31 |4 | let n: i32 = text.parse().context("숫자가 아니에요")?; | ^^^^^^^ | = help: items from traits can only be used if the trait is in scopehelp: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it |1 + use anyhow::Context; |“현재 스코프에서 Result에 context라는 메서드를 찾을 수 없다”는 에러예요. help 부분에서 Context 트레이트를 가져오면 된다고 친절하게 알려 주고 있어요.
해결 방법: use anyhow::{Context, Result};라고 쓰세요.
원인의 메시지를 내 메시지에 또 넣기. #[from]을 쓰면 안쪽 에러가 이미 원인이에요. 메시지에 {0}까지 넣으면, 전체 보고서를 출력할 때 같은 내용이 두 번 나와요.
#[derive(Debug, thiserror::Error)]enum AppError { #[error("잘못된 포트: {0}")] // 원인의 텍스트를 포함하고... BadPort(#[from] ParseIntError), // ...이 필드도 원인이에요}anyhow를 거쳐 {:#}로 출력하면 잘못된 포트: invalid digit found in string: invalid digit found in string이 나와요. 해결 방법: #[error("잘못된 포트")]라고 쓰고 원인은 체인이 보여 주게 하거나, {0}을 남기고 그 필드를 원인으로 표시하지 마세요. 둘 중 하나만 고르세요.
{}로 출력해서 원인을 놓치기. anyhow::Error를 println!("{e}")로 출력하면 “설정을 불러오지 못했어요”처럼 가장 바깥쪽 메시지만 보여요. 사람에게 에러를 보여 줄 때는 {:#}(한 줄)나 {:?}(전체 보고서)를 쓰세요.
라이브러리의 공개 API에 anyhow 쓰기. 라이브러리가 anyhow::Result를 돌려주면, 호출하는 쪽은 에러를 match로 나눠 볼 수 없어요. downcast_ref로 타입을 짐작해야 하죠. 공개 함수에는 thiserror 열거형을 쓰고, anyhow는 애플리케이션, 테스트, 예제에서 쓰세요.
- thiserror는 에러를 정의하는 도구예요.
#[derive(Error)]와 배리언트마다 붙이는#[error("...")]메시지가 손으로 쓰던Display와Error구현을 대신해요. #[from]은?가 안쪽 에러를 여러분의 배리언트로 변환하게 하고, 그 에러를 원인으로 기록해요.#[source]는 변환 없이 원인만 기록해요.- anyhow는 에러를 위로 넘기는 도구예요.
anyhow::Result<T>는?를 통해 어떤 에러든 받아요. .context()와.with_context()는 에러 체인에 사람이 읽을 수 있는 층을 하나 더해요. 체인은{:#}(한 줄)나{:?}(보고서)로 출력해요.bail!은 지금 에러를 돌려주고,ensure!는 조건이 거짓이면 에러를 돌려주고,anyhow!는 에러를 만들어요.- 흔한 구성: 라이브러리 코드에는 thiserror,
main에는 anyhow.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 라이브러리를 만들고 있는데, 호출하는 쪽이 시간 초과와 잘못된 입력에 다르게 대응해야 해요. 어떤 크레이트가 가장 잘 맞을까요?
2. 배리언트의 필드에 붙인 #[from]은 무슨 일을 할까요?
3. anyhow::Error를 체인 전체와 함께 한 줄로 출력하는 형식은 무엇일까요?
4. .context(...) 대신 .with_context(|| ...)를 써야 하는 때는 언제일까요?
5. amount가 0일 때 ensure!(amount > 0, "금액은 양수여야 해요")는 무엇을 할까요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- anyhow 문서 — docs.rs (영어):
Context,chain(), 다운캐스팅을 포함한 전체 API예요. - thiserror 문서 — docs.rs (영어):
#[error(transparent)], 백트레이스 필드 같은 모든 속성을 설명해요. - The Context trait — anyhow (영어):
context와with_context가 어떻게 동작하는지 예제와 함께 보여 줘요. - 에러 처리 — The Rust Programming Language 한국어판: 두 크레이트가 바탕으로 삼는 기초를 다뤄요.
- std::error::Error (영어):
.source()와 에러 체인의 바탕이 되는 표준 트레이트예요.