hyunsu blog

홈강좌Rust 실무 16편 중 8편째

Rust 에러 처리 실무 — 라이브러리는 thiserror, 앱은 anyhow

목차 · 3
  1. 01thiserror — 경우를 나눈 에러 타입
  2. 02anyhow — 앱에서는 설명을 붙여 위로
  3. 03실습 — 원인 사슬 읽기

Rust 실무의 에러 처리는 둘로 나눈다. 라이브러리는 thiserror 로 무엇이 틀렸는지 경우를 나눈 에러 타입을 만들고, 앱은 anyhow 로 어떤 에러든 받아 context 로 사람이 읽을 설명을 붙인다. main 이 anyhow::Result 를 돌려주면 원인 사슬을 찍고 종료 코드 1 로 끝난다.

입문 30·31편의 Result 와 ? 를 그대로 쓴다. 두 크레이트는 그 위에서 반복되는 코드를 줄여 줄 뿐이다.

01 thiserror — 경우를 나눈 에러 타입

설정 파일의 port = … 줄을 읽는 함수다. 틀릴 수 있는 경우가 셋이다.

rust
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("line {line}: missing '='")]
    NoEquals { line: usize },
    #[error("line {line}: port is not a number")]
    BadPort { line: usize, source: ParseIntError },
    #[error("unknown key '{0}'")]
    UnknownKey(String),
}

fn parse_port(text: &str) -> Result<u16, ConfigError> {
    let mut port = 8080;
    for (i, raw) in text.lines().enumerate() {
        let line = i + 1;
        let (key, value) = raw.split_once('=').ok_or(ConfigError::NoEquals { line })?;
        match key.trim() {
            "port" => {
                let n: u16 = value.trim().parse().map_err(|source| ConfigError::BadPort { line, source })?;
                port = n;
            }
            "name" => {}
            other => return Err(ConfigError::UnknownKey(other.to_string())),
        }
    }
    Ok(port)
}

fn main() {
    for text in ["name = web\nport = 3000", "port 3000", "port = abc", "color = red"] {
        match parse_port(text) {
            Ok(p) => println!("ok: port {p}"),
            Err(e) => println!("error: {e}"),
        }
    }
}
text
     Running `target/debug/config`
ok: port 3000
error: line 1: missing '='
error: line 1: port is not a number
error: unknown key 'color'
  • 에러를 입문 24편의 enum 으로 만든다. 경우마다 필요한 정보(줄 번호, 원래 에러)를 칸으로 둔다.
  • #[derive(Error)] 와 경우마다 붙인 #[error("…")] 가 사람이 읽을 문장을 만든다(입문 33편의 Display 트레이트 구현을 대신 써 준다). {line}·{0} 자리는 칸 값으로 채워진다.
  • source 라는 이름의 칸은 "이 에러의 원인" 으로 기록된다. 숫자 변환 실패(ParseIntError)를 버리지 않고 안에 품는다.
  • 부르는 쪽은 match 로 경우마다 다르게 처리할 수 있다. 예를 들어 UnknownKey 만 경고로 넘기는 식이다. 라이브러리가 경우를 나눠 주는 이유가 이것이다.

split_once('=') 는 처음 나오는 = 를 기준으로 둘로 나눠 Option 으로 준다. ok_or(…)? 는 None 이면 그 에러로 바로 돌려준다.

02 anyhow — 앱에서는 설명을 붙여 위로

앱의 main 근처에서는 경우를 나누기보다 "무엇을 하다가 실패했는지" 가 중요하다.

rust
use anyhow::{Context, Result, bail};
use std::fs;

fn read_port(path: &str) -> Result<u16> {
    let text = fs::read_to_string(path).with_context(|| format!("cannot read config file {path}"))?;
    let value = text.trim().strip_prefix("port=").context("expected a line like port=8080")?;
    let port: u16 = value.parse().context("port must be a number")?;
    if port < 1024 {
        bail!("port {port} needs admin rights, use 1024 or above");
    }
    Ok(port)
}

fn main() -> Result<()> {
    println!("port: {}", read_port("good.conf")?);
    println!("port: {}", read_port("missing.conf")?);
    Ok(())
}
text
     Running `target/debug/config`
port: 3000
Error: cannot read config file missing.conf

Caused by:
    No such file or directory (os error 2)
  • anyhow::Result<T> 는 "어떤 에러든 담을 수 있는 Result" 다. 파일 에러·숫자 변환 에러가 섞여도 ? 한 글자로 올린다.
  • .with_context(|| …) 와 .context("…") 는 에러에 설명을 한 겹 씌운다. 원래 에러(No such file or directory)는 버리지 않고 Caused by: 아래에 남는다.
  • bail!("…") 은 그 자리에서 설명을 담은 에러를 돌려준다(return Err(anyhow!("…")) 의 줄임).
  • main 이 Result 를 돌려주고 Err 로 끝나면, 맨 위 설명과 원인 사슬을 찍고 종료 코드 1 로 끝난다(입문 31편과 같다).

03 실습 — 원인 사슬 읽기

같은 앱에 port=eighty 가 든 파일을 줘 보자.

text
     Running `target/debug/config`
port: 3000
Error: port must be a number

Caused by:
    invalid digit found in string

맨 위 줄은 우리가 붙인 설명(port must be a number), Caused by: 아래는 원래 에러(숫자가 아닌 글자)다. 설명이 없었다면 invalid digit found in string 한 줄만 남아, 어느 파일의 무엇이 틀렸는지 알 수 없었다.

thiserroranyhow
어디서라이브러리·모듈의 공개 함수앱(main 과 그 가까이)
무엇을경우를 나눈 에러 타입을 만든다어떤 에러든 받아 설명을 붙인다
부르는 쪽은match 로 경우마다 처리찍고 끝내거나 그대로 올린다

read_port 에 "port 가 65535 를 넘으면" 이라는 bail! 을 더해 보자. 사실 u16 이 이미 65535 까지만 받아서, 70000 을 넣으면 변환 단계(context)에서 먼저 걸린다. 어느 줄의 설명이 찍히는지 확인하면 사슬이 손에 익는다. 다음 편부터는 코드를 잘 쓰는 습관이다. 첫 번째는 clippy 와 rustfmt 다.