Rust 사용자 정의 에러
Result 페이지에서는 모든 에러가 그냥 String이었어요. 작은 예제에서는 괜찮지만, 함수가 서로 다른 여러 가지 방식으로 실패할 수 있게 되면 금방 한계가 드러나요. String으로는 match할 수 없으니, 호출자가 텍스트를 직접 분석하지 않고서는 어떤 종류의 실패가 일어났는지 알 수 없거든요.
String 에러의 문제
섹션 제목: “String 에러의 문제”fn withdraw(balance: f64, amount: f64) -> Result<f64, String> { if amount > balance { return Err(String::from("잔액이 부족해요")); } if amount < 0.0 { return Err(String::from("금액은 음수일 수 없어요")); } Ok(balance - amount)}호출자가 “잔액 부족”과 “음수 금액”에 서로 다르게 대응하고 싶다면, 할 수 있는 건 에러 문자열 자체를 비교하는 것뿐이에요. 이건 깨지기 쉬워요. 메시지 문구를 조금만 바꿔도 바로 동작하지 않게 되니까요.
에러를 열거형으로 정의하기
섹션 제목: “에러를 열거형으로 정의하기”열거형으로 이 문제를 해결할 수 있어요. 실패의 종류마다 배리언트가 하나씩 생기고, 호출자는 그걸 바로 match할 수 있어요.
enum WithdrawError { InsufficientFunds, NegativeAmount,}
fn withdraw(balance: f64, amount: f64) -> Result<f64, WithdrawError> { if amount < 0.0 { return Err(WithdrawError::NegativeAmount); } if amount > balance { return Err(WithdrawError::InsufficientFunds); } Ok(balance - amount)}
fn main() { match withdraw(100.0, 150.0) { Ok(remaining) => println!("새 잔액: {remaining}"), Err(WithdrawError::InsufficientFunds) => println!("돈이 부족해요"), Err(WithdrawError::NegativeAmount) => println!("음수 금액은 출금할 수 없어요"), }}실행 결과:
돈이 부족해요이제 컴파일러가 모든 종류의 실패를 처리했는지 검사해 줘요. 열거형에 match를 쓸 때 늘 받던 보장을, 이번에는 에러에 적용한 것뿐이에요.
제대로 된 에러 메시지를 위해 Display 구현하기
섹션 제목: “제대로 된 에러 메시지를 위해 Display 구현하기”지금 WithdrawError를 {}로 출력하려고 하면 컴파일되지 않아요. Rust가 이걸 텍스트로 바꾸는 방법을 모르거든요. Display 트레이트를 구현하면 해결돼요. 트레이트는 곧 별도의 페이지(트레이트, 자주 쓰는 트레이트)에서 다뤄요. 지금은 이 패턴을 그대로 따라 써도 괜찮아요.
use std::fmt;
enum WithdrawError { InsufficientFunds, NegativeAmount,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InsufficientFunds => write!(f, "잔액이 부족해요"), WithdrawError::NegativeAmount => write!(f, "금액은 음수일 수 없어요"), } }}
fn main() { let error = WithdrawError::InsufficientFunds; println!("에러: {error}");}실행 결과:
에러: 잔액이 부족해요이제 WithdrawError는 사용자에게 보여 주거나 로그에 남기고 싶은 다른 에러들처럼 출력할 수 있어요. 그러면서도 특정 경우에 따라 다르게 대응해야 하는 코드에서는 여전히 match로 구분할 수 있어요.
크레이트가 도와줄 때
섹션 제목: “크레이트가 도와줄 때”실제 프로젝트에서 Display와 그 주변의 반복되는 코드를 사용자 정의 에러 타입마다 손으로 쓰다 보면 꽤 지루해져요. thiserror 크레이트(영어)는 #[derive] 하나로 대부분을 대신 만들어 주고, anyhow 크레이트(영어)는 에러마다 타입을 정의하지 않고 에러를 전파하기만 하면 되는 애플리케이션 코드에서 인기가 많아요. 이런 게 있다는 것만 알아 두면 되고, 기초를 배우는 데 꼭 필요한 건 아니에요. 준비가 되면 anyhow와 thiserror 페이지에서 둘을 실제로 써 보고, 언제 어느 쪽을 고를지 알아볼 수 있어요.
연습 문제
섹션 제목: “연습 문제”1. 로그인 에러
섹션 제목: “1. 로그인 에러”두 배리언트 EmptyUsername과 PasswordTooShort를 가진 열거형 LoginError를 정의하고, Display를 구현해 보세요. 그런 다음 환영 메시지와 함께 Ok를 반환하거나, 알맞은 에러를 반환하는 fn check_login(user: &str, password: &str) -> Result<String, LoginError>를 작성하세요. 비밀번호는 8자 이상이어야 해요. 힌트: &str의 .is_empty()와 .len()이면 필요한 검사를 모두 할 수 있어요. .len()은 바이트 수를 세기 때문에, 비밀번호는 영어와 숫자로 두었어요.
정답 보기
use std::fmt;
enum LoginError { EmptyUsername, PasswordTooShort,}
impl fmt::Display for LoginError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { LoginError::EmptyUsername => write!(f, "사용자 이름은 비어 있을 수 없어요"), LoginError::PasswordTooShort => write!(f, "비밀번호는 8자 이상이어야 해요"), } }}
fn check_login(user: &str, password: &str) -> Result<String, LoginError> { if user.is_empty() { return Err(LoginError::EmptyUsername); } if password.len() < 8 { return Err(LoginError::PasswordTooShort); } Ok(format!("{user}님, 환영해요!"))}
fn main() { let attempts = [("ana", "correct-horse"), ("", "secret123"), ("ben", "short")];
for (user, password) in attempts { match check_login(user, password) { Ok(message) => println!("{message}"), Err(e) => println!("에러: {e}"), } }}실행 결과:
ana님, 환영해요!에러: 사용자 이름은 비어 있을 수 없어요에러: 비밀번호는 8자 이상이어야 해요2. 자세한 정보를 담은 에러
섹션 제목: “2. 자세한 정보를 담은 에러”fn parse_percent(input: &str) -> Result<u32, PercentError>를 작성해 보세요. PercentError에는 데이터를 담는 배리언트 두 개가 있어요. NotANumber(String)(잘못된 텍스트)와 TooLarge(u32)(100을 넘은 숫자)예요. Display 메시지에 그 정보가 들어가도록 만드세요. 힌트: 열거형 페이지에서 봤듯이 배리언트는 값을 담을 수 있어요. fmt 안에서 PercentError::TooLarge(n) => ...처럼 match하면 값을 꺼낼 수 있어요.
정답 보기
use std::fmt;
enum PercentError { NotANumber(String), TooLarge(u32),}
impl fmt::Display for PercentError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { PercentError::NotANumber(text) => write!(f, "'{text}'은(는) 숫자가 아니에요"), PercentError::TooLarge(n) => write!(f, "{n}은(는) 100보다 커요"), } }}
fn parse_percent(input: &str) -> Result<u32, PercentError> { match input.parse::<u32>() { Ok(n) => { if n > 100 { Err(PercentError::TooLarge(n)) } else { Ok(n) } } Err(_) => Err(PercentError::NotANumber(input.to_string())), }}
fn main() { for input in ["75", "절반", "250"] { match parse_percent(input) { Ok(n) => println!("{n}%"), Err(e) => println!("에러: {e}"), } }}실행 결과:
75%에러: '절반'은(는) 숫자가 아니에요에러: 250은(는) 100보다 커요String에러는 작은 예제에서는 쓸 만하지만, 호출자가 에러 종류별로 구분해서match할 수 없어요.- 실패의 종류마다 배리언트를 하나씩 둔 열거형을 정의하면, 호출자가 구체적인 에러에
match할 수 있어요. Display를 구현하면(impl fmt::Display for YourError) 에러 타입을{}로 출력할 수 있어요.- 큰 프로젝트에서는
thiserror와anyhow같은 크레이트가 반복되는 코드를 대부분 없애 줘요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 함수가 여러 가지 방식으로 실패할 수 있게 되면, String 에러 타입이 부족해지는 이유는 무엇인가요?
2. withdraw의 호출자가 InsufficientFunds와 NegativeAmount에 서로 다르게 대응할 수 있게 해 주는 것은 무엇인가요?
3. 사용자 정의 에러 타입에 Display를 구현하면 무엇을 할 수 있나요?
4. thiserror와 anyhow 같은 크레이트는 무엇을 도와주나요?
5. 에러 메시지 문자열을 그대로 비교하는 방식이 깨지기 쉽다고 하는 이유는 무엇인가요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- Defining an error type — Rust by Example (영어):
Display를 구현한 사용자 정의 에러 타입의 짧은 실행 예제예요. - Wrapping errors — Rust by Example (영어): 다음 단계예요. 다른 에러를 감싸고,
From을 통해?와 함께 쓸 수 있는 에러 열거형을 보여 줘요. std::error::Error— 표준 라이브러리 문서 (영어): “제대로 된” 에러 타입이 구현하는 표준 트레이트예요.source()메서드가 무엇을 위한 건지도 설명해요.- panic!이냐, panic!이 아니냐, 그것이 문제로다 — The Rust Programming Language 한국어판: 언제 에러를 반환하고 언제 프로그램을 멈출지에 대한 가이드라인이에요.
- thiserror — docs.rs (영어): 에러 열거형에
Display와Error를 derive해 주는 크레이트예요. 직접 써야 할 코드가 훨씬 줄어요.