실전 Rust 프로그래밍 - 웹 스크래퍼 만들기 12편
목차
- 들어가며
- 지금 main.rs에는 너무 많은 역할이 있습니다
- 최종 프로젝트 구조
- Rust에서 파일을 모듈로 사용하려면
- 먼저 데이터만 model.rs로 옮겨봅시다
- 스크래핑과 관련된 코드는 scraper.rs로 옮깁니다
- 출력과 관련된 코드는 output.rs로 옮깁니다
- 이제 main.rs를 다시 열어봅시다
- Cargo.toml도 마지막으로 확인해봅시다
- 프로그램을 다시 실행해봅시다
- 1편의 코드와 지금 코드를 비교해봅시다
- 그래도 아직 완벽한 스크래퍼는 아닙니다
- 12편 동안 실제로 사용한 Rust
- 이 프로젝트의 최종 구조
- 처음부터 이렇게 만들 필요는 없었습니다
- 연재를 마치며
- 부록 예고: 아직 끝나지 않았습니다
들어가며
드디어 마지막 편입니다.
지난 11편에서는 프로그램 곳곳에 남아 있던 unwrap()을 살펴보고, 일부 책에서 오류가 발생하더라도 전체 작업이 멈추지 않도록 에러 처리 방식을 개선했습니다.
아직 안 보셨다면 먼저 11편을 보고 오시는 것을 추천드립니다.
1편에서 처음 작성했던 프로그램을 기억하시나요?
당시 우리가 하던 일은 정말 단순했습니다.
Books to Scrape 접속
↓
HTML 문자열 가져오기
↓
터미널에 출력
그런데 11편까지 진행하면서 프로그램은 어느새 다음 일을 전부 수행하게 됐습니다.
목록 페이지 요청
↓
페이지네이션 자동 순회
↓
책 상세 URL 수집
↓
여러 상세 페이지 동시 요청
↓
HTML 파싱
↓
가격·재고·별점 변환
↓
설명과 이미지 URL 추출
↓
오류 처리
↓
Markdown 생성
↓
slug 디렉터리 생성
↓
파일 저장
처음 계획했던 웹 스크래퍼의 기능은 이미 완성된 셈입니다.
그래서 마지막 편에서는 새로운 기능을 하나 더 얹지 않으려고 합니다.
대신 지금까지 하나의 main.rs에 차곡차곡 쌓아온 코드를 역할별로 정리해보겠습니다.
개인적으로 프로그램을 만들다 보면 이 순간이 꽤 재미있습니다.
처음에는 기능 하나 제대로 동작하게 만드는 것만으로 바빴는데, 어느 순간 코드를 보고,
이제 좀 정리해도 되겠는데?
라는 생각이 드는 시점이 오거든요.
이번 편이 바로 그 시점입니다.
지금 main.rs에는 너무 많은 역할이 있습니다
현재 코드에는 다음과 같은 것들이 모두 들어 있습니다.
Book 구조체
BookLink 구조체
HTTP 요청
URL 처리
목록 페이지 파싱
상세 페이지 파싱
페이지네이션 파싱
가격 변환
재고 변환
별점 변환
Markdown 생성
디렉터리 생성
파일 저장
동시 요청 관리
전체 페이지 순회
코드가 틀린 것은 아닙니다.
작은 프로그램에서는 모든 코드를 main.rs 하나에 두는 것도 충분히 가능합니다.
하지만 지금처럼 역할이 많아지면 특정 기능을 수정하려고 할 때 파일 전체를 계속 위아래로 오가게 됩니다.
예를 들어 Markdown 출력 형식을 바꾸고 싶다고 해보겠습니다.
우리가 찾고 싶은 것은,
render_markdown()
뿐인데 같은 파일 안에는 HTTP 요청 코드와 HTML 파싱 코드까지 모두 들어 있습니다.
반대로 사이트의 CSS 선택자를 수정하고 싶은데 파일 저장 코드까지 같은 곳에 섞여 있습니다.
그래서 이번에는 크게 세 가지 역할로 나눠보겠습니다.
데이터
스크래핑
출력
최종 프로젝트 구조
src 디렉터리를 다음처럼 바꾸겠습니다.
src/
├── main.rs
├── model.rs
├── scraper.rs
└── output.rs
각 파일의 역할은 다음과 같습니다.
model.rs
↓
Book
BookLink
scraper.rs
↓
HTTP 요청
HTML 파싱
URL 처리
데이터 변환
output.rs
↓
Markdown 생성
slug
디렉터리 생성
파일 저장
main.rs
↓
전체 프로그램 흐름
비동기 동시 처리
페이지 이동
단순히 파일을 나누기 위한 파일 분리는 아닙니다.
각 파일을 보고,
이 코드는 왜 여기에 있는가?
라는 질문에 바로 답할 수 있도록 나누는 것이 목표입니다.
Rust에서 파일을 모듈로 사용하려면
현재 모여 있는 코드를 각 파일로 분리해서 사용하려면 이 파일들을 모듈로 인식하도록 만들어야 합니다.
먼저 main.rs 위쪽에 다음 코드를 추가합니다.
mod model;
mod output;
mod scraper;
Rust는 이를 보고 같은 src 디렉터리의,
model.rs
output.rs
scraper.rs
를 각각 모듈로 인식합니다.
그리고 다른 모듈에서 사용할 구조체나 함수에는 pub을 붙입니다.
예를 들어,
pub struct Book {
// ...
}
처럼 말이죠.
pub은 말 그대로 해당 항목을 다른 모듈에서도 사용할 수 있도록 공개하는 역할을 합니다.
그리고 필요한 항목은 use로 가져옵니다.
use model::BookLink;
use output::{
render_markdown,
save_book,
};
use scraper::{
fetch_html,
parse_book_detail,
parse_book_links,
parse_next_page_url,
};
지금까지 외부 crate에서 기능을 가져올 때 계속 사용했던 use를 이번에는 우리가 직접 만든 모듈에서도 사용하는 셈입니다.
먼저 데이터만 model.rs로 옮겨봅시다
model.rs에는 프로그램에서 사용하는 데이터 구조만 두겠습니다.
pub struct BookLink {
pub title: String,
pub url: String,
}
pub struct Book {
pub title: String,
pub price: f64,
pub stock: u32,
pub rating: u8,
pub description: Option<String>,
pub image_url: String,
}
구조체뿐 아니라 각 필드에도 pub이 붙어 있습니다.
다른 모듈에서,
book.title
이나,
book.image_url
을 읽어야 하기 때문입니다.
이제 데이터 정의를 찾고 싶다면 model.rs만 보면 됩니다.
스크래핑과 관련된 코드는 scraper.rs로 옮깁니다
다음은 웹에서 데이터를 가져오고 해석하는 부분입니다.
여기에는 다음 함수들이 들어갑니다.
fetch_html()
make_absolute_url()
parse_book_links()
parse_next_page_url()
parse_price()
parse_stock()
parse_rating()
parse_book_detail()
공통점이 보이죠.
전부,
웹사이트에서 데이터를 가져와 Rust의 데이터로 바꾸는 과정
에 관여하는 함수들입니다.
먼저 필요한 항목을 가져옵니다.
use crate::model::{
Book,
BookLink,
};
use crate::AppResult;
use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{
Html,
Selector,
};
use std::io;
use url::Url;
여기서,
crate::model
은 현재 프로그램의 최상위 crate 안에 있는 model 모듈이라는 뜻입니다.
그리고 11편에서 만든,
AppResult
도 main.rs에서 가져와 사용하겠습니다.
scraper.rs의 전체 코드
scraper.rs는 다음과 같이 정리할 수 있습니다.
use crate::model::{
Book,
BookLink,
};
use crate::AppResult;
use reqwest::header::USER_AGENT;
use reqwest::Client;
use scraper::{
Html,
Selector,
};
use std::io;
use url::Url;
fn invalid_data(
message: &str
) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
message,
)
}
pub async fn fetch_html(
client: &Client,
url: &str,
) -> AppResult<String> {
let response = client
.get(url)
.header(
USER_AGENT,
"rust-book-scraper/0.1"
)
.send()
.await?
.error_for_status()?;
let html =
response.text().await?;
Ok(html)
}
fn make_absolute_url(
base_url: &str,
path: &str,
) -> AppResult<String> {
let url =
Url::parse(base_url)?
.join(path)?;
Ok(url.to_string())
}
pub fn parse_book_links(
html: &str,
page_url: &str,
) -> AppResult<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")
.ok_or_else(|| {
invalid_data(
"목록에서 책 제목을 찾을 수 없습니다."
)
})?
.to_string();
let href = element
.value()
.attr("href")
.ok_or_else(|| {
invalid_data(
"책 상세 페이지 주소를 찾을 수 없습니다."
)
})?;
let url =
make_absolute_url(
page_url,
href,
)?;
books.push(BookLink {
title,
url,
});
}
Ok(books)
}
pub fn parse_next_page_url(
html: &str,
current_url: &str,
) -> AppResult<Option<String>> {
let document =
Html::parse_document(html);
let selector =
Selector::parse(
"li.next a"
)
.unwrap();
let Some(element) =
document
.select(&selector)
.next()
else {
return Ok(None);
};
let href = element
.value()
.attr("href")
.ok_or_else(|| {
invalid_data(
"Next 링크의 href를 찾을 수 없습니다."
)
})?;
let next_url =
make_absolute_url(
current_url,
href,
)?;
Ok(Some(next_url))
}
fn parse_price(
text: &str
) -> AppResult<f64> {
let price = text
.trim()
.trim_start_matches('£')
.parse::<f64>()?;
Ok(price)
}
fn parse_stock(
text: &str
) -> AppResult<u32> {
let (_, stock_text) =
text.split_once('(')
.ok_or_else(|| {
invalid_data(
"재고 형식을 해석할 수 없습니다."
)
})?;
let number = stock_text
.split_whitespace()
.next()
.ok_or_else(|| {
invalid_data(
"재고 수량을 찾을 수 없습니다."
)
})?;
Ok(number.parse::<u32>()?)
}
fn parse_rating(
text: &str
) -> AppResult<u8> {
let rating = match text {
"One" => 1,
"Two" => 2,
"Three" => 3,
"Four" => 4,
"Five" => 5,
_ => {
return Err(
invalid_data(
"알 수 없는 별점입니다."
)
.into()
);
}
};
Ok(rating)
}
pub fn parse_book_detail(
html: &str,
detail_url: &str,
) -> AppResult<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_element = document
.select(&title_selector)
.next()
.ok_or_else(|| {
invalid_data(
"책 제목을 찾을 수 없습니다."
)
})?;
let title = title_element
.text()
.collect::<String>()
.trim()
.to_string();
let price_text = document
.select(&price_selector)
.next()
.ok_or_else(|| {
invalid_data(
"가격을 찾을 수 없습니다."
)
})?
.text()
.collect::<String>();
let stock_text = document
.select(&stock_selector)
.next()
.ok_or_else(|| {
invalid_data(
"재고 정보를 찾을 수 없습니다."
)
})?
.text()
.collect::<String>();
let rating_class = document
.select(&rating_selector)
.next()
.ok_or_else(|| {
invalid_data(
"별점을 찾을 수 없습니다."
)
})?
.value()
.attr("class")
.ok_or_else(|| {
invalid_data(
"별점 class를 찾을 수 없습니다."
)
})?;
let rating_text = rating_class
.split_whitespace()
.find(|class| {
*class != "star-rating"
})
.ok_or_else(|| {
invalid_data(
"별점 값을 찾을 수 없습니다."
)
})?;
let description = document
.select(&description_selector)
.next()
.map(|element| {
element
.text()
.collect::<String>()
.trim()
.to_string()
})
.filter(|text| {
!text.is_empty()
});
let image_path = document
.select(&image_selector)
.next()
.ok_or_else(|| {
invalid_data(
"책 이미지를 찾을 수 없습니다."
)
})?
.value()
.attr("src")
.ok_or_else(|| {
invalid_data(
"이미지 src를 찾을 수 없습니다."
)
})?;
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,
)?;
Ok(Book {
title,
price,
stock,
rating,
description,
image_url,
})
}
코드 자체는 11편과 거의 같습니다.
달라진 점은 어떤 함수를 외부 모듈에서 사용할 것인지에 따라 pub을 붙였다는 것입니다.
예를 들어,
pub async fn fetch_html(...)
은 main.rs에서 사용해야 하기 때문에 공개합니다.
반면,
fn parse_price(...)
는 scraper.rs 내부에서만 사용합니다.
따라서 굳이 pub으로 공개할 필요가 없습니다.
출력과 관련된 코드는 output.rs로 옮깁니다
이제 Markdown과 파일 저장 부분을 분리하겠습니다.
output.rs에는 다음 역할을 모읍니다.
Markdown 문자열 생성
slug 생성
디렉터리 경로 결정
파일 저장
필요한 항목부터 가져옵니다.
use crate::model::Book;
use slug::slugify;
use std::fs;
use std::io;
use std::path::PathBuf;
output.rs의 전체 코드
use crate::model::Book;
use slug::slugify;
use std::fs;
use std::io;
use std::path::PathBuf;
pub fn render_markdown(
book: &Book
) -> String {
let description =
book.description
.as_deref()
.unwrap_or(
"설명이 없습니다."
);
format!(
r#"---
title: "{}"
price: {}
stock: {}
rating: {}
image: "{}"
---
{}
"#,
book.title,
book.price,
book.stock,
book.rating,
book.image_url,
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;
}
}
pub fn save_book(
book: &Book,
markdown: &str,
) -> io::Result<PathBuf> {
let slug =
slugify(
&book.
);
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)
}
여기에서도,
render_markdown()
save_book()
은 main.rs에서 사용하므로 pub입니다.
반면,
unique_dir_path()
는 output.rs 안에서만 사용하므로 비공개로 남겨둡니다.
이렇게 하면 모듈 바깥에서는 정말 필요한 기능만 사용할 수 있습니다.
이제 main.rs를 다시 열어봅시다
가장 큰 변화가 느껴지는 곳입니다.
HTML 선택자도 없고,
Selector::parse(...)
가격 문자열을 변환하는 코드도 없고,
.parse::<f64>()
파일 경로를 만드는 코드도 없습니다.
main.rs는 프로그램의 전체 흐름을 연결하는 역할에 집중합니다.
먼저 모듈을 선언합니다.
mod model;
mod output;
mod scraper;
그리고 필요한 기능을 가져옵니다.
use model::BookLink;
use output::{
render_markdown,
save_book,
};
use scraper::{
fetch_html,
parse_book_detail,
parse_book_links,
parse_next_page_url,
};
이제 main.rs만 읽어도 프로그램이 무엇을 하는지 훨씬 빠르게 파악할 수 있습니다.
process_books()도 main.rs에 남겨두겠습니다
process_books()는 어디에 두어야 할까요?
처음에는 scraper.rs에 넣고 싶을 수도 있습니다.
하지만 이 함수는 단순히 웹페이지를 파싱하는 것만 하지 않습니다.
상세 페이지 요청
↓
Book 생성
↓
Markdown 생성
↓
파일 저장
즉 scraper와 output을 연결하는 작업 흐름에 가깝습니다.
그래서 이번 프로젝트에서는 main.rs에 남겨두겠습니다.
정답이 하나뿐인 것은 아닙니다.
프로그램이 더 커진다면 이런 작업을 별도의,
service.rs
worker.rs
app.rs
같은 모듈로 분리할 수도 있습니다.
하지만 현재 크기에서는 굳이 파일을 하나 더 늘리지 않아도 충분하다고 판단했습니다.
최종 main.rs
mod model;
mod output;
mod scraper;
use futures::{
stream,
StreamExt,
};
use model::BookLink;
use output::{
render_markdown,
save_book,
};
use reqwest::Client;
use scraper::{
fetch_html,
parse_book_detail,
parse_book_links,
parse_next_page_url,
};
use std::error::Error;
const TARGET_URL: &str =
"https://books.toscrape.com/";
const MAX_CONCURRENT_REQUESTS: usize = 5;
pub type AppResult<T> =
Result<
T,
Box<dyn Error + Send + Sync>
>;
async fn process_books(
client: &Client,
books: Vec<BookLink>,
) -> usize {
let mut book_stream =
stream::iter(books)
.map(|book_link| {
let client = client;
async move {
let title =
book_link
.
.clone();
let result = async {
let detail_html =
fetch_html(
client,
&book_link.,
)
.await?;
parse_book_detail(
&detail_html,
&book_link.,
)
}
.await;
(title, result)
}
})
.buffer_unordered(
MAX_CONCURRENT_REQUESTS
);
let mut saved_count = 0;
while let Some((title, result)) =
book_stream.next().await
{
match result {
Ok(book) => {
let markdown =
render_markdown(
&book
);
match save_book(
&book,
&markdown,
) {
Ok(file_path) => {
println!(
"{} 저장 완료",
file_path
.display()
);
saved_count += 1;
}
Err(error) => {
eprintln!(
"파일 저장 실패: {} - {}",
book.title,
error
);
}
}
}
Err(error) => {
eprintln!(
"처리 실패: {} - {}",
title,
error
);
}
}
}
saved_count
}
#[::]
async fn main() -> AppResult<()> {
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,
¤t_url,
)
.await?;
let books =
parse_book_links(
&html,
¤t_url,
)?;
let saved =
process_books(
&client,
books,
)
.await;
total_books += saved;
page_url =
parse_next_page_url(
&html,
¤t_url,
)?;
page_number += 1;
}
println!(
"총 {}권 저장 완료",
total_books
);
Ok(())
}
확실히 짧아졌습니다.
무엇보다 main() 함수만 보면 세부 구현을 몰라도 프로그램의 흐름이 보입니다.
페이지 요청
↓
책 링크 추출
↓
책 처리
↓
다음 페이지 확인
↓
반복
처음부터 코드를 이렇게 나눴다면 오히려 더 이해하기 어려웠을 수도 있습니다.
아직 프로그램이 어떤 모습이 될지도 모르는데 미리 파일부터 여러 개 만들어놓는 셈이니까요.
이번 연재에서는 먼저 동작하는 코드를 하나씩 만들고, 역할이 충분히 드러난 마지막 시점에 나눴습니다.
저는 작은 개인 프로젝트를 만들 때 이런 방식도 꽤 괜찮다고 생각합니다.
Cargo.toml도 마지막으로 확인해봅시다
연재를 진행하면서 몇 개의 crate를 추가했습니다.
현재 필요한 주요 의존성은 다음과 같습니다.
[dependencies]
futures = "..."
reqwest = "..."
scraper = "..."
slug = "..."
tokio = { version = "...", features = ["full"] }
url = "..."
여기서 버전 번호는 여러분이 cargo add를 실행한 시점의 버전에 따라 달라질 수 있습니다.
따라서 이 글의 버전 번호를 그대로 복사하기보다 지금까지 사용했던,
cargo add reqwest
cargo add scraper
cargo add tokio --features full
cargo add url
cargo add slug
cargo add futures
를 이용해 자신의 프로젝트에 필요한 의존성을 추가하는 편이 좋습니다.
Cargo가 실제 버전과 의존성 관계를 Cargo.toml과 Cargo.lock에 기록해줍니다.
프로그램을 다시 실행해봅시다
파일을 나눈다고 프로그램의 동작 자체가 달라진 것은 아닙니다.
터미널에서 다시 실행합니다.
cargo run
정상적으로 정리됐다면 이전과 마찬가지로,
1 페이지 처리 중
books/a-light-in-the-attic/index.md 저장 완료
books/tipping-the-velvet/index.md 저장 완료
...
같은 결과를 확인할 수 있습니다.
차이는 내부 구조입니다.
이전
main.rs
↓
모든 코드
현재
main.rs
↓
전체 흐름
model.rs
↓
데이터
scraper.rs
↓
수집과 파싱
output.rs
↓
결과물 생성
기능은 같지만 앞으로 수정하고 읽기는 훨씬 쉬워졌습니다.
이런 작업을 보통 **리팩터링(refactoring)**이라고 부릅니다.
프로그램이 하는 일 자체를 크게 바꾸지 않으면서 내부 구조를 개선하는 작업입니다.
1편의 코드와 지금 코드를 비교해봅시다
연재 첫 편에서는 HTML을 가져오는 것만으로도 할 일이 많았습니다.
let response = client
.get(TARGET_URL)
.send()
.await?;
let html =
response.text().await?;
처음에는 이 문자열 하나가 최종 결과였습니다.
그 뒤로 필요한 것이 하나씩 생겼습니다.
HTML 안에서 책을 찾아야 한다.
↓
scraper
상세 페이지 주소가 필요하다.
↓
URL 처리
가격을 숫자로 쓰고 싶다.
↓
자료형 변환
이미지 주소가 상대 URL이다.
↓
Url::join()
결과를 남기고 싶다.
↓
파일 시스템
파일 이름이 엉망이다.
↓
slug
첫 페이지만 가져온다.
↓
페이지네이션
너무 느리다.
↓
비동기 동시 처리
책 하나 때문에 전체가 멈춘다.
↓
Result와 Option
돌이켜보면 이번 연재에서 새로운 기능은 대부분 갑자기 등장하지 않았습니다.
현재 코드에서 불편한 점을 하나 발견하고, 그 문제를 해결하는 과정에서 다음 Rust 기능을 사용했습니다.
처음부터 완성 코드를 놓고 문법을 하나씩 설명하는 방식보다 저는 이 흐름이 실전 프로그램을 이해하기 훨씬 좋았습니다.
저 역시 처음에는 그저 동작하게 만들었던 코드가, 연재를 진행하면서 이제는 왜 이렇게 동작하는지 설명할 수 있는 코드가 되었습니다.
그래도 아직 완벽한 스크래퍼는 아닙니다
연재를 끝낸다고 해서 이 코드가 모든 웹사이트에서 사용할 수 있는 완벽한 크롤러가 되는 것은 아닙니다.
실제 사이트를 대상으로 사용한다면 아직 생각할 것이 많습니다.
1. 요청 정책
먼저 대상 사이트의 이용 약관과 크롤링 정책을 확인해야 합니다.
robots.txt가 있다면 어떤 경로의 자동 접근을 허용하거나 제한하는지도 확인해야 합니다.
그리고 기술적으로 요청할 수 있다고 해서 최대한 빠르게 요청해야 하는 것은 아닙니다.
현재 우리는,
const MAX_CONCURRENT_REQUESTS: usize = 5;
로 동시 요청 수를 제한했습니다.
실제 서비스에서는 사이트의 성격에 맞게 이 값을 더 낮추거나 요청 사이에 일정한 간격을 둘 수도 있습니다.
2. 재시도
현재 HTTP 요청이 실패하면 해당 책은 오류를 출력하고 넘어갑니다.
하지만 네트워크 오류는 잠깐 발생했다가 사라지는 경우도 있습니다.
실전에서는,
요청 실패
↓
잠시 기다림
↓
재시도
같은 구조를 추가할 수 있습니다.
특히 서버가 너무 많은 요청을 받았음을 나타내는 응답을 보내는 경우에는 무작정 즉시 다시 요청하는 것보다 기다렸다가 재시도하는 정책이 필요합니다.
3. 로그
이번 연재에서는,
println!()
eprintln!()
을 이용했습니다.
작은 프로그램에서는 충분합니다.
하지만 장시간 실행되는 프로그램이라면,
언제
어떤 URL에서
어떤 오류가 발생했는지
몇 번째 재시도인지
등을 남기는 로그 시스템이 유용합니다.
Rust에서는 이후 tracing 같은 라이브러리를 살펴보는 것도 좋은 다음 단계가 될 수 있습니다.
4. 테스트
우리가 만든 파싱 함수들은 테스트하기 꽤 좋은 형태입니다.
예를 들어,
parse_price("£51.77")
의 결과가 정말,
51.77
인지 확인할 수 있습니다.
parse_stock()이나 parse_rating()도 마찬가지입니다.
더 나아가 작은 HTML 샘플을 준비하고,
parse_book_detail()
이 원하는 Book을 만드는지도 테스트할 수 있습니다.
웹사이트의 구조가 바뀌었을 때 테스트가 먼저 실패한다면 실제 크롤링을 시작하기 전에 문제를 발견할 수도 있습니다.
5. 다시 실행할 때 파일을 어떻게 처리할 것인가
8편에서 만든 unique_dir_path()는 이미 같은 디렉터리가 있으면,
book-title/
book-title-2/
book-title-3/
처럼 새로운 경로를 만듭니다.
중복 제목을 덮어쓰지 않는다는 장점이 있습니다.
하지만 프로그램을 다음 날 다시 실행해도 기존 책에 -2가 붙을 수 있습니다.
실제 프로그램에서는 여기에도 정책이 필요합니다.
기존 파일을 덮어쓸 것인가?
이미 있으면 건너뛸 것인가?
책의 ISBN이나 UPC 같은
고유 ID를 파일명에 사용할 것인가?
이번 연재에서는 중복 파일을 덮어쓰지 않는 단순한 방식을 사용했지만, 실제 프로젝트라면 꽤 중요한 설계 포인트입니다.
6. 가격을 더 정확하게 다뤄야 한다면
이번 예제에서는 가격을,
f64
로 변환했습니다.
자료형 변환과 비교를 배우기에는 충분합니다.
하지만 실제 결제나 회계처럼 금액을 정확하게 계산해야 한다면 부동소수점 오차도 고려해야 합니다.
그런 프로그램이라면 가장 작은 화폐 단위의 정수로 저장하거나 Decimal 계열 자료형을 사용하는 방법을 검토할 수 있습니다.
7. Markdown도 목적에 맞게 바꿀 수 있습니다
이번 연재에서는 범용적으로 이해하기 쉬운 YAML 형태의 Front Matter를 사용했습니다.
---
title: "Example"
rating: 5
---
하지만 결과물을 사용하는 프로그램에 따라 형식은 얼마든지 달라질 수 있습니다.
Markdown
JSON
CSV
데이터베이스
API
로 바꿀 수도 있고, 사용하는 정적 사이트 생성기에 맞게 Front Matter 형식을 변경할 수도 있습니다.
결국 스크래퍼의 핵심은 Markdown 자체가 아니라,
웹의 비정형 데이터
↓
프로그램이 다룰 수 있는 구조
↓
원하는 결과물
로 변환하는 과정입니다.
12편 동안 실제로 사용한 Rust
이번에는 연재 전체를 Rust 관점에서 다시 한번 보겠습니다.
처음 계획보다 꽤 많은 기능을 실제 코드 안에서 사용했습니다.
| 주제 | 어디에 사용했나 |
|---|---|
struct | Book, BookLink |
String, 숫자 자료형 | 수집 데이터 저장 |
Vec<T> | 책 목록 저장 |
Option<T> | 설명, Next 페이지 |
Result<T, E> | HTTP, 파싱, 오류 처리 |
match | 별점 변환, 성공/실패 분기 |
? | 오류 전달 |
| trait | Error, Stream 기능 |
Box<dyn Error> | 여러 오류 타입 통합 |
async / await | HTTP 요청 |
| Stream | 비동기 작업 흐름 |
| closure | map(), filter() |
| iterator | 문자열과 목록 처리 |
PathBuf | 파일 경로 |
| module | 최종 코드 분리 |
처음에는 각각 따로 공부했던 Rust 문법이었을 수도 있습니다.
하지만 하나의 프로그램 안에서 만나고 나니 왜 필요한 기능인지 조금은 다르게 보이지 않을까 싶습니다.
저도 Rust를 공부하면서 문법 하나만 떼어놓고 볼 때보다 실제로 필요한 순간에 다시 만났을 때 훨씬 오래 기억에 남았습니다.
이 프로젝트의 최종 구조
이제 프로젝트를 정리하면 다음과 같습니다.
rust-book-scraper/
├── Cargo.toml
├── Cargo.lock
│
├── src/
│ ├── main.rs
│ ├── model.rs
│ ├── scraper.rs
│ └── output.rs
│
└── books/
├── a-light-in-the-attic/
│ └── index.md
│
├── tipping-the-velvet/
│ └── index.md
│
└── ...
우리가 처음 만들었던,
HTML을 출력하는 프로그램
이 12편을 지나,
웹사이트를 스스로 순회하고
필요한 데이터를 추출하고
여러 요청을 동시에 처리하고
일부 오류를 견디며
결과물을 파일로 저장하는 프로그램
이 됐습니다.
처음부터 이렇게 만들 필요는 없었습니다
이번 연재를 끝내면서 가장 이야기하고 싶은 것은 이 부분입니다.
지금 완성 코드를 처음 봤다면 꽤 복잡해 보였을 겁니다.
module
Result
Option
Stream
async
trait object
PathBuf
가 한꺼번에 등장하니까요.
하지만 실제로 우리가 만든 과정은 그렇지 않았습니다.
처음에는 웹페이지 하나를 가져왔습니다.
그다음 책 링크 하나를 찾았습니다.
상세 페이지에 들어가봤고, 문자열을 숫자로 바꿨고, 파일 하나를 저장했습니다.
20권밖에 못 가져오는 것이 아쉬워 다음 페이지를 찾았고, 1,000권을 순서대로 요청하니 느려서 동시성을 붙였습니다.
그러다 하나가 실패하면 전부 멈추는 것이 마음에 들지 않아 오류 처리도 바꿨습니다.
그리고 이제서야 커진 코드를 여러 파일로 나눴습니다.
결국 완성된 프로그램도 작은 문제들을 하나씩 해결한 결과입니다.
연재를 마치며
처음 이 연재를 시작한 이유는 Rust 문법을 다시 설명하기 위해서가 아니었습니다.
저도 Rust를 공부하면서 문법과 예제만 볼 때는,
그래서 이걸 실제 프로그램에서 언제 쓰지?
싶었던 순간이 꽤 많았습니다.
그래서 이전에 개인 프로젝트로 만들었던 웹 스크래퍼를 다시 처음부터 만들어보면서, 필요한 순간마다 Rust의 기능을 하나씩 꺼내보자는 생각으로 시작했습니다.
그 과정에서 처음 계획보다 설명할 것이 훨씬 많아졌고 결국 12편까지 왔네요.
지금까지 코드를 그대로 따라왔다면 작은 학습용 프로그램이기는 해도 하나의 시작부터 끝까지 직접 연결해본 셈입니다.
물론 여기서 더 할 수 있는 것은 많습니다.
재시도를 붙일 수도 있고, 테스트를 작성할 수도 있고, 데이터베이스에 저장할 수도 있고, CLI 옵션을 만들어 다른 사이트 주소를 받을 수도 있습니다.
하지만 그건 이제 이 연재의 다음 편이 아니라 각자의 프로젝트가 될 것 같습니다.
저 역시 처음부터 지금의 코드를 한 번에 작성한 것은 아닙니다.
작동하지 않는 코드를 고치고, 구조가 마음에 들지 않아 다시 나누고, 필요해진 기능을 하나씩 붙였습니다.
그 과정 자체가 실전 프로그래밍에 더 가깝다고 생각합니다.
12편 동안 함께 따라와 주셔서 감사합니다.
이제 이 스크래퍼는 여기서 끝이지만, 이 코드에서 시작할 수 있는 프로젝트는 꽤 많아 보입니다:)
부록 예고: 아직 끝나지 않았습니다
더운 여름날 기획을 시작하고 9월의 반이 지날 때까지 꾸준히 업로드한 연재글이었습니다.
아직 떠나보내기 싫은 마음과 함께, 조금 남은 아쉬움을 달래는 부록을 하나씩 작성해보려고 합니다.
실제 프로젝트를 할 때는 완전히 이해하지 않더라도 원하는 결과물이 나오면 일단 넘어가는 경우도 많잖아요?
한 줄 한 줄 설명하기 위해 서치하고 공부하면서 저 역시 그런 부분이 많았다고 느끼게 되었습니다.
나도 아직 이 부분을 제대로 이해하고 있지 않았구나 싶은 부분도 많았구요.
9월 말까지는 한동안 이런 부분을 하나씩 짚어나가는 글들을 준비해서 올려보고자 합니다.
이미 한두 편은 주제를 생각해둔 상태라 금방 다시 만나 뵙겠습니다. (내일이 될지도?) :)