2024-07-20
HSPACE Rust 특강 #6 — 오류 처리
Rust의 오류 처리는 두 갈래로 나뉜다. 패닉과 Result. 이 구분이 무엇을
기준으로 하는지가 이번 회차의 핵심이었다.
- 패닉 — 일어나서는 안 되는 일. 프로그램의 버그. 예: 배열 범위 초과,
0으로 나누기,
None에unwrap() Result— 일어날 수 있는 일. 예: 파일이 없음, 네트워크 끊김, 파싱 실패
C++이나 Java의 예외는 둘을 한 메커니즘으로 다루지만, Rust는 애초에 다른 종류의 문제로 보고 다른 도구를 준다.
패닉
패닉이 발생하면 다음 순서로 진행된다.
- 오류 메시지를 터미널에 출력
- 스택을 되감기(unwind) — 스택에 있던 값들의 소멸자를 역순으로 호출
- 해당 스레드를 종료
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 에서의 오류 처리
main 도 Result 를 반환할 수 있다.
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::Error 와 ParseIntError 두 종류가 나온다. 반환 타입에 뭘 적어야 할까?
방법 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 로 변환). 한 줄에 두 종류의 실패가 처리된다.
과제 힌트에서 Tokenizer 를 Iterator<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]이?의 자동 변환과 맞물린다
다음 편은 크레이트와 모듈 — 코드를 어떻게 나누고 어떻게 공개할 것인가.