</>PatchNote
목록으로

2024-07-20

HSPACE Rust 특강 #6 — 오류 처리

RustHSPACE 2024Error HandlingResult

Rust의 오류 처리는 두 갈래로 나뉜다. 패닉Result. 이 구분이 무엇을 기준으로 하는지가 이번 회차의 핵심이었다.

  • 패닉 — 일어나서는 안 되는 일. 프로그램의 버그. 예: 배열 범위 초과, 0으로 나누기, Noneunwrap()
  • Result — 일어날 수 있는 일. 예: 파일이 없음, 네트워크 끊김, 파싱 실패

C++이나 Java의 예외는 둘을 한 메커니즘으로 다루지만, Rust는 애초에 다른 종류의 문제로 보고 다른 도구를 준다.

패닉

패닉이 발생하면 다음 순서로 진행된다.

  1. 오류 메시지를 터미널에 출력
  2. 스택을 되감기(unwind) — 스택에 있던 값들의 소멸자를 역순으로 호출
  3. 해당 스레드를 종료
fn pirate_share(total: u64, crew_size: usize) -> u64 {
    let half = total / 2;
    half / crew_size as u64      // crew_size가 0이면 패닉
}

중요한 건 패닉이 안전하다는 점이다. 되감기 과정에서 소멸자가 전부 호출되므로 메모리 누수나 자원 누수 없이 정리된다. 미정의 동작이 아니라 정의된 실패다.

패닉을 어떻게 처리할지는 Cargo.toml 로 고를 수 있다.

[profile.release]
panic = "abort"     # 되감기 없이 즉시 종료

abort 를 쓰면 되감기 코드가 사라져 바이너리가 작아진다. 임베디드나 커널처럼 바이너리 크기가 중요한 환경에서 쓰인다.

스레드 하나가 패닉해도 프로세스 전체가 죽는 것은 아니다. std::thread::spawn 으로 띄운 스레드가 패닉하면 그 스레드만 끝나고, join()Err 를 돌려준다. std::panic::catch_unwind 로 잡을 수도 있지만, 이건 예외 처리 대용이 아니라 FFI 경계에서 패닉이 새어 나가지 않게 막는 용도다.

Result

실패할 수 있는 함수는 그 사실을 반환 타입에 적는다.

enum Result<T, E> { Ok(T), Err(E) }

fn get_weather(location: LatLng) -> Result<WeatherReport, io::Error>

시그니처만 봐도 "이건 실패할 수 있고, 실패하면 io::Error 가 온다"를 알 수 있다. 호출자가 이걸 무시하려면 의도적으로 그래야 한다 — Result#[must_use] 라 그냥 버리면 경고가 뜬다.

Result를 다루는 방법들

1. match — 가장 명시적

match get_weather(hometown) {
    Ok(report) => display(&report),
    Err(err)   => println!("error: {}", err),
}

2. 메서드들 — 대부분의 경우 더 짧다

result.is_ok()              // 성공했는가
result.is_err()             // 실패했는가
result.ok()                 // Option<T>로 변환 (오류는 버림)
result.err()                // Option<E>로 변환
result.unwrap_or(fallback)  // 실패하면 기본값
result.unwrap_or_else(|e| ...)  // 실패하면 계산해서 기본값
result.unwrap()             // 실패하면 패닉
result.expect("메시지")      // 실패하면 이 메시지와 함께 패닉
result.map(|v| ...)         // Ok 값 변환
result.map_err(|e| ...)     // Err 값 변환

unwrap()expect() 는 "여기서 실패하면 그건 버그"라고 선언하는 것이다. 프로토타입에서는 자주 쓰지만, 실제 코드에서는 expect("왜 실패할 리 없는지") 형태로 이유를 적는 게 좋다는 얘기가 있었다.

3. ? 연산자 — 전파

이게 실전에서 가장 많이 쓰인다.

fn read_config() -> Result<Config, io::Error> {
    let mut file = File::open("config.toml")?;
    let mut text = String::new();
    file.read_to_string(&mut text)?;
    Ok(parse(&text))
}

? 는 이렇게 확장된다.

let mut file = match File::open("config.toml") {
    Ok(f)  => f,
    Err(e) => return Err(From::from(e)),
};

성공하면 값을 꺼내고, 실패하면 즉시 반환한다. 여기서 놓치기 쉬운 게 From::from(e) 부분이다. ? 는 오류를 반환 타입의 오류로 자동 변환한다. 이게 뒤에 나올 "여러 오류 타입 합치기"의 열쇠다.

?Option 에도 쓸 수 있고, 그때는 None 이면 None 을 반환한다.

main 에서의 오류 처리

mainResult 를 반환할 수 있다.

fn main() -> Result<(), Box<dyn Error>> {
    let config = read_config()?;
    run(config)?;
    Ok(())
}

Err 로 끝나면 오류를 출력하고 0이 아닌 종료 코드로 종료된다. main 에서 unwrap() 을 남발하는 대신 이 형태를 쓰면 오류 메시지가 훨씬 읽기 좋아진다.

오류 타입 별칭

같은 오류 타입을 계속 쓰면 별칭을 만든다.

pub type Result<T> = std::result::Result<T, MyError>;

// 이제 이렇게 쓸 수 있다
fn parse_line(s: &str) -> Result<Token> { ... }

표준 라이브러리도 std::io::Result<T> 를 이 방식으로 정의한다. 앞서 타입 별칭 회차에서 배운 게 여기서 쓰인다.

여러 오류 타입 다루기

실전에서 제일 자주 부딪히는 문제다. 함수 하나가 파일도 읽고 숫자도 파싱하면 io::ErrorParseIntError 두 종류가 나온다. 반환 타입에 뭘 적어야 할까?

방법 1: Box<dyn Error>

fn run() -> Result<(), Box<dyn Error>> {
    let text = std::fs::read_to_string("nums.txt")?;   // io::Error
    let n: i32 = text.trim().parse()?;                 // ParseIntError
    println!("{}", n * 2);
    Ok(())
}

두 오류 타입 모두 Error 트레잇을 구현하므로 Box<dyn Error> 로 자동 변환된다 (? 안의 From::from 이 이걸 해준다). 빠르고 간단하지만, 호출자가 오류의 구체적인 종류로 분기할 수 없다는 단점이 있다.

방법 2: 직접 enum 정의

라이브러리를 만든다면 이쪽이 맞다. 과제 #2의 "Rewriting with Result" 풀이가 정확히 이 형태다.

use thiserror::Error;

#[derive(Debug, Error)]
enum TokenizerError {
    #[error("Unexpected character '{0}' in input")]
    UnexpectedCharacter(char),
}

#[derive(Debug, Error)]
enum ParserError {
    #[error("Tokenizer error: {0}")]
    TokenizerError(#[from] TokenizerError),
    #[error("Unexpected end of input")]
    UnexpectedEOF,
    #[error("Unexpected token {0:?}")]
    UnexpectedToken(Token),
    #[error("Invalid number")]
    InvalidNumber(#[from] std::num::ParseIntError),
}

thiserror 크레이트가 Display 구현과 From 구현을 매크로로 만들어준다. #[from] 이 붙은 변형은 From<TokenizerError> for ParserError 를 자동 생성하고, 그 덕에 ? 하나로 하위 오류가 상위 오류로 승격된다.

fn parse_expr<'a>(tokens: &mut Tokenizer<'a>) -> Result<Expression, ParserError> {
    let tok = tokens.next().ok_or(ParserError::UnexpectedEOF)??;
    let expr = match tok {
        Token::Number(num) => {
            let v = num.parse()?;             // ParseIntError → ParserError
            Expression::Number(v)
        }
        Token::Identifier(ident) => Expression::Var(ident),
        Token::Operator(_) => return Err(ParserError::UnexpectedToken(tok)),
    };
    // ...
}

?? 가 두 번 붙은 첫 줄이 재미있다. tokens.next()Option<Result<Token, TokenizerError>> 를 돌려준다. 첫 ?ok_or(...) 로 만든 바깥 Result 를 풀고(입력이 끝났으면 UnexpectedEOF), 두 번째 ? 는 안쪽 Result 를 푼다(토큰화 오류면 #[from] 을 통해 ParserError 로 변환). 한 줄에 두 종류의 실패가 처리된다.

과제 힌트에서 TokenizerIterator<Item = Result<Token, TokenizerError>> 로 만들라고 한 것도 이 맥락이다. 이터레이터가 항목마다 실패할 수 있으면, Item 자체를 Result 로 두는 게 Rust의 관용구다.

애플리케이션이면 anyhow, 라이브러리면 thiserror 라는 구분도 배웠다. 애플리케이션은 어차피 최종적으로 사람에게 오류를 보여주고 끝이라 anyhow::Error 하나로 뭉뚱그려도 되지만, 라이브러리는 호출자가 오류 종류로 분기할 수 있어야 하므로 enum 으로 정확히 정의해야 한다.

"일어날 리 없는" 오류

가끔 실패가 논리적으로 불가능한 자리가 있다.

// 이 문자열은 우리가 방금 숫자로만 만들었다
let n: i32 = digits.parse().unwrap();

이때는 unwrap() 을 쓰되, expect("digits는 항상 숫자만 포함한다") 처럼 왜 불가능한지를 적어두는 게 좋다. 나중에 이 가정이 깨졌을 때 패닉 메시지가 바로 원인을 알려준다.

오류를 무시하기

정말로 무시하고 싶으면 명시해야 한다.

let _ = writeln!(stderr(), "error: {}", err);   // 실패해도 어쩔 수 없다

let _ = 없이 그냥 두면 unused_must_use 경고가 뜬다. 무시하는 것도 의도가 코드에 남는다.

왜 Result인가

예외와 비교하면 Result 의 장단이 분명하다.

예외Result
실패 가능성 표시시그니처에 없음 (Java의 checked 예외 제외)반환 타입에 있음
무시조용히 전파됨경고가 뜸
제어 흐름비지역적 점프평범한 반환
비용던질 때 비쌈, 안 던지면 0항상 작은 분기 비용

가장 큰 차이는 코드를 읽을 때 어디서 실패할 수 있는지 보인다는 점이다. ? 가 붙은 자리가 곧 "여기서 함수가 중단될 수 있다"는 표시다. 예외 기반 코드에서 호출 한 줄이 스택을 세 층 걷어내는 걸 추적해본 사람이라면 이 차이가 얼마나 큰지 안다.

물론 ? 를 남발하면 오류 문맥이 사라지는 문제가 생긴다. 그래서 anyhow.context("설정 파일 읽기 실패") 같은 도구가 있는 것이다.

정리

  • 패닉은 버그, Result 는 예상 가능한 실패. 도구가 다르다
  • 패닉도 되감기를 거쳐 안전하게 정리된다
  • ? 는 "성공하면 꺼내고 실패하면 반환" + 오류 타입 자동 변환
  • 여러 오류를 합칠 땐 Box<dyn Error>(빠르게) 또는 커스텀 enum(정확하게)
  • thiserror#[from]? 의 자동 변환과 맞물린다

다음 편은 크레이트와 모듈 — 코드를 어떻게 나누고 어떻게 공개할 것인가.