콘텐츠로 이동

Rust 사용자 정의 에러

Result 페이지에서는 모든 에러가 그냥 String이었어요. 작은 예제에서는 괜찮지만, 함수가 서로 다른 여러 가지 방식으로 실패할 수 있게 되면 금방 한계가 드러나요. String으로는 match할 수 없으니, 호출자가 텍스트를 직접 분석하지 않고서는 어떤 종류의 실패가 일어났는지 알 수 없거든요.

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 페이지에서 둘을 실제로 써 보고, 언제 어느 쪽을 고를지 알아볼 수 있어요.

두 배리언트 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자 이상이어야 해요
▶ 직접 실행해 보기

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