실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 11편

목차

들어가며

지난 10편에서는 여러 상세 페이지를 순차적으로 요청하던 구조를 buffer_unordered()를 이용한 제한된 동시 처리 방식으로 바꿨습니다.

아직 안 보셨다면 먼저 10편을 보고 오시는 것을 추천드립니다.

덕분에 속도 문제는 상당 부분 해결했습니다.

그런데 프로그램을 오래 실행할수록 속도만큼 중요한 것이 하나 더 있습니다.

바로 실패했을 때 어떻게 행동할 것인가입니다.

현재 코드를 보면 이런 부분이 꽤 많이 남아 있습니다.

.next()
.unwrap()
.attr("href")
.unwrap()
Url::parse(base_url)
    .unwrap()
save_book(...)
    .unwrap()

사이트의 HTML이 우리가 예상한 구조와 조금만 달라지거나 URL이 잘못되거나 파일을 저장하지 못하면 unwrap()이 있는 곳에서 panic이 발생할 수 있습니다.

책을 20권 정도 처리할 때는 다시 실행하면 그만일 수도 있습니다.

하지만 1,000권을 처리하다가,

997권 성공
998번째 책에서 오류

프로그램 종료

가 된다면 꽤 속이 쓰립니다.

그래서 이번 편에서는 모든 실패를 프로그램 전체의 실패로 취급하지 않고, 실패의 종류에 따라 처리 방법을 나누는 것을 목표로 해보겠습니다.


일부러 실패 상황부터 생각해봅시다

먼저 실제 스크래퍼에서 어떤 문제가 생길 수 있는지 생각해보겠습니다.

상세 페이지의 제목을 가져오는 코드는 현재 다음과 같습니다.

let title = document
    .select(&title_selector)
    .next()
    .unwrap()
    .text()
    .collect::<String>()
    .trim()
    .to_string();

정상적인 HTML에는,

<h1>A Light in the Attic</h1>

이 있기 때문에 잘 동작합니다.

그런데 어느 날 HTML이 다음처럼 바뀌었다고 해보겠습니다.

<h2>A Light in the Attic</h2>

우리 선택자는 여전히,

Selector::parse("h1")

을 찾습니다.

결과를 찾지 못하면,

.next()

None을 반환합니다.

그리고 바로 뒤의,

.unwrap()

에서 panic이 발생합니다.

책 하나의 HTML 변경

None

unwrap()

프로그램 전체 종료

우리가 원하는 동작은 이것이 아닙니다.


모든 실패가 같은 실패는 아닙니다

여기서 먼저 구분할 것이 있습니다.

예를 들어 제목이나 가격을 찾지 못한 책이 하나 있다고 해보겠습니다.

책 1 성공
책 2 성공
책 3 제목 없음
책 4 성공
책 5 성공

이 경우에는 책 3을 건너뛰고 나머지를 계속 처리할 수 있습니다.

반면 현재 목록 페이지 자체를 가져오지 못했다면 이야기가 다릅니다.

목록 페이지 요청 실패

책 링크를 알 수 없음

Next 링크도 알 수 없음

이 경우에는 다음에 어디로 가야 하는지 자체를 알 수 없습니다.

즉 이번에는 오류를 크게 두 종류로 생각해보겠습니다.

개별 책 오류

기록하고 다음 책 계속

목록 페이지 같은 핵심 오류

호출한 쪽으로 오류 전달

모든 오류를 무시하는 것도 좋은 오류 처리는 아니고, 작은 오류 하나 때문에 모든 작업을 중단하는 것도 좋은 방법은 아닙니다.


여러 종류의 오류를 하나의 반환형으로 다뤄봅시다

지금까지 fetch_html()은 다음 반환형을 사용했습니다.

Result<String, reqwest::Error>

HTTP 요청에서 발생하는 reqwest::Error만 처리했기 때문입니다.

하지만 이제 프로그램 전체를 보면 오류의 종류가 훨씬 많습니다.

HTTP 요청 실패
URL 파싱 실패
숫자 변환 실패
HTML 데이터 누락
파일 저장 실패

각각 오류 타입도 다릅니다.

이번 예제에서는 여러 오류를 하나의 반환형으로 다루기 위해 타입 별칭을 하나 만들겠습니다.

use std::error::Error;

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

조금 낯선 모양입니다.

하지만 우선은 다음처럼 생각해도 충분합니다.

AppResult<T>

성공하면 T
실패하면 어떤 종류든 Error

이제,

AppResult<String>

은 성공하면 String을 반환하고,

AppResult<Book>

은 성공하면 Book을 반환합니다.

오류의 구체적인 종류가 서로 달라도 하나의 형태로 전달할 수 있습니다.


Box<dyn Error>는 무엇일까요?

Rust의 오류 타입은 하나가 아닙니다.

예를 들어 HTTP 요청에서는,

reqwest::Error

파일 작업에서는,

std::io::Error

숫자 변환에서는,

ParseIntError
ParseFloatError

처럼 서로 다른 오류 타입이 만들어질 수 있습니다.

함수 하나에서 이 모든 오류를 직접 하나씩 다루려고 하면 반환형이 금방 복잡해질 수 있습니다.

그래서 이번 예제에서는,

Box<dyn Error + Send + Sync>

를 사용했습니다.

처음 보면 조금 복잡해 보이지만 하나씩 나눠보면 이해하기 어렵지 않습니다.

먼저,

dyn Error

부터 보겠습니다.

Error는 Rust에서 여러 오류 타입이 공통으로 구현하는 trait입니다.

trait은 C++의 추상 클래스, Java 계열의 Interface, Swift의 Protocol 같은 개념으로 보면 쉽습니다.

그리고 앞에 붙은 dyn은 해당 trait을 trait object로 다루겠다는 것을 나타내는 키워드입니다. 이렇게 만들어진 trait object를 통해 메서드를 호출할 때는 실제 타입에 맞는 구현을 런타임에 결정하는 dynamic dispatch가 사용됩니다.

즉,

정확히 어떤 구체적인 오류 타입인지는 모르지만 Error trait을 구현한 타입을 다루겠다.

라는 의미입니다.

예를 들어 실제 오류가,

reqwest::Error
std::io::Error
ParseIntError

중 어떤 것이더라도 Error trait을 구현하고 있다면 dyn Error라는 공통된 형태로 다룰 수 있습니다.

쉽게 말하면,

구체적인 오류 타입 여러 개

Error라는 공통 인터페이스로 묶어서 처리

하는 셈입니다.

그런데 dyn Error처럼 trait object를 사용할 때는 값의 크기가 컴파일 시점에 하나로 정해지지 않습니다.

오류마다 실제 크기가 다를 수 있기 때문입니다.

그래서 그대로 저장하기보다는 Box로 감싸줍니다.

Box<dyn Error>

Box는 값을 힙에 저장하고, 프로그램에서는 그 값을 가리키는 포인터를 다루도록 해줍니다.

C언어 개발처럼 직접 메모리를 다루는 작업 경험이 없는 분들을 위한 최소한의 메모리 설명은 이 포스트에 적어두었습니다.

따라서,

Box<dyn Error>

는,

Error trait을 구현한 여러 종류의 오류를 하나의 공통된 형태로 담아둘 수 있는 값

정도로 이해하면 됩니다.

그런데 우리가 사용한 타입은 여기서 끝나지 않습니다.

Box<dyn Error + Send + Sync>

처럼 SendSync가 추가되어 있습니다.

여기서 +는 숫자를 더하는 연산자가 아닙니다.

여러 trait 조건을 함께 지정하는 문법입니다.

즉,

dyn Error + Send + Sync

는,

Error를 구현하고
+
Send도 구현하고
+
Sync도 구현한 타입

이라는 뜻입니다.

Send는 해당 값을 다른 스레드로 옮겨도 안전하다는 의미이고,

Sync는 여러 스레드에서 공유해서 참조해도 안전하다는 의미입니다.

이번 예제에서 반드시 Send + Sync가 필요한 모든 상황을 사용하는 것은 아니지만, 이후 Tokio의 멀티스레드 환경이나 비동기 작업 사이에서도 다루기 쉬운 오류 타입으로 제한하기 위해 함께 붙였습니다.

정리하면,

Box<dyn Error + Send + Sync>

는 다음과 같이 읽을 수 있습니다.

Error trait을 구현한 여러 오류 타입을 받을 수 있고,
그 오류는 비동기 또는 멀티스레드 환경에서도
안전하게 전달하고 공유할 수 있다.

그래서 이번에 만든,

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

는 성공했을 때는 원하는 타입 T를 반환하고,

실패했을 때는 HTTP 오류, 파일 오류, 숫자 변환 오류처럼 서로 다른 여러 오류를 하나의 반환형으로 전달할 수 있게 해줍니다.

더 큰 프로그램에서는 직접 에러 타입을 만들거나 thiserror, anyhow 같은 라이브러리를 사용할 수도 있습니다.

하지만 이번 연재에서는 새로운 라이브러리를 더 추가하지 않고, 표준 기능만으로 여러 종류의 오류를 한 번에 다루는 방법을 사용해보겠습니다.


fetch_html()의 반환형부터 바꿔봅시다

기존 함수는,

async fn fetch_html(
    client: &Client,
    url: &str,
) -> Result<String, reqwest::Error>

였습니다.

이제 다음처럼 수정합니다.

async fn fetch_html(
    client: &Client,
    url: &str,
) -> AppResult<String> {
    let response = client
        .get(url)
        .header(
            USER_AGENT,
            "rust-book-scraper/0.1"
        )
        .send()
        .await?
        .error_for_status()?;

    let html =
        response.text().await?;

    Ok(html)
}

여기서 기존에 사용하던 ?는 그대로 살아 있습니다.

.send()
.await?

HTTP 요청에 실패하면 오류가 AppResult의 오류 형태로 전달됩니다.

마지막에는 정상적으로 받은 HTML을,

Ok(html)

로 반환합니다.


URL을 만들 때도 unwrap()을 제거해봅시다

현재 코드는 다음과 같습니다.

fn make_absolute_url(
    base_url: &str,
    path: &str,
) -> String {
    Url::parse(base_url)
        .unwrap()
        .join(path)
        .unwrap()
        .to_string()
}

base_url이 잘못된 URL이거나 상대 경로를 합치는 과정에서 문제가 생기면 프로그램이 종료됩니다.

이제 반환형을 바꾸겠습니다.

fn make_absolute_url(
    base_url: &str,
    path: &str,
) -> AppResult<String> {
    let url =
        Url::parse(base_url)?
            .join(path)?;

    Ok(url.to_string())
}

기존의,

.unwrap()

대신,

?

를 사용했습니다.

차이는 큽니다.

unwrap()

실패하면 panic

?

실패하면 오류를 호출한 쪽으로 전달

이제 URL 문제가 생겨도 프로그램이 어떻게 처리할지 상위 코드에서 결정할 수 있습니다.


OptionResult로 바꿔야 할 때도 있습니다

이번에는 HTML에서 제목을 찾는 경우를 생각해보겠습니다.

document
    .select(&title_selector)
    .next()

의 반환값은 Option입니다.

찾음

Some(...)

못 찾음

None

그런데 parse_book_detail()에서는 단순히 “없다”보다,

필요한 제목을 찾지 못했다.

라는 오류로 처리하고 싶습니다.

이럴 때 ok_or_else()를 사용할 수 있습니다.

먼저 작은 도우미 함수를 하나 만들겠습니다.

use std::io;

fn invalid_data(
    message: &str
) -> io::Error {
    io::Error::new(
        io::ErrorKind::InvalidData,
        message,
    )
}

그리고 제목을 다음처럼 가져옵니다.

let title_element = document
    .select(&title_selector)
    .next()
    .ok_or_else(|| {
        invalid_data(
            "책 제목을 찾을 수 없습니다."
        )
    })?;

next()Some이면 안의 값을 가져옵니다.

반대로 None이면 우리가 만든 오류로 변환합니다.

Option

ok_or_else()

Result

이제 단순한 None에 “왜 실패했는지”에 대한 의미가 생겼습니다.


필요한 데이터와 선택적인 데이터도 구분해봅시다

모든 필드가 반드시 있어야 하는 것도 아닙니다.

예를 들어 책 제목이 없다면 어떤 책인지 알기 어렵습니다.

가격이 없다면 우리가 정의한 Book 데이터도 완성하기 어렵습니다.

그래서 이번에는,

title
price
stock
rating
image

를 필수 값으로 생각하겠습니다.

반면 상품 설명은 없을 수도 있다고 가정해보겠습니다.

지금까지 description은,

description: String

이었습니다.

이번에는 이것을,

description: Option<String>

으로 바꿔보겠습니다.

struct Book {
    title: String,
    price: f64,
    stock: u32,
    rating: u8,
    description: Option<String>,
    image_url: String,
}

이제,

설명 있음

Some(String)

설명 없음

None

으로 표현할 수 있습니다.

없을 수도 있는 값이라는 사실 자체를 자료형으로 표현한 것입니다.


설명은 unwrap() 없이 가져올 수 있습니다

상품 설명은 다음처럼 처리할 수 있습니다.

let description = document
    .select(&description_selector)
    .next()
    .map(|element| {
        element
            .text()
            .collect::<String>()
            .trim()
            .to_string()
    })
    .filter(|text| {
        !text.is_empty()
    });

요소가 없다면,

None

이 됩니다.

요소가 있다면 map()을 통해 문자열로 변환합니다.

그리고 내용이 빈 문자열이면 filter()를 통해 다시 None으로 처리합니다.

따라서 설명이 없다고 해서 책 전체를 실패시킬 필요가 없습니다.


Markdown에서는 설명이 없을 때 기본 문장을 사용해봅시다

description이 이제 Option<String>이므로 Markdown을 만들 때도 조금 수정해야 합니다.

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

여기에도 unwrap이라는 글자가 들어가지만 우리가 지금까지 문제 삼았던 unwrap()과는 다릅니다.

.unwrap()

은 값이 없으면 panic을 일으킵니다.

반면,

.unwrap_or("설명이 없습니다.")

는 값이 없을 때 사용할 기본값을 지정합니다.

Some("책 설명")

"책 설명"

None

"설명이 없습니다."

따라서 안전하게 사용할 수 있습니다.


가격 변환도 실패할 수 있습니다

현재 가격 변환 함수는,

fn parse_price(text: &str) -> f64 {
    text
        .trim()
        .trim_start_matches('£')
        .parse::<f64>()
        .unwrap()
}

입니다.

만약 가격이,

£51.77

이 아니라,

가격 문의

처럼 들어온다면 숫자로 변환할 수 없습니다.

이번에는 오류를 그대로 전달하겠습니다.

fn parse_price(
    text: &str
) -> AppResult<f64> {
    let price = text
        .trim()
        .trim_start_matches('£')
        .parse::<f64>()?;

    Ok(price)
}

이제 숫자 변환에 실패하면 panic이 아니라 오류가 반환됩니다.


재고 변환도 같은 방식입니다

기존에는,

text.split_once('(').unwrap()

을 사용했습니다.

하지만 예상한 괄호가 없다면 역시 프로그램이 종료됩니다.

다음처럼 수정할 수 있습니다.

fn parse_stock(
    text: &str
) -> AppResult<u32> {
    let (_, stock_text) =
        text.split_once('(')
            .ok_or_else(|| {
                invalid_data(
                    "재고 형식을 해석할 수 없습니다."
                )
            })?;

    let number = stock_text
        .split_whitespace()
        .next()
        .ok_or_else(|| {
            invalid_data(
                "재고 수량을 찾을 수 없습니다."
            )
        })?;

    Ok(number.parse::<u32>()?)
}

이제,

In stock (22 available)

형식이 아니라면 명확한 오류가 반환됩니다.


별점의 알 수 없는 값도 오류로 처리해봅시다

지금까지는 알 수 없는 별점이 들어오면,

_ => 0,

으로 처리했습니다.

하지만 다시 생각해보면 별점 0과 “별점을 파싱하지 못함”은 같은 의미가 아닙니다.

그래서 이번에는 오류로 바꾸겠습니다.

fn parse_rating(
    text: &str
) -> AppResult<u8> {
    let rating = match text {
        "One" => 1,
        "Two" => 2,
        "Three" => 3,
        "Four" => 4,
        "Five" => 5,
        _ => {
            return Err(
                invalid_data(
                    "알 수 없는 별점입니다."
                )
                .into()
            );
        }
    };

    Ok(rating)
}

한 가지 짚어야 할 것은 into() 메서드입니다. 우리가 정의한 invalid_data() 함수는 에러 발생 시,

std::io::Error

를 반환하지만, parse_rating() 함수는

Box<dyn Error + Send + Sync>

타입의 에러를 반환해야 하는 함수입니다. into() 메서드는 이와 같은 타입 변환을 알아서 해줍니다.


상세 페이지 파싱 함수도 Result를 반환합니다

이제 parse_book_detail()도 실패할 수 있는 함수로 바꿉니다.

fn parse_book_detail(
    html: &str,
    detail_url: &str,
) -> AppResult<Book>

제목을 가져오는 부분부터 바꿔보겠습니다.

let title_element = document
    .select(&title_selector)
    .next()
    .ok_or_else(|| {
        invalid_data(
            "책 제목을 찾을 수 없습니다."
        )
    })?;

let title = title_element
    .text()
    .collect::<String>()
    .trim()
    .to_string();

가격도 먼저 요소를 확인합니다.

let price_text = document
    .select(&price_selector)
    .next()
    .ok_or_else(|| {
        invalid_data(
            "가격을 찾을 수 없습니다."
        )
    })?
    .text()
    .collect::<String>();

이제 HTML 구조가 예상과 달라도 panic 대신 오류가 반환됩니다.


그런데 CSS 선택자의 unwrap()은 그대로 두겠습니다

여기서 이런 질문이 생길 수 있습니다.

unwrap()을 없애고 있다면서 Selector::parse()에는 왜 아직 남아 있나요?

좋은 질문입니다.

예를 들어,

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

에서 "p.price_color"는 외부 사이트에서 받은 값이 아닙니다.

우리가 프로그램 코드에 직접 적어놓은 고정된 CSS 선택자입니다.

이 선택자의 문법이 잘못됐다면 웹사이트의 문제가 아니라 프로그램을 작성한 개발자의 실수에 가깝습니다.

반면,

HTML에 h1이 없음
href 속성이 없음
가격 문자열 형식이 달라짐
잘못된 URL이 들어옴

등은 프로그램 외부에서 들어오는 데이터 때문에 발생할 수 있습니다.

이번 편의 목표는 단순히 코드에서 unwrap이라는 글자를 모두 지우는 것이 아닙니다.

실행 중 실제로 달라질 수 있는 외부 데이터에 대한 가정을 줄이는 것입니다.

그래서 고정된 선택자의 Selector::parse()는 이번 예제에서는 그대로 두겠습니다.


이제 책 하나의 실패가 전체를 멈추지 않게 해봅시다

지금부터가 이번 편에서 가장 중요한 부분입니다.

10편의 process_books()에서는,

while let Some(result) =
    book_stream.next().await
{
    let book = result?;

    // 저장
}

이라고 작성했습니다.

책 하나의 요청이 실패하면,

result?

에서 process_books() 자체가 종료됩니다.

결국 나머지 책들도 처리하지 못합니다.

이번에는 실패한 책의 오류만 출력하고 계속 진행하도록 바꿔보겠습니다.


목록 페이지에서 가져온 제목을 오류 로그에 사용해봅시다

BookLink에는 지금까지 거의 사용하지 않았던 값이 하나 있습니다.

struct BookLink {
    title: String,
    url: String,
}

바로 title입니다.

상세 페이지를 읽지 못했더라도 목록 페이지에서 얻은 제목은 알고 있습니다.

이 값을 오류 로그에 사용하면 어떤 책에서 문제가 생겼는지 확인하기 좋습니다.

각 비동기 작업에서 제목과 결과를 함께 반환하겠습니다.

.map(|book_link| {
    let client = client;

    async move {
        let title =
            book_link.title.clone();

        let result = async {
            let detail_html =
                fetch_html(
                    client,
                    &book_link.url,
                )
                .await?;

            parse_book_detail(
                &detail_html,
                &book_link.url,
            )
        }
        .await;

        (title, result)
    }
})

결과는 개념적으로 다음과 같습니다.

(
    "A Light in the Attic",
    Ok(Book)
)

혹은 실패했다면,

(
    "A Light in the Attic",
    Err(...)
)

가 됩니다.


성공과 실패를 match로 나눠봅시다

이제 결과를 받을 때,

while let Some((title, result)) =
    book_stream.next().await

처럼 제목도 함께 받습니다.

그리고 match를 사용합니다.

match result {
    Ok(book) => {
        // 정상 처리
    }

    Err(error) => {
        eprintln!(
            "처리 실패: {} - {}",
            title,
            error
        );
    }
}

책 하나가 실패하면,

처리 실패: Example Book - 가격을 찾을 수 없습니다.

같은 메시지만 출력됩니다.

그리고 Stream은 다음 결과를 계속 가져옵니다.

책 1 성공
책 2 성공
책 3 실패

로그 출력

책 4 계속
책 5 계속

드디어 우리가 원했던 구조입니다.


파일 저장 실패도 책 하나의 실패로 처리해봅시다

HTTP 요청과 파싱이 성공해도 파일 저장이 실패할 수 있습니다.

따라서,

save_book(...).unwrap();

도 제거하겠습니다.

match save_book(
    &book,
    &markdown,
) {
    Ok(file_path) => {
        println!(
            "{} 저장 완료",
            file_path.display()
        );

        saved_count += 1;
    }

    Err(error) => {
        eprintln!(
            "파일 저장 실패: {} - {}",
            book.title,
            error
        );
    }
}

이제 특정 책의 파일을 저장하지 못해도 다른 책들은 계속 진행됩니다.


process_books()는 이제 실패한 책 때문에 오류를 반환할 필요가 없습니다

개별 책 오류를 함수 안에서 처리하기 때문에 process_books()의 반환형도 단순해질 수 있습니다.

기존에는,

Result<usize, reqwest::Error>

였습니다.

이제는,

usize

만 반환해도 됩니다.

async fn process_books(
    client: &Client,
    books: Vec<BookLink>,
) -> usize {

성공적으로 저장한 책의 개수만 반환합니다.

따라서 main()에서도,

let saved =
    process_books(
        &client,
        books,
    )
    .await;

처럼 사용할 수 있습니다.

이제 책 하나의 실패가 목록 페이지 전체의 실패로 퍼지지 않습니다.


목록 페이지 오류는 여전히 전달하겠습니다

반면 main()의 목록 페이지 요청은 그대로 ?를 사용합니다.

let html =
    fetch_html(
        &client,
        &current_url,
    )
    .await?;

왜 이 오류는 건너뛰지 않을까요?

현재 페이지를 읽지 못하면,

책 링크
Next 링크

둘 다 알 수 없기 때문입니다.

다음에 어디로 이동해야 할지도 모르는 상태에서 무작정 계속할 수는 없습니다.

그래서,

개별 상세 페이지 오류

건너뛰고 계속

목록 페이지 오류

상위로 전달

이라는 정책을 사용합니다.

중요한 것은 ?를 많이 쓰느냐 적게 쓰느냐가 아닙니다.

어떤 실패를 어디까지 전파할 것인지 결정하는 것입니다.


main()도 새로운 반환형을 사용합니다

프로그램 전체에서 여러 종류의 오류를 다루게 됐으므로 main()도 다음처럼 바꿉니다.

#[tokio::main]
async fn main() -> AppResult<()> {

이제 목록 페이지 HTTP 오류뿐 아니라 URL 처리나 목록 파싱 과정에서 발생한 오류도 ?로 전달할 수 있습니다.

예를 들어,

let books =
    parse_book_links(
        &html,
        &current_url,
    )?;

처럼 사용할 수 있습니다.


실패한 책을 건너뛴 결과는 어떻게 보일까요?

예를 들어 책 20권 중 하나에 문제가 있다고 가정해보겠습니다.

실행 결과는 대략 다음과 같을 수 있습니다.

1 페이지 처리 중

books/a-light-in-the-attic/index.md 저장 완료
books/tipping-the-velvet/index.md 저장 완료

처리 실패: Broken Book - 가격을 찾을 수 없습니다.

books/soumission/index.md 저장 완료
books/sharp-objects/index.md 저장 완료
...

중요한 것은 오류 메시지가 등장한 뒤에도 프로그램이 계속 움직인다는 점입니다.

이제 실패가,

프로그램 종료

가 아니라,

기록할 정보

가 되기 시작했습니다.


이번 편의 완성 코드

지금까지의 내용을 전체 코드에 반영해보겠습니다.

use futures::{
    stream,
    StreamExt,
};
use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};
use slug::slugify;
use std::error::Error;
use std::fs;
use std::io;
use std::path::PathBuf;
use url::Url;

const TARGET_URL: &str =
    "https://books.toscrape.com/";

const MAX_CONCURRENT_REQUESTS: usize = 5;

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

struct BookLink {
    title: String,
    url: String,
}

struct Book {
    title: String,
    price: f64,
    stock: u32,
    rating: u8,
    description: Option<String>,
    image_url: String,
}

fn invalid_data(
    message: &str
) -> io::Error {
    io::Error::new(
        io::ErrorKind::InvalidData,
        message,
    )
}

async fn fetch_html(
    client: &Client,
    url: &str,
) -> AppResult<String> {
    let response = client
        .get(url)
        .header(
            USER_AGENT,
            "rust-book-scraper/0.1"
        )
        .send()
        .await?
        .error_for_status()?;

    let html =
        response.text().await?;

    Ok(html)
}

fn make_absolute_url(
    base_url: &str,
    path: &str,
) -> AppResult<String> {
    let url =
        Url::parse(base_url)?
            .join(path)?;

    Ok(url.to_string())
}

fn parse_book_links(
    html: &str,
    page_url: &str,
) -> AppResult<Vec<BookLink>> {
    let document =
        Html::parse_document(html);

    let selector =
        Selector::parse(
            "article.product_pod h3 a"
        )
        .unwrap();

    let mut books = Vec::new();

    for element in document.select(&selector) {
        let title = element
            .value()
            .attr("title")
            .ok_or_else(|| {
                invalid_data(
                    "목록에서 책 제목을 찾을 수 없습니다."
                )
            })?
            .to_string();

        let href = element
            .value()
            .attr("href")
            .ok_or_else(|| {
                invalid_data(
                    "책 상세 페이지 주소를 찾을 수 없습니다."
                )
            })?;

        let url =
            make_absolute_url(
                page_url,
                href,
            )?;

        books.push(BookLink {
            title,
            url,
        });
    }

    Ok(books)
}

fn parse_next_page_url(
    html: &str,
    current_url: &str,
) -> AppResult<Option<String>> {
    let document =
        Html::parse_document(html);

    let selector =
        Selector::parse(
            "li.next a"
        )
        .unwrap();

    let Some(element) =
        document
            .select(&selector)
            .next()
    else {
        return Ok(None);
    };

    let href = element
        .value()
        .attr("href")
        .ok_or_else(|| {
            invalid_data(
                "Next 링크의 href를 찾을 수 없습니다."
            )
        })?;

    let next_url =
        make_absolute_url(
            current_url,
            href,
        )?;

    Ok(Some(next_url))
}

fn parse_price(
    text: &str
) -> AppResult<f64> {
    let price = text
        .trim()
        .trim_start_matches('£')
        .parse::<f64>()?;

    Ok(price)
}

fn parse_stock(
    text: &str
) -> AppResult<u32> {
    let (_, stock_text) =
        text.split_once('(')
            .ok_or_else(|| {
                invalid_data(
                    "재고 형식을 해석할 수 없습니다."
                )
            })?;

    let number = stock_text
        .split_whitespace()
        .next()
        .ok_or_else(|| {
            invalid_data(
                "재고 수량을 찾을 수 없습니다."
            )
        })?;

    Ok(number.parse::<u32>()?)
}

fn parse_rating(
    text: &str
) -> AppResult<u8> {
    let rating = match text {
        "One" => 1,
        "Two" => 2,
        "Three" => 3,
        "Four" => 4,
        "Five" => 5,
        _ => {
            return Err(
                invalid_data(
                    "알 수 없는 별점입니다."
                )
                .into()
            );
        }
    };

    Ok(rating)
}

fn parse_book_detail(
    html: &str,
    detail_url: &str,
) -> AppResult<Book> {
    let document =
        Html::parse_document(html);

    let title_selector =
        Selector::parse("h1")
            .unwrap();

    let price_selector =
        Selector::parse(
            "p.price_color"
        )
        .unwrap();

    let stock_selector =
        Selector::parse(
            "p.instock.availability"
        )
        .unwrap();

    let rating_selector =
        Selector::parse(
            "p.star-rating"
        )
        .unwrap();

    let description_selector =
        Selector::parse(
            "#product_description + p"
        )
        .unwrap();

    let image_selector =
        Selector::parse(
            "#product_gallery img"
        )
        .unwrap();

    let title_element = document
        .select(&title_selector)
        .next()
        .ok_or_else(|| {
            invalid_data(
                "책 제목을 찾을 수 없습니다."
            )
        })?;

    let title = title_element
        .text()
        .collect::<String>()
        .trim()
        .to_string();

    let price_text = document
        .select(&price_selector)
        .next()
        .ok_or_else(|| {
            invalid_data(
                "가격을 찾을 수 없습니다."
            )
        })?
        .text()
        .collect::<String>();

    let stock_text = document
        .select(&stock_selector)
        .next()
        .ok_or_else(|| {
            invalid_data(
                "재고 정보를 찾을 수 없습니다."
            )
        })?
        .text()
        .collect::<String>();

    let rating_class = document
        .select(&rating_selector)
        .next()
        .ok_or_else(|| {
            invalid_data(
                "별점을 찾을 수 없습니다."
            )
        })?
        .value()
        .attr("class")
        .ok_or_else(|| {
            invalid_data(
                "별점 class를 찾을 수 없습니다."
            )
        })?;

    let rating_text = rating_class
        .split_whitespace()
        .find(|class| {
            *class != "star-rating"
        })
        .ok_or_else(|| {
            invalid_data(
                "별점 값을 찾을 수 없습니다."
            )
        })?;

    let description = document
        .select(&description_selector)
        .next()
        .map(|element| {
            element
                .text()
                .collect::<String>()
                .trim()
                .to_string()
        })
        .filter(|text| {
            !text.is_empty()
        });

    let image_path = document
        .select(&image_selector)
        .next()
        .ok_or_else(|| {
            invalid_data(
                "책 이미지를 찾을 수 없습니다."
            )
        })?
        .value()
        .attr("src")
        .ok_or_else(|| {
            invalid_data(
                "이미지 src를 찾을 수 없습니다."
            )
        })?;

    let price =
        parse_price(&price_text)?;

    let stock =
        parse_stock(
            stock_text.trim()
        )?;

    let rating =
        parse_rating(rating_text)?;

    let image_url =
        make_absolute_url(
            detail_url,
            image_path,
        )?;

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

fn render_markdown(
    book: &Book
) -> String {
    let description =
        book.description
            .as_deref()
            .unwrap_or(
                "설명이 없습니다."
            );

    format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---

{}
"#,
        book.title,
        book.price,
        book.stock,
        book.rating,
        book.image_url,
        description,
    )
}

fn unique_dir_path(
    base_dir: &str,
    slug: &str,
) -> PathBuf {
    let mut path =
        PathBuf::from(base_dir);

    path.push(slug);

    if !path.exists() {
        return path;
    }

    let mut number = 2;

    loop {
        let mut candidate =
            PathBuf::from(base_dir);

        candidate.push(
            format!(
                "{}-{}",
                slug,
                number
            )
        );

        if !candidate.exists() {
            return candidate;
        }

        number += 1;
    }
}

fn save_book(
    book: &Book,
    markdown: &str,
) -> io::Result<PathBuf> {
    let slug =
        slugify(&book.title);

    let dir_path =
        unique_dir_path(
            "books",
            &slug,
        );

    fs::create_dir_all(
        &dir_path
    )?;

    let mut file_path =
        dir_path;

    file_path.push("index.md");

    fs::write(
        &file_path,
        markdown,
    )?;

    Ok(file_path)
}

async fn process_books(
    client: &Client,
    books: Vec<BookLink>,
) -> usize {
    let mut book_stream =
        stream::iter(books)
            .map(|book_link| {
                let client = client;

                async move {
                    let title =
                        book_link
                            .title
                            .clone();

                    let result = async {
                        let detail_html =
                            fetch_html(
                                client,
                                &book_link.url,
                            )
                            .await?;

                        parse_book_detail(
                            &detail_html,
                            &book_link.url,
                        )
                    }
                    .await;

                    (title, result)
                }
            })
            .buffer_unordered(
                MAX_CONCURRENT_REQUESTS
            );

    let mut saved_count = 0;

    while let Some((title, result)) =
        book_stream.next().await
    {
        match result {
            Ok(book) => {
                let markdown =
                    render_markdown(
                        &book
                    );

                match save_book(
                    &book,
                    &markdown,
                ) {
                    Ok(file_path) => {
                        println!(
                            "{} 저장 완료",
                            file_path
                                .display()
                        );

                        saved_count += 1;
                    }

                    Err(error) => {
                        eprintln!(
                            "파일 저장 실패: {} - {}",
                            book.title,
                            error
                        );
                    }
                }
            }

            Err(error) => {
                eprintln!(
                    "처리 실패: {} - {}",
                    title,
                    error
                );
            }
        }
    }

    saved_count
}

#[tokio::main]
async fn main() -> AppResult<()> {
    let client =
        Client::new();

    let mut page_url =
        Some(
            TARGET_URL.to_string()
        );

    let mut page_number = 1;
    let mut total_books = 0;

    while let Some(current_url) =
        page_url
    {
        println!(
            "{} 페이지 처리 중",
            page_number
        );

        let html =
            fetch_html(
                &client,
                &current_url,
            )
            .await?;

        let books =
            parse_book_links(
                &html,
                &current_url,
            )?;

        let saved =
            process_books(
                &client,
                books,
            )
            .await;

        total_books += saved;

        page_url =
            parse_next_page_url(
                &html,
                &current_url,
            )?;

        page_number += 1;
    }

    println!(
        "총 {}권 저장 완료",
        total_books
    );

    Ok(())
}

코드는 확실히 이전보다 길어졌습니다.

하지만 이번에는 단순히 새로운 기능을 추가해서 길어진 것이 아닙니다.

그동안 코드 안에 숨어 있던,

이 값은 반드시 있을 것이다.
이 URL은 반드시 정상일 것이다.
이 문자열은 반드시 숫자로 바뀔 것이다.
파일은 반드시 저장될 것이다.

라는 가정들을 하나씩 코드 밖으로 꺼낸 결과입니다.


처음 코드와 비교해보면 차이가 더 잘 보입니다

처음에는 이렇게 작성했습니다.

let title = document
    .select(&title_selector)
    .next()
    .unwrap();

의미는 사실상,

제목이 무조건 있을 것이다.
없다면 프로그램을 종료한다.

에 가까웠습니다.

지금은,

let title = document
    .select(&title_selector)
    .next()
    .ok_or_else(|| {
        invalid_data(
            "책 제목을 찾을 수 없습니다."
        )
    })?;

로 바뀌었습니다.

이제 의미는,

제목을 찾는다.

없다면 이유가 있는 오류로 만든다.

호출한 쪽에서 처리 방법을 결정한다.

가 됩니다.

Rust의 ResultOption은 단순히 귀찮은 문법이 아니라 이런 실패 가능성을 코드 안에 명시적으로 표현하기 위한 도구입니다.


이제 프로그램이 조금 더 오래 버틸 수 있게 됐습니다

현재 프로그램은 상세 페이지 하나에서 오류가 발생해도 다음 책을 계속 처리합니다.

파일 하나를 저장하지 못해도 다른 책은 계속 저장됩니다.

설명이 없는 책은 아예 실패시키지 않고 None으로 표현합니다.

반면 목록 페이지를 가져오지 못하는 것처럼 프로그램이 앞으로 진행하기 어려운 오류는 여전히 상위로 전달합니다.

복구 가능한 오류

기록 후 계속

복구하기 어려운 오류

Result로 전달

이 구분이 이번 편에서 가장 중요한 부분입니다.

unwrap()을 모두 없애는 것이 안전한 프로그램의 목표는 아닙니다.

무엇이 실패할 수 있는지 알고, 실패했을 때 어떤 행동을 할지 결정하는 것이 더 중요합니다.


다음은 마지막 편입니다

우리가 처음 만들었던 프로그램은 겨우 이 정도였습니다.

웹페이지 요청

HTML 문자열 출력

지금은,

목록 페이지 자동 순회

상세 URL 수집

제한된 동시 요청

HTML 파싱

데이터 가공

오류 처리

Markdown 생성

디렉터리별 저장

까지 왔습니다.

기능은 거의 완성됐습니다.

하지만 지금까지 기능을 하나씩 덧붙이다 보니 main.rs 하나가 제법 커졌습니다.

마지막 편에서는 더 이상 큰 기능을 추가하지 않겠습니다.

대신 지금까지 만든 코드를 실제 프로젝트처럼 정리해보겠습니다.


12편 예고: 웹 스크래퍼를 하나의 프로젝트로 완성해봅시다

마지막 편에서는 현재 하나의 파일에 모여 있는 코드를 역할별로 나눠보겠습니다.

예를 들면,

src/
├── main.rs
├── model.rs
├── scraper.rs
└── output.rs

처럼 정리할 수 있습니다.

그리고 연재 초반의 코드와 마지막 코드를 비교해보며,

HTTP 요청
HTML 파싱
자료형
URL 처리
파일 시스템
페이지네이션
비동기 동시성
오류 처리

가 어떻게 하나의 프로그램 안에서 연결됐는지 다시 살펴보겠습니다.

마지막으로 이 코드를 실제 사이트에 적용한다면 추가로 생각해야 할,

robots.txt
이용 약관
요청 간격
재시도
로그
테스트

같은 부분도 정리해보겠습니다.

새로운 문법을 하나 더 배우기보다는, 지금까지 만든 프로그램을 제대로 닫는 편이 될 것 같습니다.

지금까지 이 과정을 함께 해 오셨다면 정말 대단하고 감사하다고 말씀드리고 싶습니다.

저 역시 쉽진 않았지만 그래도 개인 프로젝트를 하며 공부한 내용과 작성한 코드를 하나의 스토리로 이어온 것에 대해 스스로 대견스럽기도 하네요:)

그럼 이번 연재 마지막 편에서 뵐게요!


이번 편에서 배운 내용

이번 편에서는 다음 내용을 구현했습니다.

  • unwrap()이 위험해질 수 있는 상황
  • 복구 가능한 오류와 복구하기 어려운 오류 구분
  • 여러 오류를 위한 AppResult<T> 타입 별칭
  • Box<dyn Error + Send + Sync> 사용
  • OptionResult로 바꾸는 ok_or_else()
  • 선택적인 데이터를 Option<String>으로 표현
  • unwrap_or()를 이용한 안전한 기본값 처리
  • 숫자 변환 오류 전달
  • URL 오류 전달
  • 잘못된 별점을 오류로 처리
  • 상세 페이지 파서가 Result<Book>을 반환하도록 변경
  • 실패한 책의 제목과 오류 출력
  • 상세 페이지 하나가 실패해도 다음 책 계속 처리
  • 파일 저장 실패 시 다음 작업 계속 처리
  • 목록 페이지와 상세 페이지 오류 정책 구분
  • 고정된 CSS 선택자의 unwrap()을 남겨둔 이유

다음 편에서는 지금까지 작성한 코드를 역할별로 정리하고 웹 스크래퍼 프로젝트를 최종 완성해보겠습니다.