Rust 에러 처리 정리 - Option, Result, ? 연산자 제대로 이해하기

목차

이번 글은 앞서 진행한 실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 연재에서 충분히 깊게 다루지 못했던 Rust 개념을 다시 정리하는 첫 번째 심화 글입니다.

들어가며

웹 스크래퍼 연재를 끝내고 가장 먼저 다시 정리해보고 싶었던 주제가 있습니다.

바로 Rust의 에러 처리입니다.

스크래퍼를 만들면서 우리는 자연스럽게 이런 코드들을 사용했습니다.

Some(value)
None

Ok(value)
Err(error)

.unwrap()
.unwrap_or(...)

.ok_or_else(...)

?

.into()

처음에는 각각 필요한 순간에 하나씩 사용했기 때문에 코드 자체를 이해하는 데 큰 문제는 없었습니다.

그런데 어느 순간 비슷한 이름들이 한꺼번에 늘어나기 시작합니다.

Option
Result
unwrap
unwrap_or
ok_or
ok_or_else
map
map_err
and_then
?

하나씩 보면 어렵지 않은데 서로 어떤 관계인지 머릿속에서 한 번에 연결하려고 하면 꽤 헷갈립니다.

저 역시 웹 스크래퍼의 에러 처리를 정리하면서,

이걸 한 번 지도로 펼쳐놓고 보면 훨씬 이해하기 쉽겠는데?

라는 생각이 들었습니다.

그래서 이번 글에서는 새로운 프로그램을 만드는 대신 Rust가 “값이 없거나 작업이 실패할 수 있는 상황”을 어떻게 표현하는지 처음부터 다시 정리해보겠습니다.

최종적으로는 실제 개발 과정에서 아래와 같은 판단 과정을 통해 올바른 에러 처리 코드 작성을 익히는 것을 목표로 하겠습니다.

값이 없을 수 있다

Option<T>

작업이 실패할 수 있다

Result<T, E>

없음을 오류로 취급하고 싶다

ok_or / ok_or_else

성공한 값을 변환하고 싶다

map

오류를 변환하고 싶다

map_err

연속해서 실패 가능한 작업을 하고 싶다

and_then

실패하면 현재 함수에서 바로 돌려보내고 싶다

?

여러 종류의 오류를 하나로 받고 싶다

Box<dyn Error>

이번에는 각각을 따로 외우기보다 이 연결 관계에 집중해보겠습니다.


가장 먼저 구분할 것: OptionResult

Rust 에러 처리를 이해하려면 가장 먼저 OptionResult의 역할을 구분해야 합니다.

둘 다,

원하는 값이 항상 나오지는 않을 수 있다.

는 상황을 표현합니다.

하지만 왜 값이 나오지 않았는가에 차이가 있습니다.


Option<T>: 값이 있을 수도 있고 없을 수도 있다

Option<T>은 다음 두 상태를 가집니다.

Some(T)
None

예를 들어 책에 설명이 있을 수도 있고 없을 수도 있다고 해보겠습니다.

let description =
    Some("재미있는 Rust 책");

설명이 없다면,

let description: Option<&str> =
    None;

이 됩니다.

즉,

Option<T>

Some(T)

값이 있음

None

값이 없음

입니다.

여기서 중요한 것은 None이 반드시 오류를 의미하지는 않는다는 점입니다.

책 설명은 원래 선택적인 데이터일 수 있습니다.

설명 있음
→ 정상

설명 없음
→ 이것도 정상

이런 상황에는 Option이 잘 어울립니다.


Result<T, E>: 작업이 성공하거나 실패한다

반면 Result<T, E>는 어떤 작업의 성공과 실패를 표현합니다.

Ok(T)
Err(E)

예를 들어 문자열을 숫자로 변환해보겠습니다.

let price =
    "51.77".parse::<f64>();

parse()의 결과는 바로 f64가 아닙니다.

문자열이 숫자가 아닐 수도 있기 때문입니다.

성공하면,

Ok(51.77)

실패하면,

Err(...)

가 됩니다.

구조를 보면,

Result<T, E>

Ok(T)

작업 성공

Err(E)

작업 실패

입니다.

즉 가장 먼저 이렇게 구분하면 됩니다.

Option
= 값이 있는가?

Result
= 작업이 성공했는가?

직접 실행해봅시다

새로운 Rust 프로젝트를 하나 만들어도 좋고 기존 연습 프로젝트의 main.rs를 사용해도 좋습니다.

fn main() {
    let description =
        Some("Rust 입문서");

    let price =
        "51.77"
            .parse::<f64>();

    println!("{description:?}");
    println!("{price:?}");
}

실행하면 대략 다음과 같이 나옵니다.

Some("Rust 입문서")
Ok(51.77)

이번에는 가격을 잘못된 문자열로 바꿔보겠습니다.

let price =
    "가격 문의"
        .parse::<f64>();

이제 결과는 Err(...)가 됩니다.

이처럼 OptionResult는 예외를 숨기는 대신 값의 타입 자체에 실패 가능성을 표시합니다.


match로 가장 정직하게 열어봅시다

OptionResult 안의 값을 꺼내는 가장 기본적인 방법은 match입니다.

먼저 Option입니다.

let description =
    Some("Rust 입문서");

match description {
    Some(text) => {
        println!("설명: {text}");
    }

    None => {
        println!("설명이 없습니다.");
    }
}

Result도 비슷합니다.

let price =
    "51.77"
        .parse::<f64>();

match price {
    Ok(value) => {
        println!("가격: {value}");
    }

    Err(error) => {
        println!(
            "가격 변환 실패: {error}"
        );
    }
}

match를 이용하면 가능한 경우를 직접 모두 적어주기 때문에 무슨 일이 일어나는지 가장 명확하게 볼 수 있습니다.

그래서 새로운 타입이나 메서드를 이해할 때는 우선 match로 풀어보는 습관도 꽤 도움이 됩니다.


unwrap()은 무엇을 생략하는 것일까요?

우리는 웹 스크래퍼 초반에 unwrap()을 많이 사용했습니다.

let price =
    "51.77"
        .parse::<f64>()
        .unwrap();

이 코드는 성공하면 안의 값을 바로 꺼냅니다.

즉,

Ok(51.77)

에서,

51.77

만 가져옵니다.

Option에서도 같습니다.

let description =
    Some("Rust 입문서")
        .unwrap();

결과는,

Rust 입문서

입니다.

문제는 실패했을 때입니다.

let price =
    "가격 문의"
        .parse::<f64>()
        .unwrap();

Errunwrap()을 사용하면 프로그램은 panic을 일으킵니다.

None도 마찬가지입니다.

let value: Option<i32> =
    None;

let number =
    value.unwrap();

역시 panic이 발생합니다.

따라서 unwrap()은 개념적으로 다음과 같습니다.

Some / Ok

안의 값 꺼냄

None / Err

panic

그러면 unwrap()은 나쁜 코드일까요?

꼭 그렇지는 않습니다.

11편에서도 이야기했듯 중요한 것은,

unwrap이라는 글자를 없애는 것

이 아닙니다.

이 값이 실패할 가능성이 실제 실행 중 존재하는가를 생각하는 것이 중요합니다.

예를 들어 우리가 직접 코드에 적은 고정된 CSS 선택자라면,

Selector::parse(
    "p.price_color"
)
.unwrap();

프로그램이 정상적으로 작성됐다면 이 문자열은 항상 유효해야 합니다.

반면 외부 웹사이트에서 가져온,

element
    .value()
    .attr("href")

는 실제로 값이 없을 수 있습니다.

이런 외부 데이터에 무조건 unwrap()을 사용하는 것은 위험할 수 있습니다.


expect()unwrap()에 설명을 붙인 형태입니다

실패하면 프로그램을 중단시키고 싶은데 이유를 좀 더 명확히 남기고 싶다면 expect()를 사용할 수 있습니다.

let price =
    "51.77"
        .parse::<f64>()
        .expect(
            "가격은 숫자여야 합니다."
        );

실패하면 우리가 작성한 메시지도 함께 나타납니다.

개념적으로는,

unwrap()
= 실패하면 panic

expect("이유")
= 실패하면 이유를 표시하며 panic

입니다.

실패가 개발자의 잘못이라고 확신할 수 있는 경우라면 expect()가 의도를 더 잘 드러내기도 합니다.


실패해도 기본값을 쓰고 싶다면 unwrap_or()

이번에는 설명이 없는 책을 생각해보겠습니다.

let description: Option<&str> =
    None;

설명이 없다고 프로그램을 중단할 이유는 없습니다.

그냥 기본값을 사용하면 됩니다.

let text =
    description
        .unwrap_or(
            "설명이 없습니다."
        );

println!("{text}");

결과는,

설명이 없습니다.

입니다.

값이 있었다면 원래 값을 사용합니다.

let description =
    Some("Rust 입문서");

let text =
    description
        .unwrap_or(
            "설명이 없습니다."
        );

결과는,

Rust 입문서

입니다.

따라서,

unwrap()

없으면 panic

unwrap_or(default)

없으면 default

라는 차이가 있습니다.


기본값을 계산해야 한다면 unwrap_or_else()

기본값이 단순 문자열이 아니라 어떤 함수를 실행해서 만들어야 한다면 unwrap_or_else()를 사용할 수 있습니다.

fn default_description() -> String {
    println!("기본 설명 생성");

    "설명이 없습니다."
        .to_string()
}

fn main() {
    let description:
        Option<String> =
        None;

    let text =
        description
            .unwrap_or_else(
                default_description
            );

    println!("{text}");
}

unwrap_or_else()는 값이 없는 경우에만 함수를 호출합니다.

이 차이를 흔히,

unwrap_or
= 기본값을 바로 받음

unwrap_or_else
= 필요할 때 실행할 함수를 받음

으로 기억할 수 있습니다.

이런 차이는 조금 뒤에 나올 ok_or()ok_or_else()에서도 그대로 등장합니다.


OptionResult로 바꾸고 싶을 때

웹 스크래퍼에서 아주 중요한 상황을 다시 생각해보겠습니다.

다음 코드는 HTML에서 첫 번째 요소를 찾습니다.

document
    .select(&selector)
    .next()

next()는 요소가 있을 수도 있고 없을 수도 있기 때문에 Option을 반환합니다.

찾음
→ Some(element)

못 찾음
→ None

그런데 책 제목처럼 반드시 필요한 데이터라면 단순히,

없음

으로 끝내고 싶지 않을 수 있습니다.

우리는,

책 제목을 찾을 수 없습니다.

라는 오류로 만들고 싶습니다.

이때 사용하는 것이,

ok_or()
ok_or_else()

입니다.


ok_or()OptionResult로 바꾸기

간단한 예제를 보겠습니다.

let title =
    Some("Rust Book");

let result =
    title.ok_or(
        "제목이 없습니다."
    );

println!("{result:?}");

결과는,

Ok("Rust Book")

입니다.

이번에는,

let title: Option<&str> =
    None;

으로 바꾸면,

Err("제목이 없습니다.")

가 됩니다.

즉,

Option<T>

ok_or(error)

Result<T, E>


Some(T)
→ Ok(T)

None
→ Err(error)

입니다.

이 변환은 Rust 에러 처리에서 굉장히 자주 사용됩니다.


ok_or_else()는 오류를 필요할 때 만듭니다

ok_or_else()의 역할도 거의 같습니다.

let title: Option<&str> =
    None;

let result =
    title.ok_or_else(|| {
        "제목이 없습니다."
            .to_string()
    });

차이는 오류 값을 직접 받는 대신 오류를 만드는 closure를 받는다는 점입니다.

ok_or(error)
= error 값을 미리 준비

ok_or_else(|| error)
= 필요할 때 error 생성

따라서 오류를 만드는 데 함수 호출이나 문자열 생성 같은 작업이 필요하다면 ok_or_else()가 유용합니다.

우리가 스크래퍼에서 사용했던,

.next()
.ok_or_else(|| {
    invalid_data(
        "책 제목을 찾을 수 없습니다."
    )
})?

도 이제 조금 다르게 보입니다.

요소 탐색

Option<Element>

없다면 오류 생성

Result<Element, Error>

실패하면 ?

호출한 쪽으로 오류 전달

이라는 과정입니다.


값을 바꾸고 싶다면 map()

이번에는 Option<String> 안에 있는 문자열의 길이를 구하고 싶다고 해보겠습니다.

직접 match를 사용하면,

let title =
    Some("Rust");

let length =
    match title {
        Some(text) => {
            Some(text.len())
        }

        None => None,
    };

입니다.

이 패턴은 꽤 자주 등장합니다.

Some 안의 값만 변환
None은 그대로

그래서 map()이 있습니다.

let title =
    Some("Rust");

let length =
    title.map(|text| {
        text.len()
    });

println!("{length:?}");

결과는,

Some(4)

입니다.

None이었다면 closure는 실행되지 않고 결과도 그대로 None입니다.

Some(T)
    ↓ map
Some(U)

None

None

Result에서도 map()을 사용할 수 있습니다

Result에서 map()은 성공 값만 변환합니다.

let price =
    "51.77"
        .parse::<f64>();

let doubled =
    price.map(|value| {
        value * 2.0
    });

성공했다면,

Ok(103.54)

가 됩니다.

원래 결과가 Err라면 오류는 그대로 전달되고 closure는 실행되지 않습니다.

즉,

Ok(T)
    ↓ map
Ok(U)

Err(E)

Err(E)

입니다.


오류 쪽을 바꾸고 싶다면 map_err()

map()이 성공 값을 바꾸는 메서드라면 map_err()는 반대입니다.

let price =
    "가격 문의"
        .parse::<f64>()
        .map_err(|error| {
            format!(
                "가격 파싱 실패: {error}"
            )
        });

println!("{price:?}");

성공 값은 그대로 두고 오류만 다른 형태로 바꿉니다.

Ok(T)

Ok(T)

Err(E)
    ↓ map_err
Err(F)

에러에 더 많은 문맥을 붙이고 싶을 때 유용합니다.

예를 들어,

fn parse_price(
    text: &str
) -> Result<f64, String> {
    text
        .trim_start_matches('£')
        .parse::<f64>()
        .map_err(|error| {
            format!(
                "가격 '{text}' 변환 실패: {error}"
            )
        })
}

라고 작성하면 단순히 숫자 변환에 실패했다는 것보다 어떤 값에서 문제가 발생했는지 알기 쉬워집니다.


실패할 수 있는 작업을 이어 붙이는 and_then()

이번에는 조금 다른 상황을 보겠습니다.

먼저 문자열을 숫자로 변환하고,

그 숫자가 0보다 큰지도 확인하고 싶습니다.

각 작업이 모두 실패할 수 있다고 해보겠습니다.

fn positive_price(
    value: f64
) -> Result<f64, String> {
    if value > 0.0 {
        Ok(value)
    } else {
        Err(
            "가격은 0보다 커야 합니다."
                .to_string()
        )
    }
}

이제,

let result =
    "51.77"
        .parse::<f64>()
        .map_err(|error| {
            error.to_string()
        })
        .and_then(
            positive_price
        );

처럼 연결할 수 있습니다.

and_then()은 앞의 결과가 Ok일 때만 다음 함수를 실행합니다.

Ok(T)

다음 함수 실행

Result<U, E>

Err(E)

다음 함수 실행 안 함

Err(E)

그래서 여러 단계의 실패 가능한 작업을 연결하는 데 사용할 수 있습니다.

일부 언어나 라이브러리에서는 이런 동작을 flatMap이라고 부르기도 합니다.


그런데 and_then()만 계속 쓰면 코드가 복잡해지기도 합니다

예를 들어 URL을 읽고 파일을 읽고 숫자를 파싱하는 과정이 계속 이어진다고 생각해보겠습니다.

and_then()만으로도 작성할 수 있지만 중첩이 깊어지면 읽기가 쉽지 않습니다.

Rust에는 이런 상황을 훨씬 간단하게 만드는 문법이 있습니다.

우리가 연재 내내 사용했던,

?

입니다.


?는 무엇을 하는 걸까요?

다음 함수를 보겠습니다.

fn parse_price(
    text: &str
) -> Result<f64, std::num::ParseFloatError> {
    let price =
        text.parse::<f64>()?;

    Ok(price)
}

핵심은,

text.parse::<f64>()?

입니다.

parse()가 성공하면 Ok 안의 값만 꺼내 price에 넣습니다.

실패하면 현재 함수를 즉시 빠져나가 Err를 호출한 쪽으로 돌려보냅니다.

즉 개념적으로,

let price =
    match text.parse::<f64>() {
        Ok(value) => value,

        Err(error) => {
            return Err(error);
        }
    };

와 비슷한 일을 합니다.

그래서 ?를 저는 다음처럼 이해하는 편이 가장 쉽다고 생각합니다.

성공

안의 값을 꺼내 다음 코드 진행

실패

현재 함수에서 즉시 반환

?는 오류를 없애는 문법이 아닙니다

처음 ?를 보면,

오류를 알아서 처리해주는 문법인가?

싶을 수 있습니다.

그렇지는 않습니다.

?는 오류를 해결하지 않습니다.

현재 위치에서 처리하지 않고 호출한 쪽으로 전달합니다.

오류 발생

내가 여기서 해결하지 않음

?

호출한 함수에게 전달

따라서 어느 단계에서는 결국 그 오류를 처리하거나 프로그램 종료로 이어져야 합니다.


Option에서도 ?를 사용할 수 있습니다

웹 스크래퍼 9편에서 이 형태를 사용했습니다.

fn find_next(
    values: &[i32],
) -> Option<i32> {
    let first =
        values.first()?;

    Some(*first)
}

값이 있다면,

Some(&value)

value 사용

값이 없다면,

None

함수에서 바로 None 반환

이 됩니다.

따라서 ?Result 전용 문법이 아닙니다.

우리가 자주 사용하는 두 경우를 비교하면,

Result에서 ?

Ok(value)
→ value

Err(error)
→ Err(error) 반환
Option에서 ?

Some(value)
→ value

None
→ None 반환

입니다.


Option 함수 안에서 Result?를 바로 쓸 수 없을까요?

다음 코드는 문제가 있습니다.

fn example()
    -> Option<f64>
{
    let value =
        "51.77"
            .parse::<f64>()?;

    Some(value)
}

parse()는,

Result<f64, _>

을 반환합니다.

하지만 함수는,

Option<f64>

을 반환합니다.

실패했을 때,

Err(error)

를 반환해야 할지,

None

을 반환해야 할지 현재 코드만으로 알 수 없습니다.

?는 아무 타입이나 마법처럼 변환하는 문법이 아닙니다.

현재 함수가 표현하는 실패 방식과 맞아야 합니다.


Result 안에서 서로 다른 오류는 어떻게 ?로 전달될까요?

11편에서는 이런 반환형을 만들었습니다.

type AppResult<T> =
    Result<
        T,
        Box<dyn std::error::Error
            + Send
            + Sync>
    >;

그런데 함수 안에서는,

Url::parse(...)?

도 사용하고,

.parse::<f64>()?

도 사용했습니다.

둘의 오류 타입은 서로 다릅니다.

그런데도 ?가 동작했습니다.

Result에서 ?를 사용할 때 Rust는 필요한 경우 오류 타입을 변환해서 호출한 쪽으로 전달할 수 있습니다.

이 과정에서 From 계열의 변환이 사용됩니다.

그래서 여러 표준 오류를,

Box<dyn Error + Send + Sync>

처럼 하나의 공통 오류 타입으로 받을 수 있었습니다.


.into()도 같은 타입 변환 흐름에 있습니다

11편의 코드를 다시 보겠습니다.

return Err(
    invalid_data(
        "알 수 없는 별점입니다."
    )
    .into()
);

invalid_data()는,

std::io::Error

를 반환합니다.

하지만 현재 함수가 요구하는 오류 타입은,

Box<dyn Error + Send + Sync>

입니다.

그래서,

.into()

를 이용해 필요한 타입으로 변환합니다.

std::io::Error

.into()

Box<dyn Error + Send + Sync>

.into()Into trait을 이용하는 일반적인 타입 변환 방법입니다.


Box<dyn Error>는 왜 사용했을까요?

실제 프로그램에서는 오류가 한 종류만 발생하지 않습니다.

HTTP 오류
파일 오류
URL 오류
정수 변환 오류
실수 변환 오류
직접 만든 오류

이 오류들은 서로 다른 타입입니다.

그런데 함수마다,

Result<T, reqwest::Error>
Result<T, io::Error>

처럼 각각 다른 반환형을 사용하면 여러 작업을 한 함수에서 묶을 때 점점 복잡해질 수 있습니다.

그래서 연재에서는,

Box<dyn Error + Send + Sync>

를 이용해 여러 오류를 공통된 오류 타입으로 다뤘습니다.

여기서는 에러 처리의 흐름만 기억해두겠습니다.

여러 구체적인 오류

공통 Error trait

Box<dyn Error>

trait, dyn, Box 자체는 각각 다룰 내용이 많기 때문에 뒤의 부록에서 다시 자세히 살펴보겠습니다.


or_else()는 실패했을 때 다른 방법을 시도합니다

and_then()이 성공했을 때 다음 작업을 이어간다면 or_else()는 반대 방향입니다.

let result:
    Result<i32, &str> =
    Err("첫 번째 방법 실패");

let recovered =
    result.or_else(|_| {
        Ok::<i32, &str>(100)
    });

println!("{recovered:?}");

결과는,

Ok(100)

입니다.

개념적으로,

Ok

기존 성공값 유지

Err

다른 방법 실행

입니다.

따라서 fallback이나 복구 로직을 표현할 때 사용할 수 있습니다.

Option에도 같은 이름의 or_else()가 있습니다.


여기까지 한 장으로 정리해봅시다

지금까지 등장한 주요 도구를 기능별로 모으면 이렇습니다.

목적OptionResult
값/성공 여부Some, NoneOk, Err
모든 경우 직접 처리matchmatch
성공값 강제로 꺼내기unwrap()unwrap()
실패 메시지와 panicexpect()expect()
기본값 사용unwrap_or()unwrap_or()
기본값을 필요할 때 생성unwrap_or_else()unwrap_or_else()
성공값 변환map()map()
오류값 변환해당 없음map_err()
다음 실패 가능 작업 연결and_then()and_then()
대체 경로 사용or_else()or_else()
실패 전파??
Option → Resultok_or(), ok_or_else()-

처음에는 메서드가 많아 보이지만 대부분,

성공 쪽을 만질 것인가?
실패 쪽을 만질 것인가?
기본값을 사용할 것인가?
실패를 전달할 것인가?

중 하나입니다.


직접 만들어봅시다

지금까지는 각각의 문법을 따로 봤습니다.

이번에는 작은 프로그램 하나에서 모두 연결해보겠습니다.

웹 스크래퍼 본편과 비슷하게 문자열로 들어온 책 데이터를 안전하게 변환하는 프로그램을 만들어보겠습니다.

외부 crate는 사용하지 않습니다.


목표

다음과 같은 데이터가 들어온다고 가정합니다.

title = "The Rust Book"
price = "39.90"
rating = "5"
description = None

우리가 원하는 최종 자료형은 다음과 같습니다.

struct Book {
    title: String,
    price: f64,
    rating: u8,
    description: String,
}

규칙도 정하겠습니다.

제목 없음
→ 오류

가격 숫자 변환 실패
→ 오류

별점 숫자 변환 실패
→ 오류

별점 1~5 범위 벗어남
→ 오류

설명 없음
→ "설명이 없습니다." 사용

같은 “값이 없음”이라도 제목과 설명의 처리 방식이 다릅니다.

바로 이 차이를 코드로 표현해보겠습니다.


에러 타입부터 준비합니다

이번에는 설명을 단순하게 유지하기 위해 오류 타입을 String으로 사용하겠습니다.

type AppResult<T> =
    Result<T, String>;

실제 큰 프로그램이라면 전용 에러 타입이나 Box<dyn Error> 등을 사용할 수 있지만, 지금은 흐름 자체에 집중하겠습니다.


가격을 변환합니다

fn parse_price(
    text: &str
) -> AppResult<f64> {
    text
        .parse::<f64>()
        .map_err(|error| {
            format!(
                "가격 '{text}' 변환 실패: {error}"
            )
        })
}

여기에서는 map_err()를 사용해 기존 숫자 파싱 오류에 우리가 이해하기 쉬운 정보를 붙였습니다.


별점을 변환합니다

fn parse_rating(
    text: &str
) -> AppResult<u8> {
    let rating = text
        .parse::<u8>()
        .map_err(|error| {
            format!(
                "별점 '{text}' 변환 실패: {error}"
            )
        })?;

    if (1..=5).contains(&rating) {
        Ok(rating)
    } else {
        Err(
            format!(
                "별점은 1~5여야 합니다: {rating}"
            )
        )
    }
}

여기에서는,

?

가 등장했습니다.

문자열을 숫자로 바꾸는 데 실패하면 함수에서 즉시 Err를 반환합니다.

성공했다면 rating에는 u8 값만 들어갑니다.


제목의 OptionResult로 바꿉니다

제목은 반드시 필요합니다.

fn parse_title(
    title: Option<&str>
) -> AppResult<String> {
    title
        .filter(|text| {
            !text.trim().is_empty()
        })
        .map(|text| {
            text.trim().to_string()
        })
        .ok_or_else(|| {
            "책 제목이 없습니다."
                .to_string()
        })
}

흐름을 펼쳐보면,

Option<&str>

filter
빈 제목 제거

map
String으로 변환

ok_or_else
None을 Err로 변환

입니다.

Option의 여러 메서드를 연결하면 이런 식으로 데이터를 단계적으로 정제할 수 있습니다.


설명은 오류가 아니라 기본값을 사용합니다

설명은 선택적인 값입니다.

fn parse_description(
    description: Option<&str>
) -> String {
    description
        .map(|text| {
            text.trim()
        })
        .filter(|text| {
            !text.is_empty()
        })
        .unwrap_or(
            "설명이 없습니다."
        )
        .to_string()
}

제목과 비교해보면 재미있습니다.

제목 없음

Err

설명 없음

기본값

둘 다 입력형은,

Option<&str>

이지만 프로그램의 정책에 따라 전혀 다르게 처리하고 있습니다.

Rust의 에러 처리는 결국 이런 정책을 타입과 코드에 표현하는 과정이라고 볼 수도 있습니다.


최종 Book을 만들어봅시다

#[derive(Debug)]
struct Book {
    title: String,
    price: f64,
    rating: u8,
    description: String,
}

fn make_book(
    title: Option<&str>,
    price: &str,
    rating: &str,
    description: Option<&str>,
) -> AppResult<Book> {
    let title =
        parse_title(title)?;

    let price =
        parse_price(price)?;

    let rating =
        parse_rating(rating)?;

    let description =
        parse_description(
            description
        );

    Ok(Book {
        title,
        price,
        rating,
        description,
    })
}

여기서 ?가 세 번 등장합니다.

parse_title(title)?;
parse_price(price)?;
parse_rating(rating)?;

셋 중 하나라도 실패하면 make_book()은 그 자리에서 오류를 반환합니다.

모두 성공한 경우에만 최종 Book이 만들어집니다.


완성 코드를 실행해봅시다

type AppResult<T> =
    Result<T, String>;

#[derive(Debug)]
struct Book {
    title: String,
    price: f64,
    rating: u8,
    description: String,
}

fn parse_title(
    title: Option<&str>
) -> AppResult<String> {
    title
        .filter(|text| {
            !text.trim().is_empty()
        })
        .map(|text| {
            text.trim().to_string()
        })
        .ok_or_else(|| {
            "책 제목이 없습니다."
                .to_string()
        })
}

fn parse_price(
    text: &str
) -> AppResult<f64> {
    text
        .parse::<f64>()
        .map_err(|error| {
            format!(
                "가격 '{text}' 변환 실패: {error}"
            )
        })
}

fn parse_rating(
    text: &str
) -> AppResult<u8> {
    let rating = text
        .parse::<u8>()
        .map_err(|error| {
            format!(
                "별점 '{text}' 변환 실패: {error}"
            )
        })?;

    if (1..=5).contains(&rating) {
        Ok(rating)
    } else {
        Err(
            format!(
                "별점은 1~5여야 합니다: {rating}"
            )
        )
    }
}

fn parse_description(
    description: Option<&str>
) -> String {
    description
        .map(|text| {
            text.trim()
        })
        .filter(|text| {
            !text.is_empty()
        })
        .unwrap_or(
            "설명이 없습니다."
        )
        .to_string()
}

fn make_book(
    title: Option<&str>,
    price: &str,
    rating: &str,
    description: Option<&str>,
) -> AppResult<Book> {
    let title =
        parse_title(title)?;

    let price =
        parse_price(price)?;

    let rating =
        parse_rating(rating)?;

    let description =
        parse_description(
            description
        );

    Ok(Book {
        title,
        price,
        rating,
        description,
    })
}

fn main() {
    let result =
        make_book(
            Some("The Rust Book"),
            "39.90",
            "5",
            None,
        );

    match result {
        Ok(book) => {
            println!("{book:#?}");
        }

        Err(error) => {
            eprintln!(
                "책 생성 실패: {error}"
            );
        }
    }
}

정상적으로 실행하면 다음과 비슷하게 출력됩니다.

Book {
    title: "The Rust Book",
    price: 39.9,
    rating: 5,
    description: "설명이 없습니다.",
}

직접 실패시켜봅시다

에러 처리는 정상 데이터만 넣어서는 제대로 확인하기 어렵습니다.

일부러 값을 망가뜨려보겠습니다.

가격을,

"가격 문의"

로 바꿔봅니다.

let result =
    make_book(
        Some("The Rust Book"),
        "가격 문의",
        "5",
        None,
    );

이번에는 "가격 문의"f64로 변환할 수 없기 때문에 parse_price()에서 오류가 발생합니다.

터미널에는 다음과 비슷한 메시지가 출력됩니다.

책 생성 실패: 가격 '가격 문의' 변환 실패: invalid float literal

앞부분의,

가격 '가격 문의' 변환 실패:

는 우리가 map_err()에서 직접 추가한 문장이고,

invalid float literal

parse::<f64>()가 반환한 실제 오류 메시지입니다.

다음으로 제목을 없애봅니다.

make_book(
    None,
    "39.90",
    "5",
    None,
);

이번에는,

책 제목이 없습니다.

라는 오류가 발생합니다.

별점을,

"7"

로 바꿔보면 숫자 변환에는 성공하지만 우리가 만든 범위 검사에서 오류가 발생합니다.

별점은 1~5여야 합니다: 7

같은 실패라도 발생 위치와 이유가 서로 다릅니다.


여기에서 사용한 에러 처리 흐름을 다시 따라가봅시다

제목은,

Option<&str>

filter

map

ok_or_else

Result<String, String>

가격은,

&str

parse

Result<f64, ParseFloatError>

map_err

Result<f64, String>

별점은,

&str

parse

map_err

?

범위 확인

Result<u8, String>

설명은,

Option<&str>

map

filter

unwrap_or

String

마지막으로 make_book()에서는,

parse_title ?
parse_price ?
parse_rating ?

모두 성공

Ok(Book)

이 됩니다.

이번 글에서 다룬 대부분의 에러 처리 방법이 이 작은 예제 안에 들어 있습니다.


조금 더 알아보기: flatten()

실제 코드를 작성하다 보면 이런 타입이 생길 수 있습니다.

Option<Option<i32>>

예를 들어,

let value =
    Some(Some(10));

우리가 원하는 것은,

Some(10)

일 수 있습니다.

이때,

let value =
    value.flatten();

을 사용할 수 있습니다.

Option<Option<T>>

flatten()

Option<T>

중첩을 한 겹 벗겨주는 셈입니다.


조금 더 알아보기: transpose()

이번에는 이런 타입이 있다고 해보겠습니다.

Option<Result<i32, String>>

의미는,

값 자체가 없을 수도 있고, 값이 있다면 그 작업은 또 실패할 수도 있다.

입니다.

상황에 따라 우리는 오히려,

Result<Option<i32>, String>

형태가 필요할 수 있습니다.

이때,

.transpose()

를 사용할 수 있습니다.

Option<Result<T, E>>

transpose()

Result<Option<T>, E>

처음에는 다소 낯설 수 있지만 OptionResult를 함께 사용하는 코드가 많아지면 꽤 유용한 도구입니다.

지금 당장 외울 필요는 없습니다.

중첩된 Option이나 Result가 보일 때,

이걸 뒤집거나 한 겹 줄여주는 메서드가 있었는데?

정도만 기억해도 충분합니다.


그럼 언제 무엇을 사용해야 할까요?

마지막으로 실제 코드를 작성할 때의 판단 흐름을 정리해보겠습니다.

값이 없을 수 있지만 정상적인 상황이라면,

Option<T>

을 먼저 생각할 수 있습니다.

상품 설명 없음
검색 결과 없음
다음 페이지 없음

같은 경우입니다.

작업 자체가 실패할 수 있고 실패 이유가 중요하다면,

Result<T, E>

가 어울립니다.

HTTP 요청 실패
파일 읽기 실패
숫자 변환 실패
잘못된 데이터 형식

같은 경우입니다.

OptionNone을 오류로 취급해야 한다면,

ok_or()
ok_or_else()

Result로 바꿀 수 있습니다.

성공한 값만 변환하려면,

map()

오류만 변환하려면,

map_err()

실패 가능한 작업을 연결하려면,

and_then()

실패를 지금 처리하지 않고 호출한 쪽으로 넘기려면,

?

를 사용할 수 있습니다.

실패해도 괜찮고 기본값을 사용하면 된다면,

unwrap_or()
unwrap_or_else()

가 있습니다.

실패가 정말 프로그램의 잘못이며 계속 실행할 의미가 없다면 panic!, unwrap(), expect()를 사용할 상황도 있습니다.


Rust의 에러 처리는 결국 선택의 문제입니다

처음 Rust를 배우면 OptionResult 때문에 코드가 더 복잡해 보일 수 있습니다.

다른 언어에서는 그냥,

null
exception

등으로 넘어가던 상황을 Rust에서는 계속 우리 앞에 꺼내놓는 느낌이 들기도 합니다.

하지만 웹 스크래퍼를 끝까지 만들고 나니 이 방식의 장점도 꽤 분명하게 느껴졌습니다.

이 값은 없어도 되는가?

없다면 오류인가?

오류가 발생하면 여기서 처리할 것인가?

호출한 쪽으로 넘길 것인가?

기본값으로 복구할 수 있는가?

프로그램을 중단해야 하는가?

이 질문에 대한 답이 코드에 남게 됩니다.

결국 Rust 에러 처리의 핵심은 메서드 이름을 많이 외우는 것이 아니라,

실패 가능성을 어떤 타입으로 표현하고, 그 실패를 어디에서 어떻게 처리할 것인가

를 결정하는 데 있다고 생각합니다.


한눈에 보는 최종 지도

마지막으로 이번 글의 내용을 한 번에 모아보겠습니다.

    값이 항상 있는가?

┌───────┴───────┐
│                      │
Yes                    No
│                      │
│               값 없음도 정상인가?
│                      │
│            ┌──────┴──────┐
│            │                   │
│           Yes                  No
│            │                   │
│       Option<T>           Result<T, E>


Option<T>
├─ Some(T)
├─ None
├─ map()
├─ filter()
├─ and_then()
├─ or_else()
├─ unwrap_or()
├─ unwrap_or_else()
├─ ok_or()
├─ ok_or_else()
└─ ?

Result<T, E>
├─ Ok(T)
├─ Err(E)
├─ map()
├─ map_err()
├─ and_then()
├─ or_else()
├─ unwrap()
├─ expect()
├─ unwrap_or()
├─ unwrap_or_else()
└─ ?

Option → Result
    ok_or()
    ok_or_else()

오류 타입 변환
    From / Into
    .into()

여러 오류를 하나로
    Box<dyn Error>

중첩 구조 정리
    flatten()
    transpose()

이제 웹 스크래퍼 코드에서,

.next()
.ok_or_else(...)?

나,

.parse::<u32>()?

혹은,

book.description
    .as_deref()
    .unwrap_or(...)

같은 코드를 다시 보면 이전보다 각 메서드가 왜 그 자리에 있는지 훨씬 선명하게 보일 겁니다.


다음 부록에서는 소유권으로 돌아가보겠습니다

웹 스크래퍼를 작성하면서 또 계속 등장했던 것이 있습니다.

String
&str
&Client
&book
clone()
async move

처음에는,

왜 어떤 값은 &를 붙이고 어떤 값은 그냥 넘기지?

라는 생각이 들기 쉽습니다.

그리고 Rust를 공부하다 보면 결국 반드시 만나는 단어들이 있습니다.

Ownership
Move
Borrow
Reference
Mutable Reference
Clone
Slice

다음 부록에서는 이 개념들을 문법 정의부터 다시 외우기보다, 웹 스크래퍼에서 실제로 작성했던 코드가 왜 그렇게 생겼는지 하나씩 역으로 따라가보겠습니다.