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

들어가며

지난 4편에서는 각 책의 상세 페이지에서 제목, 가격, 재고, 별점을 가져와 Book 구조체에 저장했습니다.

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

지난 편에서 최종적으로 얻은 데이터는 대략 이런 모습이었습니다.

제목: A Light in the Attic
가격: £51.77
재고: In stock (22 available)
별점: Three

사람이 보기에는 아무 문제가 없습니다.

하지만 프로그램 입장에서 보면 조금 이야기가 달라집니다.

현재 가격도,

£51.77

재고도,

In stock (22 available)

별점도,

Three

모두 단순한 문자열입니다.

그렇다면 이런 질문이 생길 수 있습니다.

가격이 30파운드보다 저렴한 책만 찾고 싶다면?

혹은,

재고가 10권 이상 있는 책만 저장하고 싶다면?

또는,

별점이 4점 이상인 책만 골라내고 싶다면?

문자열 상태에서도 어떻게든 처리할 수는 있겠지만, 숫자 데이터는 숫자로 다루는 것이 훨씬 편리합니다.

이번 편에서는 지난 시간에 가져온 문자열을 우리가 실제로 사용할 수 있는 숫자 자료형으로 바꿔보겠습니다.


이번 편을 읽기 전에 자료형에 대해 복습해볼까요?

이번 편에서는 여러 종류의 숫자 자료형이 등장합니다.

요즘처럼 메모리 용량이 넘쳐나는 시대에 굳이 자료형 하나하나 따져가며 정의하는 것이 오히려 비효율적일 수도 있지만, 개발자로서 사용할 수 있는 도구를 알고 있는 것과 아닌 것은 큰 차이를 만듭니다.

따라서, Rust 자료형에 대해 가물가물하다면, Rust 타입 시스템 포스트를 한 번 읽고 돌아오시는 것을 권해드립니다.

현재 Book 구조체를 다시 봅시다

지난 편에서 만든 Book 구조체는 다음과 같았습니다.

struct Book {
    title: String,
    price: String,
    stock: String,
    rating: String,
}

현재 모든 값이 String입니다.

사실 책 제목은 문자열이 맞습니다.

A Light in the Attic

하지만 가격은 숫자로 사용할 수 있습니다.

£51.77

51.77

재고도 마찬가지입니다.

In stock (22 available)

22

별점 역시 숫자로 표현할 수 있습니다.

Three

3

따라서 최종적으로는 Book을 다음과 같은 형태로 바꿔보겠습니다.

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

각 자료형이 왜 이렇게 정해졌는지도 하나씩 살펴보겠습니다.


가격에서 £ 기호를 제거해봅시다

현재 가격은 다음과 같은 문자열입니다.

£51.77

우리가 필요한 값은 숫자 부분입니다.

51.77

Rust에서는 trim_start_matches() 메서드를 이용해 문자열 앞에 붙은 특정 문자를 제거할 수 있습니다.

let price_text = "£51.77";

let price_text =
    price_text.trim_start_matches('£');

결과는 다음과 같습니다.

51.77

이름 그대로 문자열의 시작 부분에서 일치하는 값을 제거하는 메서드입니다.

trim_start_matches('£')

비슷하게 문자열 뒤쪽을 제거하는 trim_end_matches()도 있습니다.

trim_end_matches(...)

그리고 문자열 양쪽을 모두 대상으로 하는 trim_matches()도 있습니다.

trim_matches(...)

이번에는 가격 기호가 문자열 맨 앞에 있기 때문에 trim_start_matches()가 가장 적절합니다.

이번 예시와 다른 화폐 기준을 사용하는 사이트이더라도 위 메서드들을 적절히 사용하면 충분히 숫자 값만 추출할 수 있으실 겁니다.

문자열을 실제 숫자로 바꾸는 parse()

하지만 아직 끝난 것은 아닙니다.

£를 제거했다고 해서 51.77이 자동으로 숫자가 되는 것은 아닙니다.

현재도 자료형은 문자열입니다.

"51.77"

이 문자열을 실제 숫자로 바꾸려면 parse() 메서드를 사용할 수 있습니다.

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

여기서 f64는 소수점을 표현할 수 있는 숫자 자료형입니다.

가격에는 다음처럼 소수점이 들어가기 때문에,

51.77

정수 자료형이 아니라 실수 자료형을 사용하는 것입니다.

다만 f64와 같은 부동소수점 자료형은 일부 소수 값을 정확하게 표현하지 못할 수 있습니다. 이번 예제에서는 자료형 변환을 익히기 위해 사용하지만, 실제 결제나 회계처럼 정확한 금액 계산이 필요한 프로그램에서는 정수 단위로 저장하거나 별도의 Decimal 자료형을 사용하는 것이 좋습니다.

결과적으로,

"51.77"

이라는 문자열이

51.77

이라는 숫자로 변환됩니다.


parse()에도 unwrap()이 붙을까요?

코드를 보면 또 익숙한 친구가 등장했습니다.

.unwrap()

이유는 간단합니다.

모든 문자열이 숫자로 변환될 수 있는 것은 아니기 때문입니다.

예를 들어,

"51.77".parse::<f64>()

는 정상적으로 숫자로 변환할 수 있습니다.

하지만,

"hello".parse::<f64>()

는 불가능합니다.

Rust 입장에서는 변환이 성공할 수도 있고 실패할 수도 있기 때문에 parse()Result를 반환합니다.

개념적으로 보면 다음과 같습니다.

"51.77"

parse::<f64>()

Ok(51.77)

반면,

"hello"

parse::<f64>()

Err(...)

가 됩니다.

이번 연습 사이트에서는 가격 형식을 알고 있기 때문에 일단 unwrap()을 사용하겠습니다.

나중에 실제 서비스용 코드를 만들 때는 오류를 안전하게 처리하는 방식으로 바꿔보겠습니다.


가격 변환 코드를 하나로 합쳐봅시다

지난 편의 가격 추출 코드는 다음과 같았습니다.

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

이번에는 문자열을 가져온 뒤 숫자로 변환해야 합니다.

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

let price = price_text
    .trim()
    .trim_start_matches('£')
    .parse::<f64>()
    .unwrap();

이제 price의 자료형은 String이 아니라 f64입니다.

price: f64

그리고 다음처럼 숫자 비교도 할 수 있게 됩니다.

if price < 30.0 {
    println!("30파운드보다 저렴한 책입니다.");
}

이제 가격이 진짜 가격다운 데이터가 되었습니다.


이번에는 재고 숫자를 꺼내봅시다

현재 재고 정보는 다음과 같습니다.

In stock (22 available)

우리가 원하는 값은 이 안의 숫자 하나입니다.

22

이 값을 가져오는 방법은 여러 가지가 있습니다.

정규표현식을 사용할 수도 있고 문자열을 여러 단계로 나눌 수도 있습니다.

이번에는 새로운 라이브러리를 추가하지 않고 Rust의 문자열 메서드만 사용해보겠습니다.

먼저 괄호 안의 값을 생각해보면,

22 available

를 가져오고 싶습니다.

문자열에서 ( 이후의 값을 가져올 때 split_once()를 사용할 수 있습니다.

let stock_text =
    "In stock (22 available)";

let (_, stock_text) =
    stock_text.split_once('(').unwrap();

split_once()는 특정 문자를 기준으로 문자열을 딱 한 번 나눕니다.

예를 들어,

In stock (22 available)

( 기준으로 나누면,

앞:
In stock

뒤:
22 available)

가 됩니다.

우리는 뒤쪽만 필요하기 때문에 다음처럼 작성한 것입니다.

let (_, stock_text) =
    stock_text.split_once('(').unwrap();

여기서 _는 첫 번째 값을 사용하지 않겠다는 의미입니다.

여러 값을 소괄호(())로 감싸는 튜플 자료형에 대해서 더 알아보고 싶다면 여기를 참고해 주세요.


숫자 부분만 다시 잘라봅시다

현재 stock_text에는 다음 값이 들어 있습니다.

22 available)

여기서 첫 번째 단어가 우리가 원하는 재고 수량입니다.

공백 기준으로 문자열을 나누면,

22
available)

가 됩니다.

지난 편에서 별점을 추출할 때 사용했던 split_whitespace()를 다시 사용할 수 있습니다.

let stock = stock_text
    .split_whitespace()
    .next()
    .unwrap()
    .parse::<u32>()
    .unwrap();

이번에는 처음부터 하나씩 살펴보겠습니다.

.split_whitespace()

공백 기준으로 문자열을 나눕니다.

22
available)

그리고,

.next()

첫 번째 값을 가져옵니다.

22

마지막으로,

.parse::<u32>()

문자열 "22"를 숫자 22로 변환합니다.


u32는 어떤 자료형일까요?

재고 자료형에는 u32를 사용했습니다.

stock: u32

u3232-bit unsigned integer를 나타내며 음수가 없는 정수를 저장하는 자료형입니다.

대략 다음과 같은 숫자들을 표현할 수 있습니다.

0
1
2
10
100
1000
...

재고가,

-3권

처럼 음수가 되는 것은 일반적인 상황이 아니기 때문에 부호가 없는 정수 자료형을 사용한 것입니다.

물론 실제 프로그램에서는 비즈니스 로직에 따라 다른 자료형을 선택할 수도 있습니다.

지금은,

재고 = 0 이상의 정수

라고 생각하고 u32를 사용하겠습니다.


별점도 숫자로 바꿔봅시다

현재 별점은 다음 다섯 가지 중 하나입니다.

One
Two
Three
Four
Five

이것을 다음 숫자로 바꾸겠습니다.

One   → 1
Two   → 2
Three → 3
Four  → 4
Five  → 5

여기서는 Rust의 match 표현식을 사용하기 좋습니다.

let rating = match rating_text {
    "One" => 1,
    "Two" => 2,
    "Three" => 3,
    "Four" => 4,
    "Five" => 5,
    _ => 0,
};

match는 하나의 값이 어떤 경우에 해당하는지 비교해서 각각 다른 결과를 반환할 수 있습니다.

쉽게 생각하면,

rating_text가 "One"이면 1

rating_text가 "Two"이면 2

rating_text가 "Three"이면 3

...

라는 뜻입니다.


마지막의 _ => 0은 무엇일까요?

match를 사용할 때는 가능한 경우를 모두 처리해야 합니다.

현재 우리가 알고 있는 별점은,

One
Two
Three
Four
Five

입니다.

하지만 Rust는 혹시라도 다른 값이 들어오는 경우까지 처리하라고 요구합니다.

그래서 마지막에,

_ => 0,

을 넣었습니다.

여기서 _는,

위의 어떤 조건에도 해당하지 않는 나머지 모든 값

을 의미합니다.

예를 들어,

Six
Unknown
Excellent

같은 값이 들어오면 0으로 처리됩니다.

현재는 연습을 위해 단순하게 0으로 처리하겠습니다.

실전에서는 알 수 없는 별점이 들어왔을 때 오류로 처리하는 것이 더 적절할 수도 있습니다.


별점 자료형은 u8로 사용하겠습니다

별점은 1부터 5까지만 사용합니다.

따라서 아주 큰 숫자를 저장할 필요가 없습니다.

rating: u8

u8은 0부터 255까지의 정수를 저장할 수 있기 때문에 별점을 표현하기에는 충분합니다.

물론 다음처럼 u32를 사용해도 프로그램은 정상적으로 동작합니다.

rating: u32

하지만 자료형을 정할 때는 데이터가 실제로 어떤 범위를 갖는지도 생각해볼 수 있습니다.

Rust가 자료형을 꽤 세밀하게 제공하는 이유 중 하나입니다.


변환 작업을 함수로 분리해볼까요?

지금까지 가격, 재고, 별점에 각각 변환 코드가 생겼습니다.

parse_book_detail() 안에 모든 코드를 넣어도 되지만 함수가 점점 길어집니다.

예를 들어 가격 변환은 이렇게 함수로 분리할 수 있습니다.

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

이제 각 함수의 역할이 아주 명확해졌습니다.

parse_price()

가격 문자열을 숫자로 변환

parse_stock()

재고 문자열에서 숫자 추출

parse_rating()

영문 별점을 숫자로 변환

프로그램이 조금씩 작은 부품들로 나누어지고 있습니다.


Book 구조체도 수정해봅시다

이제 데이터의 자료형이 달라졌으므로 구조체도 수정해야 합니다.

기존에는,

struct Book {
    title: String,
    price: String,
    stock: String,
    rating: String,
}

이었습니다.

이제는 다음처럼 바꿀 수 있습니다.

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

이 차이는 단순해 보이지만 상당히 중요합니다.

이제 Rust는 가격을 문자열이 아니라 숫자로 알고 있습니다.

그래서,

if book.price < 30.0 {
    println!("저렴한 책");
}

처럼 계산과 비교를 할 수 있습니다.

재고 역시,

if book.stock > 10 {
    println!("재고 충분");
}

처럼 사용할 수 있습니다.

별점도,

if book.rating >= 4 {
    println!("평점이 높은 책");
}

처럼 비교할 수 있습니다.

웹사이트에 보이던 글자가 이제 실제 프로그램에서 사용할 수 있는 데이터가 되었습니다.


parse_book_detail()을 수정해봅시다

그럼 지금까지 만든 변환 함수를 기존 파싱 함수에 적용해보겠습니다.

fn parse_book_detail(html: &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 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 price =
        parse_price(&price_text);

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

    let rating =
        parse_rating(rating_text);

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

이제 parse_book_detail()은 두 가지 작업을 연결합니다.

HTML에서 원본 문자열 추출

각 변환 함수에 전달

정리된 데이터 생성

Book 구조체 저장

코드의 역할도 조금 더 선명해졌습니다.


실행 결과를 확인해봅시다

출력 코드는 지난 편과 크게 달라지지 않습니다.

println!("제목: {}", book.title);
println!("가격: {}", book.price);
println!("재고: {}", book.stock);
println!("별점: {}", book.rating);

프로그램을 실행합니다.

cargo run

이제 결과는 다음과 같은 형태가 됩니다.

제목: A Light in the Attic
가격: 51.77
재고: 22
별점: 3

제목: Tipping the Velvet
가격: 53.74
재고: 20
별점: 1

...

겉으로 보기에는 문자가 조금 줄어든 정도입니다.

하지만 프로그램 내부에서는 큰 변화가 생겼습니다.

이전

가격 → String
재고 → String
별점 → String

에서,

현재

가격 → f64
재고 → u32
별점 → u8

로 바뀌었습니다.

이제 이 값들은 계산하고, 비교하고, 정렬할 수 있습니다.


실제로 조건을 걸어봅시다

숫자로 바꾼 효과를 간단히 확인해보겠습니다.

예를 들어 별점이 4점 이상인 책만 출력하고 싶다면 다음처럼 작성할 수 있습니다.

if book.rating >= 4 {
    println!("제목: {}", book.title);
    println!("별점: {}", book.rating);
}

가격이 30파운드 이하인 책을 찾는 것도 간단합니다.

if book.price <= 30.0 {
    println!(
        "{} - £{}",
        book.title,
        book.price
    );
}

재고가 없는 책을 검사할 수도 있습니다.

if book.stock == 0 {
    println!(
        "{}는 현재 품절입니다.",
        book.title
    );
}

문자열을 그대로 가지고 있었다면 이런 조건을 처리할 때 매번 문자열을 다시 분석해야 했을 것입니다.

데이터를 처음 가져올 때 적절한 자료형으로 정리해두는 이유입니다.


수집과 가공을 분리한 이유

여기까지 오면 한 가지 구조가 눈에 보이기 시작합니다.

우리 프로그램에는 크게 세 가지 단계가 있습니다.

웹페이지 요청

HTML에서 데이터 추출

추출한 데이터 가공

조금 더 구체적으로 보면,

fetch_html()

웹페이지를 가져옴

parse_book_detail()

HTML에서 필요한 값을 찾음

parse_price()
parse_stock()
parse_rating()

값을 적절한 자료형으로 변환

하나의 함수가 모든 것을 처리하게 만들 수도 있습니다.

하지만 이렇게 역할을 나누면 나중에 문제가 생겼을 때 어느 부분을 확인해야 하는지도 쉽게 알 수 있습니다.

예를 들어 가격만 이상하다면,

parse_price()

부터 확인하면 됩니다.

재고 형식이 바뀌었다면,

parse_stock()

을 수정하면 됩니다.

프로그램을 여러 작은 역할로 나누는 것이 유지보수에 중요한 이유입니다.


이번 편의 완성 코드

지금까지 작성한 전체 코드를 확인해보겠습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{Html, Selector};

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

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

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

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 parse_book_links(html: &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 url = element
            .value()
            .attr("href")
            .unwrap()
            .to_string();

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

    books
}

fn make_detail_url(path: &str) -> String {
    format!("{}{}", TARGET_URL, path)
}

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) -> 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 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 price =
        parse_price(&price_text);

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

    let rating =
        parse_rating(rating_text);

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

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

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

    let books = parse_book_links(&html);

    println!("찾은 책: {}권", books.len());
    println!();

    for book_link in books {
        let detail_url =
            make_detail_url(&book_link.url);

        let detail_html = fetch_html(
            &client,
            &detail_url,
        )
        .await?;

        let book =
            parse_book_detail(&detail_html);

        println!("제목: {}", book.title);
        println!("가격: {}", book.price);
        println!("재고: {}", book.stock);
        println!("별점: {}", book.rating);
        println!();
    }

    Ok(())
}

이제 책 하나는 단순한 문자열 묶음이 아니라 자료형이 구분된 하나의 데이터가 되었습니다.

Book

title  → String
price  → f64
stock  → u32
rating → u8

앞으로 파일로 저장하거나 정렬, 필터링, 계산을 할 때도 훨씬 편리해집니다.


6편 예고: 상품 설명과 이미지도 가져와봅시다

아직 상세 페이지에서 가져오지 않은 중요한 데이터가 있습니다.

상품 설명과 책 표지 이미지입니다.

상품 설명은 HTML 안에 텍스트로 존재하지만, 우리가 지금까지 다룬 제목이나 가격보다 조금 다른 위치에 있습니다.

이미지 역시,

<img src="../../media/cache/...jpg">

처럼 상대 URL 형태로 들어 있습니다.

따라서 다음 편에서는,

상품 설명 추출

이미지 URL 추출

이미지 상대 URL 정리

Book 구조체에 추가

과정을 진행해보겠습니다.

이 과정까지 끝나면 한 권의 책에 대해 우리가 필요로 하는 핵심 데이터는 거의 모두 갖추게 됩니다.

그다음부터는 수집하는 것보다 어떻게 저장할 것인가에 초점이 넘어가게 됩니다.

최종 목표였던 Markdown 파일이 조금씩 시야에 들어오기 시작합니다:)


이번 편에서 배운 내용

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

  • 가격 문자열에서 통화 기호 제거
  • parse()를 이용한 문자열 숫자 변환
  • f64 자료형으로 가격 저장
  • split_once()를 이용한 문자열 분리
  • 재고 문자열에서 숫자 추출
  • u32 자료형으로 재고 저장
  • match를 이용한 별점 변환
  • u8 자료형으로 별점 저장
  • parse_price() 함수 작성
  • parse_stock() 함수 작성
  • parse_rating() 함수 작성
  • Book 구조체의 자료형 개선
  • 수집과 데이터 가공을 분리하는 방법
  • 숫자로 변환한 데이터를 조건문에서 활용하는 방법

다음 편에서는 상세 페이지에서 상품 설명과 이미지 URL을 추가로 가져와보겠습니다.

다음 글: Rust로 상품 설명과 이미지 URL 추출하기