실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 3편
목차
들어가며
지난 2편에서는 scraper 라이브러리를 사용해 HTML 문서를 파싱하고, 첫 페이지에 있는 책들의 제목과 상세 페이지 주소를 가져왔습니다.
아직 안 보셨다면 먼저 2편을 보고 오시는 것을 추천드립니다.
지난 편의 프로그램을 실행하면 다음과 같이 책 제목과 주소가 출력되었습니다.
찾은 책: 20권
제목: A Light in the Attic
주소: catalogue/a-light-in-the-attic_1000/index.html
제목: Tipping the Velvet
주소: catalogue/tipping-the-velvet_999/index.html
...
여기까지 왔다면 목록 페이지에서 필요한 링크를 골라내는 것에는 성공한 셈입니다.
하지만 자세히 보면 아직 문제가 하나 남아 있습니다.
catalogue/a-light-in-the-attic_1000/index.html
이 주소를 그대로 브라우저 주소창에 넣는다면 정상적으로 접속할 수 없습니다.
우리가 원하는 주소는 다음과 같은 완전한 형태이기 때문입니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
오늘은 지난 편에서 가져온 상대 URL을 절대 URL로 바꾸고, 그 주소를 이용해 각각의 책 상세 페이지에 실제로 요청을 보내보겠습니다.
드디어 목록 페이지를 벗어나 각 책의 안쪽으로 들어갈 차례입니다.
상대 URL을 다시 한번 살펴봅시다
지난 편에서 HTML의 <a> 태그에 들어 있는 href 값을 가져왔습니다.
<a
href="catalogue/a-light-in-the-attic_1000/index.html"
title="A Light in the Attic"
>
여기서 href 값은 다음과 같습니다.
catalogue/a-light-in-the-attic_1000/index.html
잠시 2편의 내용을 복습하자면,
프로토콜과 도메인이 생략되고 현재 페이지의 위치를 기준으로 해석되는 이런 주소를 상대 URL(Relative URL) 이라고 합니다.
반대로 다음처럼 전체 주소가 모두 포함된 형태를 절대 URL(Absolute URL) 이라고 합니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
브라우저는 현재 접속 중인 페이지의 주소를 이미 알고 있기 때문에 상대 URL만 있어도 알아서 이동할 수 있습니다.
하지만 우리 Rust 프로그램은 다음 요청을 어디로 보내야 하는지 정확한 주소를 알아야 합니다.
따라서 상대 URL을 절대 URL로 바꿔주는 작업이 필요합니다.
가장 단순하게 주소를 합쳐볼까요?
현재 사이트의 기본 주소는 다음과 같습니다.
https://books.toscrape.com/
그리고 책의 상대 URL은 다음과 같습니다.
catalogue/a-light-in-the-attic_1000/index.html
두 문자열을 붙이면 우리가 원하는 주소가 만들어집니다.
https://books.toscrape.com/
+
catalogue/a-light-in-the-attic_1000/index.html
↓
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Rust에서는 format!() 매크로를 이용해 간단히 문자열을 합칠 수 있습니다.
let detail_url = format!(
"{}{}",
TARGET_URL,
book.url
);
예를 들어 book.url에 다음 값이 들어 있다면,
catalogue/a-light-in-the-attic_1000/index.html
detail_url은 다음과 같이 만들어집니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
우선은 이 방식으로 진행해보겠습니다.
실전에서는 상대 URL의 형태가 다양할 수 있어 URL 전용 라이브러리를 사용하는 것이 더 안전하지만, 지금 사이트의 구조에서는 이 방법만으로도 충분합니다.
만들어진 URL을 먼저 출력해봅시다
지난 편의 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);
for book in books {
println!("제목: {}", book.title);
println!("주소: {}", book.url);
println!();
}
Ok(())
}
여기에 format!()을 추가합니다.
for book in books {
let detail_url = format!(
"{}{}",
TARGET_URL,
book.url
);
println!("제목: {}", book.title);
println!("상세 주소: {}", detail_url);
println!();
}
실행해보겠습니다.
cargo run
정상적으로 동작한다면 다음과 같은 결과를 확인할 수 있습니다.
제목: A Light in the Attic
상세 주소: https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
제목: Tipping the Velvet
상세 주소: https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
...
이제 우리가 수집한 모든 책이 실제로 요청 가능한 URL을 갖게 되었습니다.
이제 상세 페이지에 요청을 보내봅시다
사실 다음 단계는 생각보다 간단합니다.
1편에서 이미 URL 하나에 HTTP 요청을 보내는 fetch_html() 함수를 만들어두었기 때문입니다.
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
}
이 함수는 어떤 URL이 들어오든 해당 주소에 GET 요청을 보내고 HTML을 문자열로 반환합니다.
지난번에는 TARGET_URL을 넣었습니다.
let html = fetch_html(
&client,
TARGET_URL,
)
.await?;
이번에는 만들어둔 detail_url을 넣으면 됩니다.
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
그동안 만든 함수들을 재사용하는 순간입니다.
저는 이럴 때 과거의 제 자신을 칭찬하곤 합니다. 만들어두길 잘했어! 하구요.
20개의 상세 페이지에 차례대로 요청하기
이제 for문 안에서 각 책의 상세 페이지 HTML 코드를 하나씩 요청해보겠습니다.
for book in books {
let detail_url = format!(
"{}{}",
TARGET_URL,
book.url
);
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
println!("제목: {}", book.title);
println!("주소: {}", detail_url);
println!("HTML 크기: {} bytes", detail_html.len());
println!();
}
코드의 동작은 다음과 같습니다.
첫 번째 책 URL 생성
↓
첫 번째 상세 페이지 요청
↓
HTML 수신
↓
두 번째 책 URL 생성
↓
두 번째 상세 페이지 요청
↓
HTML 수신
↓
...
첫 페이지에는 총 20권의 책이 있으므로 fetch_html()도 20번 호출됩니다.
실행 결과는 대략 다음과 같습니다.
제목: A Light in the Attic
주소: https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
HTML 크기: 10120 bytes
제목: Tipping the Velvet
주소: https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
HTML 크기: 10542 bytes
...
HTML의 실제 크기는 사이트 상태에 따라 달라질 수 있습니다.
중요한 것은 각 책마다 서로 다른 HTML을 정상적으로 가져오고 있다는 점입니다.
지금 프로그램은 순서대로 움직입니다
여기서 프로그램의 동작 방식을 조금 자세히 살펴볼 필요가 있습니다.
현재 코드는 다음처럼 작성되어 있습니다.
for book in books {
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
}
await가 for문 안에 있기 때문에 첫 번째 요청이 끝날 때까지 기다린 뒤 두 번째 요청을 시작합니다.
예를 들면 이런 방식입니다.
1번 요청 시작
↓
1번 응답 완료
↓
2번 요청 시작
↓
2번 응답 완료
↓
3번 요청 시작
즉, 현재는 여러 페이지에 요청을 동시에 보내는 것이 아니라 순차적으로 요청하고 있습니다.
이 방식은 속도 면에서는 아주 빠르다고 할 수 없지만, 지금 단계에서는 오히려 장점도 있습니다.
코드가 단순하고 서버에 한꺼번에 많은 요청을 보내지 않기 때문입니다.
처음 웹 스크래퍼를 만들 때는 무작정 많은 요청을 동시에 보내기보다 이렇게 순차적으로 정상 동작을 확인하는 것이 좋습니다.
나중에 여러 요청을 동시에 처리하도록 프로그램을 개선하게 되면 짧은 시간 동안 많은 요청을 보낼 수도 있습니다. 이 경우 웹 서버에 부담을 줄 수 있기 때문에, 요청 수나 간격을 적절하게 조절하는 것도 중요합니다.
Client를 다시 만들지 않는 이유
여기서 지난 1편에서 왜 Client::new()를 한 번만 호출했는지도 조금 더 분명해집니다.
현재 Client는 main()에서 한 번만 생성합니다.
let client = Client::new();
그리고 목록 페이지를 가져올 때도 같은 client를 사용합니다.
let html = fetch_html(
&client,
TARGET_URL,
)
.await?;
상세 페이지를 가져올 때도 같은 객체를 다시 전달합니다.
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
만약 매번 다음처럼 만들었다면 어떨까요?
for book in books {
let client = Client::new();
// 요청
}
요청할 때마다 새로운 HTTP 클라이언트를 만들게 됩니다.
반대로 지금처럼 하나의 Client를 계속 재사용하면 내부적으로 연결을 재사용할 수 있어 여러 HTTP 요청을 처리하는 데 더 적합합니다.
처음에는 별것 아닌 코드 한 줄처럼 보이지만, 페이지가 20개, 100개, 1,000개로 늘어나면 이런 구조의 차이가 퀄리티 높은 프로그램을 만드는 중요한 열쇠가 됩니다.
상세 페이지를 직접 확인해봅시다
이제 우리가 가져온 상세 페이지에는 어떤 데이터가 들어 있는지 확인해보겠습니다.
예시로 첫 번째 책의 주소를 브라우저에서 열어봅니다.
https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
목록 페이지에서는 제목과 가격 정도만 간단하게 볼 수 있었지만, 상세 페이지에는 훨씬 다양한 정보가 존재합니다.

예를 들면 다음과 같습니다.
책 제목
가격
재고 여부
상품 설명
UPC
상품 타입
세금
리뷰 수
별점
카테고리
이미지
이제 앞으로 우리가 할 일도 보이기 시작합니다.
1편에서는 웹페이지 자체를 가져왔고,
2편에서는 목록 페이지에서 상세 주소를 찾았으며,
이번 3편에서는 그 주소를 이용해 각 책의 상세 페이지 HTML까지 가져왔습니다.
다음 단계에서는 이 상세 HTML을 다시 scraper로 파싱해 필요한 데이터를 하나씩 뽑아내면 됩니다.
URL 생성도 함수로 분리해봅시다
현재는 main() 안에서 다음 코드를 직접 작성하고 있습니다.
let detail_url = format!(
"{}{}",
TARGET_URL,
book.url
);
한 줄짜리 작업이라 굳이 함수로 나눌 필요가 없어 보일 수도 있습니다.
하지만 앞으로 코드가 길어질 것을 생각하면 URL을 만드는 역할도 별도의 함수로 분리해두는 편이 좋습니다.
fn make_detail_url(path: &str) -> String {
format!("{}{}", TARGET_URL, path)
}
이제 main()에서는 다음처럼 사용할 수 있습니다.
let detail_url = make_detail_url(&book.);
코드만 보아도 무엇을 하는지 바로 이해할 수 있습니다.
make_detail_url()
상세 페이지 URL을 만든다.
함수로 코드를 나누는 이유가 단순히 줄 수를 줄이기 위함은 아닙니다.
각 코드가 무엇을 담당하고 있는지 이름을 붙여주는 것도 중요한 이유 중 하나입니다.
그런데 문자열을 그냥 붙여도 항상 괜찮을까요?
지금은 다음과 같이 문자열을 이어 붙이고 있습니다.
format!("{}{}", TARGET_URL, path)
Books to Scrape에서는 잘 동작합니다.
하지만 실제 웹사이트를 다루다 보면 상대 URL의 형태가 제각각일 수 있습니다.
예를 들어 다음처럼 /로 시작할 수도 있습니다.
/catalogue/book/index.html
이 상태에서 기본 URL 끝에도 /가 있다면 단순히 붙였을 때 이런 주소가 만들어질 수 있습니다.
https://example.com//catalogue/book/index.html
혹은 현재 페이지를 기준으로 상위 디렉터리로 이동하는 주소가 들어 있을 수도 있습니다.
../book/index.html
이런 경우에는 문자열을 단순히 이어 붙이는 것보다 URL 전용 도구를 사용하는 것이 안전합니다.
Rust에서는 url crate의 Url::join()을 사용할 수 있습니다.
다만 지금 단계에서는 새로운 라이브러리를 한꺼번에 너무 많이 추가하지 않고, 현재 예제 사이트에서 정상적으로 작동하는 구조를 먼저 완성해보겠습니다.
URL을 보다 안전하게 처리하는 방법은 프로그램을 다듬는 과정에서 다시 다루겠습니다.
지금까지의 프로그램 흐름을 다시 봅시다
처음 연재를 시작할 때 전체 프로그램의 흐름을 다음과 같이 잡았습니다.
도서 목록 페이지 요청
↓
각 도서의 상세 페이지 주소 수집
↓
상세 페이지 요청
↓
제목·가격·평점·재고·설명 추출
↓
Markdown 파일 생성
이제 어디까지 왔는지 표시해보겠습니다.
[완료] 도서 목록 페이지 요청
↓
[완료] 각 도서의 상세 페이지 주소 수집
↓
[완료] 상세 페이지 요청
↓
[다음] 제목·가격·평점·재고·설명 추출
↓
[예정] Markdown 파일 생성
어느새 전체 구조의 절반 이상이 실제 코드로 만들어졌습니다.
웹 스크래핑은 처음 보면 뭔가 거대한 자동화 작업처럼 보이지만, 이렇게 하나씩 쪼개보면 결국 지금까지 배운 몇 가지 작업을 연결하는 과정이라는 것을 알 수 있습니다.
이번 편의 완성 코드
지금까지 작성한 코드를 하나로 합쳐보겠습니다.
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,
}
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)
}
#[::]
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 in books {
let detail_url =
make_detail_url(&book.);
let detail_html = fetch_html(
&client,
&detail_url,
)
.await?;
println!("제목: {}", book.title);
println!("주소: {}", detail_url);
println!(
"HTML 크기: {} bytes",
detail_html.len()
);
println!();
}
Ok(())
}
터미널에서 실행합니다.
cargo run
정상적으로 요청이 이루어진다면 첫 페이지의 20권에 대해 각각의 상세 페이지 HTML을 가져오게 됩니다.
처음에는 URL 하나만 요청하던 프로그램이 어느새 목록을 읽고, 20개의 주소를 찾아서, 각 주소를 다시 방문하는 프로그램으로 자랐습니다.
4편 예고: 상세 페이지의 데이터를 뽑아봅시다
이제 각 책의 상세 페이지 HTML까지 손에 넣었습니다.
다음 편부터는 지금까지 배운 CSS 선택자와 scraper를 다시 활용해 실제 데이터를 추출해보겠습니다.
예를 들어 상세 페이지에는 다음과 같은 가격 정보가 있습니다.
<p class="price_color">£51.77</p>
이 요소를 가져오기 위한 선택자는 다음처럼 작성할 수 있습니다.
p.price_color
재고 정보도 찾을 수 있습니다.
<p class="instock availability">
In stock (22 available)
</p>
상품 설명, 별점, 이미지도 같은 방식으로 접근할 수 있습니다.
다음 편에서는 우선 상세 페이지에서 제목, 가격, 재고, 별점을 가져오고, 이를 하나의 Book 구조체에 저장해보겠습니다.
여기까지 완성하면 우리가 만드는 프로그램도 단순히 웹페이지를 돌아다니는 수준을 넘어 실제로 데이터를 수집하는 프로그램다운 모습을 갖추게 됩니다.
이번 편에서 배운 내용
이번 편에서는 다음 내용을 구현했습니다.
- 상대 URL과 절대 URL의 차이
format!()을 이용한 상세 페이지 URL 생성- 목록에서 수집한 URL을
for문으로 순회하는 방법 - 기존
fetch_html()함수 재사용 - 여러 상세 페이지에 순차적으로 HTTP 요청 보내기
await가 반복문 안에서 동작하는 방식- 하나의
Client를 여러 요청에서 재사용하는 이유 - URL 생성 로직을 별도의 함수로 분리하는 방법
- 단순 문자열 결합 방식의 한계
다음 편에서는 이번에 가져온 상세 페이지 HTML에서 실제 책 정보를 추출해보겠습니다.