실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 1편
목차
들어가며
2023년에 한창 Rust 프로그래밍에 입문하면서 시작한 블로그인데, 3년 만에 실전 예제 포스팅으로 돌아오게 되었네요.
당시에는 Rust를 통해 프로그래밍의 하드웨어적인 측면과 소프트웨어적인 측면을 함께 이해할 수 있는 글을 작성하는 것이 목표였습니다.
사실 현생을 살다 보니 블로그에 꾸준히 집중하기가 쉽지는 않았습니다.
그래서 이 시점에 다시 기초 문법부터 돌아가기보다는, 그동안 제가 직접 작성했던 Rust 프로그램의 코드를 바탕으로 실제로 동작하는 프로그램을 하나씩 만들어보는 연재를 시작해보려고 합니다.
개인적으로 어떤 분야를 공부하든 가장 효과적인 학습 방법 중 하나는 직접 만들어보고, 다시 누군가에게 설명해보는 것이라고 생각합니다.
이 연재도 제가 공부했던 내용을 다시 정리하는 과정인 동시에, 읽어주시는 분들에게 실제 Rust 코드를 따라가볼 수 있는 시간이 되었으면 좋겠습니다.
대략 12편 정도로 계획하고 있지만, 만들다 보면 하고 싶은 이야기가 조금 더 생길 수도 있을 것 같습니다.
그럼 시작해보겠습니다.
그래서 무엇을 만들까요?
웹사이트에 표시된 정보를 직접 복사해서 정리해본 적이 있다면, 몇 페이지를 넘기지 않아 이런 생각이 들었을지도 모릅니다.
이 작업을 프로그램이 대신하게 만들 수는 없을까?
물론 가능합니다.
웹페이지에서 필요한 정보를 자동으로 가져오는 작업을 보통 **웹 스크래핑(Web Scraping)**이라고 합니다.
예전에는 신문이나 잡지에서 필요한 부분을 잘라 모아두는 것을 스크랩한다고 했고, 지금도 다시 읽고 싶은 블로그나 웹사이트의 글을 모아두는 일을 스크랩한다고 표현하곤 합니다.
이번 연재에서는 이 개념을 프로그램으로 옮겨보겠습니다.
Rust를 이용해 온라인 서점의 도서 정보를 수집하고, 수집한 결과를 Markdown 문서로 저장하는 웹 스크래퍼를 단계별로 만들 예정입니다.
완성된 프로그램은 대략 다음 순서로 작동합니다.
도서 목록 페이지 요청
↓
각 도서의 상세 페이지 주소 수집
↓
상세 페이지 요청
↓
제목·가격·평점·재고·설명 추출
↓
Markdown 파일 생성
한 번에 완성 코드를 만들기보다는 필요한 기능을 하나씩 추가하면서 그 과정에서 등장하는 Rust 문법과 라이브러리도 함께 살펴보겠습니다.
오늘 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 파싱
HTML을 가져왔다고 바로 원하는 데이터를 편하게 찾을 수 있는 것은 아닙니다.
처음 받은 HTML은 기본적으로 하나의 문자열입니다.
따라서 이후에는 scraper와 같은 라이브러리를 사용해 이 문자열을 프로그램이 탐색할 수 있는 HTML 문서 구조로 변환합니다.
이 과정을 **파싱(parsing)**이라고 합니다.
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
CSS 선택자를 이용한 실제 HTML 탐색은 다음 편에서 구현하겠습니다.
4. 결과 저장
마지막으로 추출한 데이터를 Rust의 구조체 등에 담고 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"
---
책의 상세 설명이 들어가는 영역입니다.
이렇게 생성한 데이터는 정적 사이트 생성기의 콘텐츠로 사용하거나, 다른 프로그램에서 읽어 다시 가공할 수도 있습니다. 필요하다면 이후 JSON이나 CSV 같은 다른 형식으로 바꾸는 것도 가능합니다.
실제 사이트의 콘텐츠를 그대로 복제해서 게시하는 방식은 피해야 합니다. 이번 예제는 웹 스크래핑 프로그램을 만드는 과정을 학습하는 데 목적이 있으며, 생성된 데이터는 로컬 실습 결과로 사용하겠습니다.
Rust 프로젝트 만들기
프로그램이 어떤 순서로 동작할지 대략 살펴봤으니 이제 실제 프로젝트를 만들어보겠습니다.
터미널에서 새로운 Rust 프로젝트를 생성합니다.
cargo new rust-book-scraper
cd rust-book-scraper
프로젝트가 만들어지면 다음과 같은 기본 구조를 확인할 수 있습니다.
rust-book-scraper/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml은 프로젝트의 이름과 버전, 사용하는 라이브러리 등의 정보를 관리하는 파일입니다.
src/main.rs는 프로그램의 실행이 시작되는 파일입니다.
필요한 라이브러리 추가하기
이번 편에서는 웹페이지에 HTTP 요청을 보내기 위해 reqwest를 사용하겠습니다.
그리고 비동기 코드를 실행하기 위해 tokio도 사용합니다.
웹 요청에는 서버로 요청을 보낸 뒤 응답이 돌아올 때까지 기다리는 시간이 있습니다.
비동기 프로그래밍은 이런 기다림이 발생하는 작업을 효율적으로 다룰 수 있도록 해줍니다.
Rust 프로젝트에서 사용하는 외부 라이브러리는 Cargo.toml의 [dependencies]에서 관리합니다.
앞에서 생성한 Cargo.toml을 열어보면 대략 다음과 같은 형태입니다.
[package]
name = "rust-book-scraper"
version = "0.1.0"
edition = "2024"
[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 요청을 보내고 서버가 반환한 응답 본문을 문자열로 읽는 등의 작업을 할 수 있습니다.
tokio
Rust의 비동기 프로그램을 실행하기 위한 런타임입니다.
이번 프로그램에서는 웹 서버의 응답을 기다리는 HTTP 요청을 처리하기 위해 async와 await를 사용하게 되는데, Tokio가 이러한 비동기 코드를 실행할 환경을 제공해줍니다.
지금은 이 정도만 알아두고 실제 코드에서 하나씩 살펴보겠습니다.
첫 번째 웹 요청 보내기
첫 번째 단계는 웹 서버에 요청을 보내는 것입니다.
src/main.rs를 다음과 같이 작성합니다.
use reqwest::Client;
const TARGET_URL: &str = "https://books.toscrape.com/";
#[::]
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를 하나 만들어 재사용하겠습니다.
하나의 Client를 계속 사용하면 여러 HTTP 요청에서 내부 연결을 재사용하는 등 네트워크 자원을 효율적으로 관리할 수 있습니다.
그리고 Rust에서는 현재 파일에서 사용할 외부 타입이나 함수 등을 보통 use 키워드로 가져옵니다.
use reqwest::Client;
이런 use 문은 일반적으로 파일 위쪽에 모아 작성합니다.
GET 요청 보내기
let response = client
.get(TARGET_URL)
.send()
.await?;
Client 객체의 get() 메서드는 요청할 URL을 받습니다.
.get("https://books.toscrape.com/")
아직 이 단계에서는 실제 요청이 전송된 것은 아닙니다.
뒤이어 send()를 호출하면 HTTP 요청이 서버로 전송됩니다.
.send()
웹 요청은 서버의 응답을 기다려야 하는 작업이므로 여기에서는 await를 사용합니다.
.send().await
그리고 마지막의 ?는 요청 과정에서 오류가 발생했다면 그 오류를 현재 함수의 호출자에게 전달합니다.
.send().await?;
예를 들어 인터넷 연결에 문제가 생겼거나 서버와 정상적으로 통신하지 못했다면 성공한 것처럼 다음 코드를 계속 실행하는 대신 오류를 반환하게 됩니다.
?는 앞으로도 자주 만나게 될 문법입니다.
응답을 HTML 문자열로 읽기
서버로부터 응답을 받았다면 이번에는 응답 본문을 Rust에서 다루기 쉬운 문자열로 읽어오겠습니다.
let html = response
.text()
.await?;
response에는 상태 코드와 헤더, 본문 등 HTTP 응답에 대한 여러 정보가 들어 있습니다.
그중 text() 메서드는 응답 본문을 문자열로 읽어옵니다.
Books to Scrape에서는 응답 본문이 HTML 문서이므로 html 변수에는 다음과 비슷한 문자열이 들어가게 됩니다.
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
...
</body>
</html>
여기서 한 가지 구분할 점이 있습니다.
response.text().await?
는 HTML을 파싱하는 코드가 아닙니다.
지금은 응답 본문을 String으로 읽어왔을 뿐입니다.
실제 HTML 구조를 분석하는 작업은 다음 편에서 scraper 라이브러리를 이용해 진행하겠습니다.
현재는 전체 문자열을 그대로 출력합니다.
println!("{html}");
HTTP 상태 코드 확인하기
웹 서버로부터 응답을 받았다고 해서 항상 우리가 원하는 정상 페이지를 받은 것은 아닙니다.
서버는 상황에 따라 다음과 같은 HTTP 상태 코드를 반환할 수 있습니다.
200 OK
404 Not Found
500 Internal Server Error
예를 들어 주소가 잘못되어 서버가 404 Not Found 페이지를 반환했는데도 그 내용을 정상적인 책 페이지라고 생각하고 파싱하기 시작하면 곤란합니다.
그래서 요청 코드를 조금 수정하겠습니다.
use reqwest::Client;
const TARGET_URL: &str = "https://books.toscrape.com/";
#[::]
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()?
error_for_status()는 400번대와 500번대 HTTP 상태 코드를 오류로 처리합니다.
따라서 정상적인 상태 코드가 아니라면 이후 파싱 작업으로 넘어가지 않고 오류를 반환할 수 있습니다.
실행 결과는 대략 다음과 같은 형태입니다.
응답 상태: 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/";
#[::]
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(())
}
현재는 연습용 사이트를 대상으로 하는 학습 프로그램이므로 브라우저처럼 보이도록 위장할 필요는 없습니다.
대신,
rust-book-scraper/0.1
처럼 프로그램의 이름과 버전을 적어 어떤 프로그램이 요청했는지 알 수 있도록 했습니다.
실제 웹사이트를 대상으로 스크래핑할 때는 사이트의 정책과 이용 조건을 확인하고 그에 맞는 요청 방식을 사용해야 합니다.
요청 로직을 함수로 분리하기
앞으로는 목록 페이지 하나만 요청하지 않습니다.
각 책의 상세 페이지도 요청해야 하고, 이후에는 여러 목록 페이지까지 이동하게 됩니다.
그때마다 동일한 HTTP 요청 코드를 작성하는 것은 불편합니다.
그래서 지금까지 만든 요청 부분을 fetch_html()이라는 함수로 분리하겠습니다.
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
}
#[::]
async fn main() -> Result<(), reqwest::Error> {
let client = Client::new();
let html = fetch_html(
&client,
TARGET_URL,
)
.await?;
println!("가져온 HTML 길이: {} bytes", html.len());
Ok(())
}
이제 다른 페이지의 HTML이 필요하더라도 같은 함수를 재사용할 수 있습니다.
let html = fetch_html(
&client,
"https://books.toscrape.com/catalogue/page-2.html",
)
.await?;
프로그램의 역할도 조금 더 분명해졌습니다.
fetch_html()
↓
웹페이지 요청 담당
main()
↓
프로그램 전체 실행 흐름 담당
지금은 함수가 두 개뿐이라 큰 차이가 없어 보일 수 있습니다.
하지만 앞으로 HTML 파싱과 데이터 변환, 파일 저장 기능까지 추가하면 이런 식으로 역할을 나눠놓은 것이 점점 중요해집니다.
이번 편의 완성 코드
지금까지의 내용을 반영한 전체 코드는 다음과 같습니다.
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
}
#[::]
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
다음과 비슷한 결과가 나타나면 첫 번째 HTTP 요청에 성공한 것입니다.
응답을 정상적으로 받았습니다.
HTML 크기: 51294 bytes
여기까지는 아직 책 제목 하나도 가져오지 않았습니다.
하지만 웹 스크래퍼가 가장 먼저 해야 하는,
원하는 웹페이지에 요청을 보내고
↓
응답 본문을 가져온다
는 단계가 완성됐습니다.
2편 예고: 아직 책 제목을 가져오지 않은 이유
현재 우리가 가지고 있는 것은 HTML 전체가 들어 있는 하나의 문자열입니다.
그 안에는 책 제목뿐 아니라 메뉴, 카테고리, 가격, 버튼, 페이지 이동 링크 등 페이지를 구성하는 수많은 정보가 함께 들어 있습니다.
이 가운데 원하는 요소만 골라내려면 HTML을 파싱하고 CSS 선택자를 사용해야 합니다.
다음 편에서는 scraper 라이브러리를 추가하고 첫 페이지에 표시된 책들의 제목과 상세 페이지 주소를 추출해보겠습니다.
HTML 문자열
↓
HTML 문서로 파싱
↓
article.product_pod h3 a 선택
↓
책 제목과 href 추출
새로운 라이브러리와 메서드가 등장하지만 이번 편에서 했던 것처럼 하나씩 코드를 추가해보겠습니다.
그 과정에서 앞에서 배웠던 내용도 자연스럽게 다시 만나게 될 겁니다.
이번 편에서 배운 내용
이번 편에서는 다음 내용을 구현했습니다.
- 웹 스크래핑 프로그램의 기본 구조
- Rust 프로젝트 생성
reqwest와tokio추가- HTTP GET 요청 전송
- 응답 본문을 HTML 문자열로 읽기
- HTTP 오류 상태 검사
- User-Agent 설정
- 요청 로직을 함수로 분리하는 방법
다음 편에서는 가져온 HTML을 실제로 탐색해보겠습니다.