실행 파일(서버, 명령줄 도구)에서 에러는 대부분 “무엇을 하다가 왜 실패했는지 사람에게 알리고 멈추거나 넘어가는” 것으로 끝난다. 그래서 종류를 가르는 enum보다, 어떤 에러든 받아서 설명을 한 겹씩 덧붙이는 상자 하나가 편하다. anyhow가 그 상자다. 대신 시그니처만 보고는 어떤 에러가 올지 알 수 없으므로, 종류에 따라 다르게 대응해야 하는 자리에서는 이 방식이 모자라다.
예시로 보기
요청에서 사용자 번호를 읽고 저장소에서 이름을 찾는 핸들러를 짠다. 실패는 세 층에서 난다. 숫자 해석(ParseIntError), 내가 정한 규칙(번호는 0이 아님), 저장소(StoreError). 로그에 남길 때는 “어느 요청에서, 무엇을 하다가, 무엇 때문에”가 모두 보여야 한다.
use anyhow::{Context, Result, bail, ensure};
fn parse_id(raw: &str) -> Result<u32> {
let id: u32 = raw.trim().parse()
.with_context(|| format!("invalid id {raw:?}"))?;
ensure!(id != 0, "id must be positive");
Ok(id)
}
fn handle(raw: &str) -> Result<String> {
if raw.is_empty() {
bail!("empty request");
}
let id = parse_id(raw).context("bad request")?;
let name = store_get(id).with_context(|| format!("failed to load user {id}"))?;
Ok(name)
}
anyhow::Result<T>는 Result<T, anyhow::Error>의 줄임이다1. ?는 std::error::Error를 구현한 에러라면 무엇이든 anyhow::Error로 바꾼다1. .context(..)는 낮은 층의 에러 위에 설명 한 줄을 덧씌운다1. 설명을 만드는 데 format!이 들면 with_context(|| ..)를 쓴다. 클로저라서 실패할 때만 문자열을 만든다.
handle("abc")의 에러를 세 가지 형식으로 찍으면 이렇다(검증 코드 실행 결과).
{} => bad request
{:#} => bad request: invalid id "abc": invalid digit found in string
{:?} =>
bad request
Caused by:
0: invalid id "abc"
1: invalid digit found in string
chain()과 root_cause(), bail!·ensure!, context를 덧씌운 뒤에도 downcast_ref로 라이브러리 에러를 되찾는 것, 에러 종류에 따른 상태 코드 고르기를 실행해 확인했다 — 25_application-errors_verify.rs출력 형식
| 형식 | 찍히는 것 | 쓰는 곳 |
|---|---|---|
{} |
맨 바깥 설명 한 줄. 아래 원인은 안 찍는다2 | 사용자에게 짧게 |
{:#} |
바깥부터 원인까지 : 로 이은 한 줄2 |
한 줄 로그 |
{:?} |
바깥 설명, 빈 줄, Caused by: 목록, 잡힌 경우 백트레이스2 |
main이 Err로 끝날 때의 기본 출력2 |
{}만 찍으면 “bad request”만 남는다. 로그에는 {:#}나 {:?}를 쓴다. 이 차이를 모르면 운영 중에 원인을 잃는다.
main이 anyhow::Result<()>를 돌려주고 Err로 끝나면 {:?} 형식이 stderr에 찍힌다2. 물음표 연산자와 에러 전파에서 본 Error: ParseIntError { kind: InvalidDigit } 같은 출력보다 사람이 읽기 좋다.
사슬 다루기
chain()은 맨 바깥 에러부터 원인을 차례로 돌려주는 반복자다2. 위 예에서는["bad request", "invalid id \"abc\"", "invalid digit found in string"]이다.root_cause()는 맨 아래 원인이다2. 여기서는"invalid digit found in string".downcast_ref::<T>()로 원래 타입을 꺼낸다1. context를 덧씌운 뒤에도 된다.handle("7")의 에러는{:#}로failed to load user 7: record 7 not found이고,downcast_ref::<StoreError>()가Some(NotFound(7))을 돌려줬다.
bail!(..)은 return Err(anyhow!(..))의 줄임이고, ensure!(조건, ..)은 조건이 거짓이면 그렇게 한다1. 둘이 만드는 에러는 메시지만 있는 에러다. 아래 원인도 없고 꺼낼 타입도 없다.
“라이브러리는 thiserror, 앱은 anyhow”와 그 한계
흔히 쓰는 어림 규칙이다. 라이브러리는 부른 쪽이 종류를 갈라야 하므로 라이브러리 에러 설계의 enum을 공개한다. 앱은 대부분 에러를 찍고 끝내므로 anyhow로 모은다. anyhow 문서 자체는 이 규칙을 적지 않는다. 다만 anyhow는 derive 매크로를 주지 않으니 직접 구현하거나 thiserror 같은 매크로를 쓰라고 적는다1.
규칙이 너무 단순해지는 자리가 있다. 앱 안에서도 에러 종류에 따라 다르게 대응해야 하는 경계다.
- HTTP 서버는 “없음”이면 404, “형식 틀림”이면 400, 그 밖은 500을 돌려줘야 한다.
- 일시적 실패(시간 초과)는 다시 시도하고, 영구 실패(권한 없음)는 바로 포기해야 한다.
anyhow로만 모으면 이 경계에서 downcast_ref로 타입을 하나씩 찾아야 한다. 검증 코드의 status_of가 그렇게 했다. StoreError::NotFound면 404, ReadOnly면 503, ParseIntError면 400이었다. 그런데 bail!("empty request")와 ensure! 에러는 메시지뿐이라 찾을 타입이 없어 500이 됐다. 원래는 400이어야 할 요청 오류다. 컴파일러는 이 빠짐을 알려 주지 않는다.
그래서 이런 경계가 있는 앱은 경계 바로 안쪽에 작은 enum(예: ApiError { BadRequest, NotFound, Internal(anyhow::Error) })을 두고, 그 enum을 응답으로 바꾸는 코드를 한 곳에 모은다. 안쪽 깊은 곳은 anyhow로 편하게 쓰고, 경계만 타입으로 지킨다. axum에서 이렇게 하는 법은 axum으로 HTTP 서비스 만들기에서 다룬다.
활용
- 로그.
tracing::error!("request failed: {e:#}")처럼 사슬 전체를 남긴다.{}만 쓰면 원인이 빠진다. 구조화 로그는 tracing으로 관측하기. - 설명 문장. context는 “무엇을 하다가”를 적는다(
failed to load user 7). 원인 메시지(“record 7 not found”)를 다시 쓰지 않는다. 사슬이 알아서 붙인다. - 스레드·태스크.
anyhow::Error는Send + Sync + 'static이다2.?가 쓰는From<E>구현도E: Error + Send + Sync + 'static을 요구한다2. 그래서Send가 아닌 값을 담은 에러는?로 넣을 수 없다. - 백트레이스. 원인이 백트레이스를 주지 않으면 anyhow가 잡는다.
RUST_BACKTRACE나RUST_LIB_BACKTRACE로 켠다1. - 흔한 실수. 라이브러리 공개 함수가
anyhow::Result를 돌려준다. 쓰는 사람은 종류를 가를 수 없고, anyhow가 공개 의존성이 된다.
연결
- 선수: 물음표 연산자와 에러 전파, 라이브러리 에러 설계
- 복구하지 않고 멈추는 쪽: panic을 쓸 때
- Java의 예외도 원인을 담는다(
new RuntimeException("msg", cause),getCause()). anyhow의 context는 그 감싸기를 한 메서드로 한 것이다: 예외 던지기
확인 문제
C1 본문의 handle에 "0"을 넣었을 때 {}와 {:#}의 출력을 쓰라.
답: {}는 bad request, {:#}는 bad request: id must be positive.
이유: "0"은 숫자로는 바뀌므로 with_context 쪽 에러는 생기지 않는다. ensure!가 메시지만 있는 에러를 만들고, handle의 .context("bad request")가 그 위에 덧씌운다. 사슬은 두 칸이다. 검증 코드의 결과와 같다.
C2 HTTP 핸들러가 모두 anyhow::Result를 돌려주고, 경계에서 downcast_ref로 상태 코드를 고른다. 이 설계에서 bail!("empty request")가 500이 되는 까닭은? 어떻게 막는가?
답: bail!이 만든 에러는 메시지만 있고 타입이 없어서 downcast_ref로 찾을 것이 없다. 그래서 기본값 500으로 떨어진다. 경계 안쪽에 ApiError::BadRequest 같은 enum을 두고 요청 오류를 그 타입으로 돌려주면, match가 빠짐없이 상태 코드를 고르게 된다.
흔한 오답: “메시지 문자열을 비교하면 된다.” 메시지가 바뀌면 조용히 깨진다.
C3 다음 각각에 anyhow와 thiserror 중 무엇이 맞는가? (가) 명령줄 백업 도구의 main 근처 함수들 (나) 여러 서비스가 쓰는 결제 SDK의 공개 함수 (다) 재시도 여부를 에러 종류로 정해야 하는 메시지 소비 루프
답: (가) anyhow. (나) thiserror. (다) 종류를 가르는 enum(thiserror)이 맞고, 안쪽 세부 원인은 anyhow로 담아도 된다.
이유: (가)는 찍고 끝낸다. (나)는 부른 쪽이 갈라야 한다. (다)는 앱이지만 “다시 시도할까”를 타입으로 정해야 하는 경계다.
-
anyhow 1.0.104 문서(docs.rs), 크레이트 첫 페이지(Details, Context, bail!/ensure!, downcast, backtrace) — https://docs.rs/anyhow/latest/anyhow/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
anyhow 문서, Struct
anyhow::Error— Display representations,chain,root_cause,From<E>구현의 경계 — https://docs.rs/anyhow/latest/anyhow/struct.Error.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9