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

들어가며

지난 9편에서는 목록 페이지의 Next 링크를 직접 찾아가면서 첫 페이지부터 마지막 페이지까지 자동으로 순회하도록 프로그램을 확장했습니다.

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

이제 우리 프로그램은 더 이상 첫 페이지의 20권에 머물지 않습니다.

목록 페이지 요청

책 20권 처리

Next 페이지 이동

책 20권 처리

Next 페이지 이동

...

마지막 페이지에서 종료

기능만 놓고 보면 상당히 완성된 스크래퍼가 됐습니다.

하지만 실제로 실행해보면 새로운 문제가 하나 눈에 들어옵니다.

느립니다.

이유는 지난 편의 다음 코드에 있습니다.

for book_link in books {
    let detail_html =
        fetch_html(
            &client,
            &book_link.url,
        )
        .await?;

    // ...
}

첫 번째 책의 응답이 돌아올 때까지 기다린 뒤 두 번째 책을 요청하고, 다시 응답을 기다린 뒤 세 번째 책을 요청합니다.

처음에는 한 페이지에 20권만 처리했기 때문에 이러한 순차 요청 방식도 크게 답답하지 않았습니다.

그런데 페이지네이션까지 붙여 전체 목록을 처리하려고 하니 이야기가 달라졌습니다. 책 수가 늘어날수록 네트워크 응답을 기다리는 시간이 그대로 누적됐고, 이 시점에서야 동시 처리를 적용할 이유가 확실해졌습니다.

이번 편에서는 이 구조를 개선해 여러 상세 페이지를 동시에 처리해보겠습니다.

다만 무작정 모든 요청을 한꺼번에 보내지는 않을 겁니다.

빠르면서도 무례하지 않은 스크래퍼를 만들어보겠습니다.


현재 요청 구조를 다시 살펴봅시다

지금 프로그램에서 한 목록 페이지에는 약 20개의 책이 있습니다.

현재 동작을 단순화하면 다음과 같습니다.

책 1 요청 시작

응답 대기

책 1 처리 완료

책 2 요청 시작

응답 대기

책 2 처리 완료

...

만약 각각의 요청에 0.2초씩 걸린다고 단순하게 가정해보겠습니다.

20권이라면 대략,

0.2초 × 20

약 4초

가 됩니다.

여기에 목록 페이지가 여러 개라면 대기 시간이 계속 쌓입니다.

물론 실제 네트워크 응답 시간은 매번 다르지만, 핵심은 똑같습니다.

네트워크 응답을 기다리는 동안 다음 요청을 시작하지 않고 있다는 것입니다.


그런데 우리는 처음부터 비동기 프로그램을 만들고 있었습니다

1편부터 우리의 main() 함수에는 다음 코드가 붙어 있었습니다.

#[tokio::main]
async fn main() {
    // ...
}

HTTP 요청에도 계속 await를 사용했습니다.

.send()
.await?

지금까지는 asyncawait를 주로,

HTTP 요청에는 시간이 걸리기 때문에 비동기로 기다린다.

정도로 사용했습니다.

하지만 비동기의 진짜 장점은 기다리는 동안 다른 작업을 진행할 수 있다는 것입니다.

예를 들어 요청을 하나 보낸 뒤 서버의 응답만 기다리고 있다면, 그 시간 동안 다른 책의 요청을 시작할 수 있습니다.

책 1 요청 ───────── 응답
책 2 요청 ───── 응답
책 3 요청 ─────────── 응답
책 4 요청 ──── 응답

모든 요청이 똑같은 순간에 끝날 필요도 없습니다.

먼저 응답이 도착한 책부터 처리하면 됩니다.


그렇다면 20개를 한꺼번에 보내면 될까요?

첫 페이지에는 20권이 있습니다.

그러면 이런 생각을 할 수 있습니다.

그냥 20개를 전부 동시에 요청하면 가장 빠르지 않을까?

20개 정도라면 큰 문제가 없을 수도 있습니다.

하지만 지금 프로그램은 전체 사이트를 순회합니다.

대상이 1,000개, 10,000개로 늘어난다면 이야기가 달라집니다.

1,000개 URL 발견

1,000개 요청 동시에 시작

이런 프로그램은 서버에도 부담을 줄 수 있고, 우리 컴퓨터에서도 너무 많은 네트워크 작업을 동시에 관리해야 합니다.

그래서 보통은 동시에 처리할 작업의 개수를 제한합니다.

예를 들어,

동시에 최대 5개

라고 정할 수 있습니다.

[책 1]
[책 2]
[책 3]
[책 4]
[책 5]

동시에 요청

하나 완료

책 6 시작

또 하나 완료

책 7 시작

이런 방식입니다.

이번 예제에서는 동시에 최대 5개의 상세 페이지를 요청해보겠습니다.


futures 라이브러리를 추가해봅시다

여러 비동기 작업을 조금 더 편리하게 다루기 위해 futures crate를 사용하겠습니다.

터미널에서 다음 명령을 실행합니다.

cargo add futures

그리고 코드 위쪽에 다음을 추가합니다.

use futures::{
    stream,
    StreamExt,
};

이번 편에서 사용할 핵심은 크게 두 가지입니다.

stream

여러 값을 비동기 스트림처럼 처리

StreamExt

map(), next(), buffer_unordered() 등의 기능 제공

처음 보면 이름부터 조금 거창합니다.

하지만 우리가 할 일은 지금까지 사용했던 Iterator와 꽤 비슷합니다.


Iterator와 Stream을 비교해볼까요?

우리는 이미 여러 책을 for문으로 순회했습니다.

for book_link in books {
    // ...
}

Vec<BookLink> 안에 있는 데이터를 하나씩 꺼내는 방식입니다.

이런 동기적인 값의 흐름을 다루는 대표적인 도구가 Iterator입니다.

반면 Stream은 비슷한 개념을 비동기 값의 흐름으로 확장한 것이라고 이해하면 편합니다.

Iterator





반면,

Stream

값이 준비됨

처리

다음 값이 준비됨

처리

처럼 생각할 수 있습니다.

이번에는 각 책에 대한 HTTP 요청이라는 비동기 작업을 Stream으로 다루겠습니다.


먼저 책 목록을 Stream으로 바꿔봅시다

현재 books는,

Vec<BookLink>

인데요.

비동기 작업을 위해 다음처럼 Stream으로 만들 수 있습니다.

let book_stream =
    stream::iter(books);

이제 book_stream을 통해 BookLink들을 하나씩 처리할 수 있습니다.

하지만 아직 비동기 요청은 없습니다.

다음 단계에서 각 BookLink를 HTTP 요청 작업으로 바꿔보겠습니다.


map()으로 각각의 책을 비동기 작업으로 바꿔봅시다

다음과 같이 작성합니다.

let book_stream =
    stream::iter(books)
        .map(|book_link| {
            let client = client;

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

                let book =
                    parse_book_detail(
                        &detail_html,
                        &book_link.url,
                    );

                Ok::<Book, reqwest::Error>(
                    book
                )
            }
        });

코드가 갑자기 조금 복잡해졌습니다.

하지만 구조부터 보면 간단합니다.

기존에는,

BookLink

하나를 꺼내서 바로 요청했습니다.

이번에는,

BookLink

async 작업

으로 변환하고 있습니다.

각 책마다,

async move {
    // 상세 페이지 요청
}

이라는 하나의 비동기 작업이 만들어지는 셈입니다.


async move는 무엇일까요?

우리는 지금까지 주로 함수 앞에서 async를 사용했습니다.

async fn fetch_html(...)

이번에는 코드 블록 자체가 async입니다.

async move {
    // ...
}

이런 형태를 async block이라고 합니다.

이 블록 자체가 나중에 실행될 비동기 작업을 나타냅니다.

그리고 뒤의 move는 이 작업에서 사용할 값을 async block 안으로 가져오도록 합니다.

지금은 각 작업이 자신에게 해당하는,

book_link

를 가지고 있어야 합니다.

따라서,

async move

를 사용합니다.

각 책마다 자기 주소를 들고 출발하는 작은 작업 하나가 만들어진다고 생각해도 좋습니다.


그런데 아직 동시에 실행되는 것은 아닙니다

여기서 중요한 부분이 있습니다.

다음 코드를 만들었다고 해서,

stream::iter(books)
    .map(|book_link| {
        async move {
            // 요청
        }
    });

모든 HTTP 요청이 갑자기 동시에 실행되는 것은 아닙니다.

현재 Stream 안에는,

비동기 작업 1
비동기 작업 2
비동기 작업 3
...

이 준비된 상태입니다.

이제 이 작업들을 몇 개씩 실행할 것인지 정해야 합니다.

여기서 오늘의 핵심이 등장합니다.


buffer_unordered()로 동시 실행 개수를 제한해봅시다

다음 코드를 추가합니다.

let mut book_stream =
    stream::iter(books)
        .map(|book_link| {
            let client = &client;

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

                let book =
                    parse_book_detail(
                        &detail_html,
                        &book_link.url,
                    );

                Ok::<Book, reqwest::Error>(
                    book
                )
            }
        })
        .buffer_unordered(5);

새롭게 추가된 부분은,

.buffer_unordered(5)

입니다.

여기서 5동시에 실행할 최대 작업 수입니다.

즉,

최대 5개 요청 실행

1개 완료

다음 요청 하나 시작

또 완료

다음 요청 하나 시작

하는 식으로 진행됩니다.

20권의 책이 있어도 동시에 진행되는 작업은 최대 5개입니다.


왜 이름이 buffer_unordered일까요?

이름을 나눠보면 이해하기 쉽습니다.

buffer
+
unordered

buffer는 일정 개수의 작업을 진행 중인 상태로 유지한다는 의미로 볼 수 있습니다.

그리고 unordered가 중요합니다.

응답이 요청한 순서대로 반환된다는 보장이 없습니다.

예를 들어,

요청 시작 순서

1
2
3
4
5

였더라도 실제 응답은,

3
1
5
2
4

순서로 돌아올 수 있습니다.

웹 서버의 응답 시간은 각각 다를 수 있기 때문입니다.

buffer_unordered()는 1번 요청이 끝나기를 기다렸다가 2번 결과를 내놓지 않습니다.

먼저 끝난 작업부터 다음 단계로 넘깁니다.

웹 스크래핑에서는 책의 저장 순서가 반드시 목록 순서와 같을 필요가 없으므로 이런 방식이 잘 맞습니다.


동시에 처리할 개수는 상수로 빼놓겠습니다

코드 곳곳에 숫자 5를 직접 적어두는 것보다는 의미 있는 이름을 붙여놓는 것이 좋습니다.

코드 위쪽에 다음을 추가하겠습니다.

const MAX_CONCURRENT_REQUESTS: usize = 5;

그리고,

.buffer_unordered(
    MAX_CONCURRENT_REQUESTS
);

처럼 사용합니다.

나중에 동시에 처리할 개수를 바꾸고 싶다면 이 값 하나만 수정하면 됩니다.

const MAX_CONCURRENT_REQUESTS: usize = 3;

혹은,

const MAX_CONCURRENT_REQUESTS: usize = 10;

처럼 조절할 수 있습니다.


완료된 작업을 하나씩 받아봅시다

이제 최대 5개의 작업이 동시에 진행됩니다.

결과는 다음 코드로 하나씩 받을 수 있습니다.

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

    // ...
}

여기서,

book_stream.next().await

은,

완료된 다음 작업의 결과가 나올 때까지 기다린다.

라는 의미로 생각할 수 있습니다.

중요한 것은 특정 책 하나만 기다리는 것이 아니라는 점입니다.

동시에 진행 중인 작업 중 어느 것이든 먼저 끝나면 그 결과를 가져옵니다.


next()에도 await가 붙습니다

예전 Iterator에서 다음 값을 가져오는 것은 즉시 가능합니다.

하지만 비동기 Stream에서는 다음 결과가 아직 준비되지 않았을 수도 있습니다.

그래서,

book_stream.next()

만으로 끝나지 않고,

book_stream.next().await

를 사용합니다.

완료된 작업 있음

결과 반환

아직 완료된 작업 없음

await

하나가 완료되면 결과 반환

이것이 일반 Iterator와 비동기 Stream의 중요한 차이 중 하나입니다.


결과를 받은 뒤 Markdown을 저장합니다

HTTP 요청과 상세 페이지 파싱이 끝난 Book을 받았다면 그 뒤의 작업은 기존과 같습니다.

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

    let markdown =
        render_markdown(&book);

    let file_path =
        save_book(
            &book,
            &markdown,
        )
        .unwrap();

    println!(
        "{} 저장 완료",
        file_path.display()
    );

    total_books += 1;
}

즉 이번에 동시에 실행하는 핵심 작업은,

상세 페이지 HTTP 요청
        +
상세 HTML 파싱

부분입니다.

Markdown 생성과 파일 저장은 결과를 하나씩 받아 처리합니다.

구조를 이렇게 나누면 동시성 코드를 지나치게 복잡하게 만들지 않아도 됩니다.


기존 for문과 비교해봅시다

기존 코드는 다음과 같았습니다.

for book_link in books {
    let detail_html =
        fetch_html(
            &client,
            &book_link.url,
        )
        .await?;

    let book =
        parse_book_detail(
            &detail_html,
            &book_link.url,
        );

    // 저장
}

흐름은,

요청 1

완료

요청 2

완료

요청 3

완료

였습니다.

새로운 구조는,

요청 1 ───────┐
요청 2 ────┐  │
요청 3 ──────────┐
요청 4 ─────┐ │  │
요청 5 ────────┐ │

        완료되는 대로 처리

에 가깝습니다.

동시에 다섯 개의 네트워크 요청을 진행할 수 있기 때문에 네트워크 대기 시간을 훨씬 효율적으로 사용할 수 있습니다.


페이지 자체는 여전히 순서대로 이동합니다

여기서 한 가지 구분할 부분이 있습니다.

이번에 동시에 처리하는 것은 한 목록 페이지 안에 있는 상세 페이지 요청들입니다.

목록 페이지 자체는 여전히,

1페이지

2페이지

3페이지

순서대로 이동합니다.

왜 이렇게 했을까요?

현재 Next URL은 해당 목록 페이지 HTML에서 찾아야 하기 때문입니다.

목록 페이지 요청

Next URL 발견

다음 목록 페이지

따라서 이번 편에서는 구조를 너무 복잡하게 만들지 않고,

목록 페이지
    순차 처리

상세 페이지
    제한된 동시 처리

로 구분하겠습니다.

이 정도만으로도 현재 프로그램에서는 상당한 속도 향상을 기대할 수 있습니다.


동시에 많이 요청할수록 항상 좋을까요?

그렇지는 않습니다.

예를 들어,

.buffer_unordered(1000)

이라고 하면 아주 빨라질 것처럼 보일 수도 있습니다.

하지만 동시에 너무 많은 요청을 보내면 대상 서버에 부담을 줄 수 있습니다.

서버가 요청을 제한하거나,

429 Too Many Requests

같은 응답을 보낼 수도 있습니다.

우리 프로그램 역시 동시에 관리해야 할 네트워크 연결과 작업이 많아집니다.

따라서 동시성 숫자는,

클수록 무조건 좋다

가 아니라,

필요한 만큼만 사용한다

에 가깝습니다.

이번 예제에서는 학습용 사이트를 대상으로 비교적 보수적인 값인 5를 사용하겠습니다.

실제 사이트를 대상으로 스크래핑할 때는 해당 사이트의 이용 정책과 robots.txt 등을 확인하고 서버에 부담을 주지 않는 범위에서 요청해야 합니다.


한 페이지의 책을 처리하는 함수로 분리해봅시다

main() 안에 Stream 코드가 모두 들어가면 다시 조금 길어집니다.

따라서 한 목록 페이지의 책을 처리하는 부분을 함수로 분리해보겠습니다.

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

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

                    let book =
                        parse_book_detail(
                            &detail_html,
                            &book_link.url,
                        );

                    Ok::<Book, reqwest::Error>(
                        book
                    )
                }
            })
            .buffer_unordered(
                MAX_CONCURRENT_REQUESTS
            );

    let mut saved_count = 0;

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

        let markdown =
            render_markdown(&book);

        let file_path =
            save_book(
                &book,
                &markdown,
            )
            .unwrap();

        println!(
            "{} 저장 완료",
            file_path.display()
        );

        saved_count += 1;
    }

    Ok(saved_count)
}

이 함수의 역할은 명확합니다.

책 링크 목록 입력

상세 페이지 제한 동시 요청

Book 생성

Markdown 저장

저장한 책 수 반환

성공하면 저장한 책의 개수를 반환합니다.

Result<usize, reqwest::Error>

입니다.


main()이 다시 간단해졌습니다

이제 main()에서는 책 하나하나를 직접 처리할 필요가 없습니다.

기존의 긴 for문 대신,

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

만 호출하면 됩니다.

그리고 전체 개수에 더합니다.

total_books += saved;

전체 코드는 다음처럼 바뀝니다.

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;
}

main()만 읽어도 전체 프로그램의 흐름을 이해하기 쉬워졌습니다.

목록 페이지 요청

책 링크 추출

책들 처리

Next 확인

반복

동시에 몇 개를 요청하는지와 같은 세부적인 내용은 process_books() 안에 숨겨져 있습니다.


속도만 빨라진 것이 아닙니다

이번 수정은 단순히 프로그램을 빠르게 만든 것만은 아닙니다.

구조에도 변화가 생겼습니다.

기존에는 main()이,

목록 페이지 관리
상세 페이지 요청
HTML 파싱
Markdown 생성
파일 저장

을 모두 직접 지휘했습니다.

이제는,

main()

전체 페이지 흐름 관리

process_books()

한 페이지의 책 처리

fetch_html()

HTTP 요청

parse_book_detail()

HTML 파싱

save_book()

파일 저장

처럼 역할이 다시 나뉘었습니다.

프로그램이 커질수록 main()이 모든 세부사항을 알고 있는 것보다 이런 구조가 훨씬 관리하기 편합니다.


이번 편의 완성 코드

이번 편까지 반영한 전체 코드는 다음과 같습니다.

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

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

const MAX_CONCURRENT_REQUESTS: usize = 5;

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

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

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

    response.text().await
}

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

fn parse_book_links(
    html: &str,
    page_url: &str,
) -> 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")
            .unwrap()
            .to_string();

        let href = element
            .value()
            .attr("href")
            .unwrap();

        let url =
            make_absolute_url(
                page_url,
                href,
            );

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

    books
}

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

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

    let href = document
        .select(&selector)
        .next()?
        .value()
        .attr("href")?;

    Some(
        make_absolute_url(
            current_url,
            href,
        )
    )
}

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

fn parse_stock(text: &str) -> u32 {
    let (_, stock_text) =
        text.split_once('(').unwrap();

    stock_text
        .split_whitespace()
        .next()
        .unwrap()
        .parse::<u32>()
        .unwrap()
}

fn parse_rating(text: &str) -> u8 {
    match text {
        "One" => 1,
        "Two" => 2,
        "Three" => 3,
        "Four" => 4,
        "Five" => 5,
        _ => 0,
    }
}

fn parse_book_detail(
    html: &str,
    detail_url: &str,
) -> 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 = document
        .select(&title_selector)
        .next()
        .unwrap()
        .text()
        .collect::<String>()
        .trim()
        .to_string();

    let price_text = document
        .select(&price_selector)
        .next()
        .unwrap()
        .text()
        .collect::<String>();

    let stock_text = document
        .select(&stock_selector)
        .next()
        .unwrap()
        .text()
        .collect::<String>();

    let rating_text = document
        .select(&rating_selector)
        .next()
        .unwrap()
        .value()
        .attr("class")
        .unwrap()
        .split_whitespace()
        .nth(1)
        .unwrap();

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

    let image_path = document
        .select(&image_selector)
        .next()
        .unwrap()
        .value()
        .attr("src")
        .unwrap();

    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,
        );

    Book {
        title,
        price,
        stock,
        rating,
        description,
        image_url,
    }
}

fn render_markdown(
    book: &Book
) -> String {
    format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---

{}
"#,
        book.title,
        book.price,
        book.stock,
        book.rating,
        book.image_url,
        book.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,
) -> std::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>,
) -> Result<usize, reqwest::Error> {
    let mut book_stream =
        stream::iter(books)
            .map(|book_link| {
                let client = client;

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

                    let book =
                        parse_book_detail(
                            &detail_html,
                            &book_link.url,
                        );

                    Ok::<Book, reqwest::Error>(
                        book
                    )
                }
            })
            .buffer_unordered(
                MAX_CONCURRENT_REQUESTS
            );

    let mut saved_count = 0;

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

        let markdown =
            render_markdown(&book);

        let file_path =
            save_book(
                &book,
                &markdown,
            )
            .unwrap();

        println!(
            "{} 저장 완료",
            file_path.display()
        );

        saved_count += 1;
    }

    Ok(saved_count)
}

#[tokio::main]
async fn main()
    -> Result<(), reqwest::Error>
{
    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(())
}

이번 수정에서 핵심적으로 추가된 코드는 생각보다 많지 않습니다.

futures 추가

Stream 생성

각 책을 async 작업으로 변환

buffer_unordered(5)

완료되는 결과부터 처리

하지만 프로그램이 네트워크를 사용하는 방식은 크게 달라졌습니다.

순차적으로 기다리던 스크래퍼가 이제 제한된 범위 안에서 여러 작업을 함께 진행할 수 있게 되었습니다.


아직 해결하지 않은 문제가 하나 있습니다

속도는 빨라졌습니다.

하지만 지금까지 작성한 코드를 다시 보면 곳곳에 이런 코드가 남아 있습니다.

.unwrap()

HTML 요소를 찾을 때도,

.next()
.unwrap()

URL을 처리할 때도,

Url::parse(base_url)
    .unwrap()

파일을 저장할 때도,

save_book(...)
    .unwrap()

unwrap() 메서드를 사용하고 있습니다.

연습용 사이트에서 구조를 하나씩 알아가는 동안에는 코드를 간단하게 유지하는 데 도움이 됐습니다.

하지만 이제 프로그램이 1,000개의 데이터를 자동으로 처리한다면 이야기가 달라집니다.

999번째 책 하나에서 예상하지 못한 HTML을 만났다는 이유로 프로그램 전체가 멈춰버린다면 꽤 아쉽겠죠.

다음 편에서는 드디어 그동안 미뤄두었던 unwrap() 메서드들을 정리해보며 안전한 Rust 프로그래밍에 대해 더 깊이 알아보겠습니다.


11편 예고: 실패해도 멈추지 않는 스크래퍼를 만들어봅시다

다음 편에서는 지금까지 곳곳에 사용했던,

.unwrap()

을 하나씩 살펴보겠습니다.

그리고,

Option
Result
?
에러 타입

을 이용해 예상하지 못한 데이터가 등장하더라도 프로그램 전체가 바로 종료되지 않도록 개선하겠습니다.

특히 여러 책을 처리하는 상황에서는,

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

이라면,

책 3 때문에 프로그램 전체 종료

보다,

책 3 오류 기록

책 4 계속 처리

하는 구조가 훨씬 실용적입니다.

10편에서 속도를 올렸다면, 11편에서는 프로그램의 맷집을 올려보겠습니다.


이번 편에서 배운 내용

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

  • 순차 요청의 한계
  • 비동기 요청의 장점
  • futures crate 추가
  • Stream의 기본 개념
  • stream::iter() 사용
  • map()으로 async 작업 생성
  • async block과 async move
  • buffer_unordered() 사용
  • 최대 동시 요청 개수 제한
  • 완료 순서대로 결과 처리
  • 비동기 Stream에서 next().await 사용
  • MAX_CONCURRENT_REQUESTS 상수 작성
  • 목록 페이지와 상세 페이지 처리 방식 구분
  • process_books() 함수 분리
  • 제한된 동시성으로 상세 페이지 처리 속도 개선

다음 편에서는 프로그램 곳곳에 남아 있는 unwrap()을 줄이고, 일부 요청이나 데이터가 실패하더라도 전체 작업을 계속 진행할 수 있는 구조를 만들어보겠습니다.

다음 글: Rust 웹 스크래퍼의 오류를 안전하게 처리하기