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

들어가며

23년에 한창 Rust 프로그래밍에 입문할 때 시작한 블로그인데, 3년 만에 실전 예제 포스팅으로 돌아오게 되었네요.

당시에는, Rust를 가지고 프로그래밍의 하드웨어적인 측면과 소프트웨어적인 측면을 복합적으로 이해시키는 글을 작성하는 것이 목표였는데요.

사실 현생을 살다보니 블로그에 집중하기 쉽지 않았습니다.

이 시점에 다시 기초로 돌아가기보다는, 그동안 제가 직접 작성한 Rust 프로그램의 실제 코드를 바탕으로 현장에서 사용할 수 있는 프로그램을 작성하는 연재글을 올려보려고 합니다.

개인적으로 어떤 분야를 학습하든 가장 효율적인 학습 방법은 누군가를 가르치는 것이라고 생각합니다.

그래서, 이 연재글을 작성하는 저와 읽어주시는 방문자분들 모두에게 도움이 되는 시간일 거라 확신합니다.

대략 12편 정도의 주제로 나누었는데, 하고 싶은 이야기가 생기면 더 길어질 수도 있을 것 같긴 합니다.

그럼 시작해봅시다!

그래서 무엇을 만들까요?

여러분이 웹사이트에 표시된 정보를 직접 복사해 정리해 보신 적이 있다면, 몇 페이지를 넘기지 못하고 같은 생각이 드셨을 것입니다.

이 작업을 프로그램이 대신하게 만들 수는 없을까요?

당연히 가능하죠! 실제로 수없이 많은 프로그램이 이러한 작업을 위해 24시간 구동 중인데요.

웹페이지에서 필요한 정보를 자동으로 가져오는 이런 작업을 웹 스크핑(Web Scraping) 이라고 합니다.

우리가 과거에는 신문이나 잡지를 잘라서, 요새는 다시 읽을 볼로그나 웹사이트 글을 모아두는 걸 흔히 스크랩한다고 하죠.

한 번 배우면 평생 써먹을 수 있는 Rust 웹 스크레이퍼 오늘부터 차근차근 코드 한 줄씩 이해하며 만들어보겠습니다.

구체적으로, 이번 연재글에서는 Rust를 사용해 온라인 서점의 도서 정보를 수집하고, 수집한 결과를 Markdown 문서로 저장하는 프로그램을 단계별로 만들 것입니다.

완성된 프로그램은 다음 순서로 작동합니다.

도서 목록 페이지 요청

각 도서의 상세 페이지 주소 수집

상세 페이지 요청

제목·가격·평점·재고·설명 추출

Markdown 파일 생성

오늘 1편에서는 웹 스크래핑 프로그램의 전체 구조를 살펴보고, Rust로 웹페이지의 HTML을 가져오는 단계까지 구현해 보겠습니다.


실습에 사용할 웹사이트

이번 연재에서 예시로 사용할 웹사이트는 이 곳입니다.

https://books.toscrape.com/

Books to Scrape는 웹 스크래핑 연습을 위해 만들어진 가상의 온라인 서점입니다.

실제 판매 사이트가 아니라 테스트용 사이트이므로 표시된 가격과 평점에는 현실적인 의미가 없습니다. 대신 웹 스크래핑 학습에 필요한 구조를 아래와 같이 골고루 갖추고 있습니다.

  • 도서 목록
  • 도서 상세 페이지
  • 카테고리
  • 가격
  • 별점
  • 재고
  • 표지 이미지
  • 상품 설명
  • 페이지네이션

접속해보면 아시겠지만, 첫 화면에는 20권의 책이 표시되며, 전체 데이터는 1,000권입니다. 총 50개의 목록 페이지를 이동하면서 데이터를 수집할 수 있습니다.

무엇보다 JavaScript로 화면을 생성하지 않기 때문에, 브라우저 자동화 도구 없이 일반적인 HTTP 요청만으로 HTML을 가져올 수 있습니다.


웹 스크래핑 프로그램의 기본 구조

웹 스크래퍼는 복잡해 보이지만 기본 구조는 크게 네 단계로 나눌 수 있습니다.

1. 웹페이지 요청

우리가 웹브라우저로 접속해서 마주하는 사이트는 결국 HTML로 틀이 잡혀 있는 소스 코드입니다.

이 코드를 가져오기 위해, 가장 먼저 프로그램이 웹 서버에 특정 페이지를 요청합니다.

GET https://books.toscrape.com/

서버는 요청을 받으면 HTML 문서를 응답으로 돌려줍니다.

2. HTML 파싱

GET 요청에 성공했다면, 이제 가져온 HTML 코드를 프로그램이 탐색할 수 있는 문서 구조로 변환합니다.

HTML을 단순 문자열로만 다루면 원하는 정보를 찾기 어렵습니다. 따라서 scraper와 같은 라이브러리를 사용해 HTML 요소를 탐색해야 합니다.

3. 필요한 데이터 추출

프로그램이 HTML 구조를 알게 되면, CSS 선택자를 사용해 원하는 요소를 찾을 수 있습니다.

예를 들어 다음과 같은 HTML이 있다고 가정해 보겠습니다.

<article class="product_pod">
    <h3>
        <a href="catalogue/example-book/index.html">
            Example Book
        </a>
    </h3>

    <p class="price_color">£19.99</p>
</article>

책 제목 링크는 다음 CSS 선택자로 찾을 수 있습니다.

article.product_pod h3 a

가격은 다음 선택자로 찾을 수 있습니다.

article.product_pod p.price_color

4. 결과 저장

마지막으로,추출한 데이터를 객체에 담고 Markdown, JSON 또는 CSV 파일로 저장합니다.

이번 연재에서는 책 한 권마다 다음과 같은 Markdown 문서를 생성할 예정입니다.

---
title: "Example Book"
price: "£19.99"
rating: 4
stock: 12
source_url: "https://books.toscrape.com/catalogue/example-book/index.html"
---

책의 상세 설명이 들어가는 영역입니다.

이렇게 생성한 문서는 일반 서류나 PPT 파일, 나아가 정적 사이트 생성기나 블로그 콘텐츠로도 활용할 수 있습니다.

단, 실제 사이트의 콘텐츠를 그대로 복제해 게시하는 방식은 피해야 합니다. 이번 예제에서는 수집 프로그램을 배우는 데 목적을 두고, 생성된 데이터는 로컬 실습 결과로만 사용하겠습니다.


Rust 프로젝트 만들기

이제, 우리 프로그램의 동작 원리를 대략적으로 알게 되었으니, 실제 프로그램을 만들 차례입니다. 하나의 프로그램은 주로 하나의 프로젝트라는 거대한 틀로 시작하게 됩니다. Rust에서 프로젝트를 만드는 방법은 다음과 같습니다.

우선, 터미널에서 새로운 Rust 프로젝트를 생성하겠습니다.

cargo new rust-book-scraper
cd rust-book-scraper

프로젝트가 만들어지면 다음과 같은 디렉터리 구조를 확인할 수 있습니다.

rust-book-scraper/
├── Cargo.toml
└── src/
    └── main.rs

Cargo.toml은 프로젝트의 이름과 라이브러리 의존성을 관리하는 파일입니다.

src/main.rs는 프로그램이 시작되는 지점입니다.


필요한 라이브러리 추가하기

이번 편에서는 웹페이지를 요청하기 위해 reqwest를 사용하겠습니다.

비동기 실행을 위해서는 tokio도 필요합니다. 비동기 실행을 쉽게 설명하자면, 프로그램 동작 중 오래 걸리는 작업은 따로, 동작하게 만들어 메인 프로그램이 "로딩 중" 상태에 오래 머물지 않도록 하는 방법입니다.

Rust는 한 프로젝트에서 사용하는 패키지 목록들을 Cargo.toml 파일로 관리하는데요. 이전 단계에서 생성한 Cargo.toml 파일을 에디터로 열면, 다음과 같습니다.

[package]
name = "rust-book-scraper"
version = "0.1.0"
edition = "2024"

[dependencies]

이제, 우리가 원하는 라이브러리의 이름과 사용할 버전을 [dependencies] 아래 추가해주면 됩니다.

[package]
name = "rust-book-scraper"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = "0.12"
tokio = { version = "1", features = ["full"] }

각 라이브러리의 역할은 다음과 같습니다.

reqwest

Rust에서 HTTP 요청을 보내기 위한 라이브러리입니다. 웹페이지에 GET 요청을 보내고, 서버가 반환한 HTML 문서를 문자열로 읽을 수 있습니다.

tokio

Rust의 비동기 프로그램을 실행하기 위한 런타임입니다. 웹 요청은 서버의 응답을 기다리는 시간이 발생합니다. 이 시간을 효율적으로 처리하기 위해 asyncawait를 사용합니다.


첫 번째 웹 요청 보내기

첫 번째 단계로 웹서버에 요청을 보내기 위해서는 src/main.rs를 다음과 같이 작성합니다.

use reqwest::Client;

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

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

    let response = client
        .get(TARGET_URL)
        .send()
        .await?;

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

    println!("{html}");

    Ok(())
}

이 코드는 Books to Scrape의 첫 페이지를 요청하고, 서버가 반환한 HTML을 터미널에 출력합니다.

코드를 한 줄씩 살펴보겠습니다.


Client 생성하기

use reqwest::Client;

// 중간 코드 생략

let client = Client::new();

Client는 HTTP 요청을 보내는 객체입니다.

간단한 요청 하나는 reqwest::get()으로도 보낼 수 있지만, 앞으로 여러 상세 페이지를 요청할 예정이므로 Client를 생성해 재사용하는 편이 좋습니다. 하나의 클라이언트를 재사용하면 여러 요청에서 네트워크 연결을 효율적으로 관리할 수 있기 때문입니다.

Rust 코드에서는 특정 파일에서 사용할 라이브러리나, 객체, 함수 등을 use 키워드를 사용해 명시합니다. 주로, 파일 가장 위에 작성합니다.


GET 요청 보내기

let response = client
    .get(TARGET_URL)
    .send()
    .await?;

Client객체가 가진 get()메서드는 요청할 URL을 인자로 받습니다.

.get("https://books.toscrape.com/")

여기에 send()메서드를 호출하면 실제 요청이 서버로 전송됩니다.

.send()

웹 요청은 서버의 응답을 기다려야 하므로 비동기 작업입니다. 따라서 뒤에 await를 붙입니다.

.send().await

마지막의 ?는 요청 도중 오류가 발생했을 때 오류를 호출한 쪽으로 전달합니다.

.send().await?;

예를 들어 인터넷 연결이 끊겼거나 서버에 접속할 수 없다면 프로그램은 성공한 것처럼 계속 진행하지 않고 오류를 반환합니다.


응답을 HTML 문자열로 읽기

다음은 위에서처럼 요청했을 때 받은 응답 본문을 Rust 코드에서 다루기 쉬운 문자열로 읽어오는 부분입니다. 아직 HTML 구조를 분석하는 것은 아니며, 실제 파싱은 다음 단계에서 진행하겠습니다.

이를 위해서, 다음과 같은 코드가 필요합니다.

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

웹 서버가 정상적으로 응답했다면, response 객체는 상태 코드, 헤더, 본문 등 여러 정보를 갖게 됩니다. 이때,text()메서드를 호출하면 응답 본문을 문자열로 읽습니다.

Books to Scrape의 경우 응답 본문은 HTML 문서입니다.

<!DOCTYPE html>
<html lang="en">
<head>
    ...
</head>
<body>
    ...
</body>
</html>

현재는 HTML 전체를 그대로 출력합니다.

println!("{html}");

다음 편부터는 이 HTML에서 책 제목과 상세 페이지 주소만 골라내겠습니다.


HTTP 상태 코드 확인하기

웹 요청이 성공했다고 해서 항상 정상적인 HTML을 받은 것은 아닙니다.

서버는 다음과 같은 상태 코드를 반환할 수 있습니다.

200 OK
404 Not Found
500 Internal Server Error

응답 상태를 확인하도록 코드를 조금 개선해 보겠습니다.

use reqwest::Client;

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

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

    let response = client
        .get(TARGET_URL)
        .send()
        .await?
        .error_for_status()?;

    println!("응답 상태: {}", response.status());

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

    println!("가져온 HTML 길이: {} bytes", html.len());

    Ok(())
}

error_for_status()는 400번대 또는 500번대 응답을 오류로 처리합니다.

.error_for_status()?

이 처리가 없으면 404 오류 페이지도 정상 HTML처럼 파싱하려 할 수 있습니다. 이러한 동작은 우리가 원하는 정상적인 프로그램이 아니죠.

실행 결과는 대략 다음과 같은 형태입니다.

응답 상태: 200 OK
가져온 HTML 길이: 51294 bytes

HTML 길이는 사이트 상태에 따라 달라질 수 있습니다.


User-Agent 추가하기

HTTP 요청에는 요청을 보낸 프로그램의 정보를 나타내는 User-Agent 헤더를 넣을 수 있습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;

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

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

    let response = client
        .get(TARGET_URL)
        .header(
            USER_AGENT,
            "rust-book-scraper/0.1"
        )
        .send()
        .await?
        .error_for_status()?;

    println!("응답 상태: {}", response.status());

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

    println!("가져온 HTML 길이: {} bytes", html.len());

    Ok(())
}

현재로서는, 브라우저인 것처럼 위장하기 위한 헤더를 사용할 필요는 없습니다. 연습용 프로그램이라는 사실을 알 수 있도록 프로그램 이름과 버전을 적는 정도면 충분합니다. 다만, 실전에서는 해당 사이트에 적합한 헤더가 필요한 경우가 있으니 참고하시기 바랍니다.


요청 로직을 함수로 분리하기

앞으로 목록 페이지뿐 아니라 수백 개의 상세 페이지를 요청해야 합니다. 이를 위해서는 HTML 요청 코드를 계속 재사용하게 됩니다. 이럴 때는 위 로직을 별도 함수로 분리하면 편리하겠죠?

아래와 같이 HTML 응답 요청만 담당하는 fetch_html() 함수를 작성하고, main() 함수에서 호출하도록 수정하였습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;

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

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
}

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

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

    println!("가져온 HTML 길이: {} bytes", html.len());

    Ok(())
}

이제 다른 페이지가 필요할 때도 fetch_html()을 재사용할 수 있습니다.

let html = fetch_html(
    &client,
    "https://books.toscrape.com/catalogue/page-2.html",
)
.await?;

프로그램의 역할도 분명해집니다.

fetch_html()
    웹페이지 요청 담당

main()
    프로그램 실행 순서 담당

스크래퍼가 커질수록 요청, 파싱, 데이터 정제, 파일 저장을 서로 다른 함수로 나누는 것이 중요합니다.


이번 편의 완성 코드

지금까지 작성한 전체 코드는 다음과 같습니다.

use reqwest::header::USER_AGENT;
use reqwest::Client;

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

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
}

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

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

    println!("응답을 정상적으로 받았습니다.");
    println!("HTML 크기: {} bytes", html.len());

    Ok(())
}

터미널에서는 다음 명령으로 실행합니다.

cargo run

아래와 비슷한 결과가 나타나면 첫 번째 요청에 성공한 것입니다.

응답을 정상적으로 받았습니다.
HTML 크기: 51294 bytes

2편 예고: 아직 책 제목을 가져오지 않은 이유

지금까지는 HTML 전체를 하나의 문자열로 가져오기만 했습니다.

문자열 안에는 책 제목뿐 아니라 메뉴, 카테고리, 가격, 버튼, 페이지 이동 링크 등 페이지를 구성하는 모든 정보가 함께 들어 있습니다.

이 가운데 원하는 요소를 찾으려면 HTML을 파싱하고 CSS 선택자를 사용해야 합니다.

다음 편에서는 scraper 라이브러리를 추가하고, 첫 페이지에 표시된 책들의 제목과 상세 페이지 주소를 추출하겠습니다.

HTML 문자열

HTML 문서로 파싱

article.product_pod h3 a 선택

책 제목과 href 추출

2편에서도 이번과 마찬가지로 라이브러리를 추가해서 새로운 함수, 메서드를 사용할 텐데요. 이 과정에서 새로운 학습과 더불어 배운 것을 자연스럽게 복습하는 효과도 기대해봅니다:)


이번 편에서 배운 내용

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

  • 웹 스크래핑 프로그램의 기본 구조
  • Rust 프로젝트 생성
  • reqwesttokio 설치
  • HTTP GET 요청 전송
  • 응답 본문을 HTML 문자열로 변환
  • HTTP 오류 상태 검사
  • User-Agent 설정
  • 요청 함수를 별도로 분리하는 방법

다음 편에서는 가져온 HTML을 실제로 탐색하겠습니다.

다음 글: Rust의 scraper로 책 제목과 상세 페이지 URL 추출하기