And Brain said,
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 3 본문
Rust
1. 소개
2. Rust 개발 환경 구축하기
3. 일반적인 프로그래밍 문법
4. Rust의 메모리 관리
5. 구조체, 열거형, 패턴 매칭
6 크레이트, 패키지, 모듈
7. 컬렉션, 그리고 제네릭
8. 트레잇
9. 라이프타임과 빌림 검사기
10. 클로저와 반복자
11. 에러 핸들링
12. 테스트와 문서화
13. I/O, 파일 처리
14. 스마트 포인터
15. 겁을 상실한 동시성 (fearless concurrency)
16. 위험한 Rust (unsafe rust)
17. Final Project: Multi-Thread Web Crawler
18. 끝으로
이제 실전입니다. 끝이 보이니 흥분되지 않으신가요? 축배를 들기 전에 우리의 수행을 마무리 지어봅시다.
Rust를 이용해 고성능 멀티스레드 웹 크롤러를 만들어 봅시다.
시작합시다.
17. Final Project: Multi-Thread Web Crawler
웹 크롤러는 주어진 웹사이트에서 정보를 추출하는 프로그램으로, 멀티스레드를 사용하면 여러 웹페이지를 동시에 크롤링하여 성능을 크게 향상시킬 수 있습니다.
웹 크롤러는 주로 HTTP 요청을 통해 웹페이지를 다운로드하고, HTML 파싱을 통해 필요한 정보를 추출합니다. 또한, 추출된 웹페이지에서 다른 웹페이지로의 링크를 찾아 다음에 크롤링할 페이지를 결정하는 등의 작업이 필요합니다.
이러한 작업들은 독립적으로 수행될 수 있으며, 각각 별도의 스레드에서 처리하면 크롤러의 전체 성능을 향상시킬 수 있습니다. 특히 네트워크 I/O와 같은 블로킹 작업이 많이 포함되어 있어 멀티스레드를 사용하면 크게 성능을 향상시킬 수 있습니다.
그러면, Rust를 이용해 Multi-Thread Web Crawler 만들기를 시작해봅시다. 데이터는 PokeAPI를 사용할 것입니다.
먼저, 프로젝트를 만든 뒤 필요한 라이브러리를 Cargo.toml 파일에 추가합니다.
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
reqwest는 HTTP 요청을 처리하는 라이브러리입니다. tokio는 Rust에서 비동기 프로그래밍을 지원하는 런타임 라이브러리이고, serde는 JSON과 같은 데이터 형식을 직렬화하고 역직렬화하는 라이브러리입니다.
일단, main.rs에 전부 작성한 후 모듈을 나눠볼 것입니다.
데이터 모델
PokeAPI에서 얻은 데이터를 담기 위해 Pokemon 구조체를 만듭니다.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Pokemon {
name: String,
id: u32,
}
#[derive(Deserialize, Debug)]은 serde가 Pokemon 구조체를 자동으로 역직렬화하고 디버깅 정보를 출력할 수 있게 해줍니다.
비동기 API 요청
이제 reqwest를 사용하여 비동기적으로 PokeAPI에 요청을 보낼 함수를 만들어 보겠습니다.
async fn fetch_pokemon(id: u32) -> Result<Pokemon, reqwest::Error> {
let url = format!("https://pokeapi.co/api/v2/pokemon/{}", id);
let response = reqwest::get(&url).await?.json::<Pokemon>().await?;
Ok(response)
}
이 함수는 주어진 ID에 대한 포켓몬 데이터를 비동기적으로 요청합니다. async fn 키워드를 사용하여 함수를 비동기로 만들고, .await를 사용하여 비동기 작업의 결과를 기다립니다.
Multi-Thread
이제 멀티스레드 환경에서 151마리의 포켓몬에 대한 데이터를 동시에 요청해보도록 합시다.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut tasks = vec![];
for id in 1..=151 {
let task = tokio::spawn(async move {
match fetch_pokemon(id).await {
Ok(pokemon) => println!("{:?}", pokemon),
Err(e) => eprintln!("Error: {}", e),
}
});
tasks.push(task);
}
for task in tasks {
task.await?;
}
Ok(())
}
tokio::spawn를 사용하여 비동기 태스크를 새로운 OS 스레드에서 실행하도록 스케줄링합니다. 이렇게 생성된 태스크는 tasks 벡터에 추가됩니다. 그런 다음 모든 태스크가 완료될 때까지 기다립니다.
cargo run 명령어를 통해 실행시켜봅시다.
(추가적으로 몇 초 걸리는지 체크하는 코드를 추가한 후 실행하였습니다.)
151번의 HTTP 요청과 그 응답을 받는 시간이 겨우 7.44초 밖에 걸리지 않았습니다. 여러분들의 컴퓨터 성능에 따라 더 늘어날 수도 줄어들 수도 있지만, 매우 빠른 것은 틀림없습니다.
그러면 이제, 우리의 웹 크롤러를 몇 가지 개선해보도록 할까요?
에러 처리 개선
먼저, 에러 처리를 조금 더 세분화하여 코드를 개선해봅시다. 현재 코드는 reqwest::Error로 모든 에러를 처리하고 있습니다. 이렇게 하면 문제가 발생했을 때 그 원인을 정확히 파악하기 어렵습니다. 예를 들어, URL이 잘못되었는지, 서버가 응답하지 않는지, JSON 파싱이 잘못된 것인지 등을 알 수 없습니다. 그래서 'fetch_pokemon' 함수에서 각 에러 사항에 대해 별도로 처리해보도록 합시다.
async fn fetch_pokemon(id: u32) -> Result<Pokemon, Box<dyn std::error::Error>> {
let url = format!("https://pokeapi.co/api/v2/pokemon/{}", id);
let response = reqwest::get(&url).await.map_err(Box::new)?;
let pokemon = response.json::<Pokemon>().await.map_err(Box::new)?;
Ok(pokemon)
}
map_err() 함수를 사용하여 발생한 에러를 사용자 지정 에러로 변환합니다. 이 경우에는 Box<dyn std::error::Error> 타입의 에러로 변환합니다.
결과 출력 개선
현재 각각의 비동기 작업에서 결과를 즉시 출력하고 있습니다. 대신에 결과를 모아둘 수 있는 구조를 사용하면, 모든 작업이 끝난 후에 결과를 한 번에 출력하거나 처리할 수 있습니다. 또한, ID의 순서도 정렬시켜봅시다.
use std::collections::BTreeMap;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut tasks = vec![];
let start_time = Instant::now();
for id in 1..=151 {
let task = tokio::spawn(async move {
match fetch_pokemon(id).await {
Ok(pokemon) => Some((id, pokemon)),
Err(e) => {
eprintln!("Error: {}", e);
None
}
}
});
tasks.push(task);
}
let mut results = BTreeMap::new();
for task in tasks {
if let Ok(Some((id, pokemon))) = task.await {
results.insert(id, pokemon);
}
}
for (pokemon) in &results {
println!("{:?}", pokemon);
}
let elapsed_time = start_time.elapsed();
println!("Elapsed time: {:.2?}", elapsed_time);
Ok(())
}
BTreeMap이라는 자료구조를 사용했습니다. BTreeMap은 키를 기준으로 정렬된 상태로 요소를 저장합니다. 따라서, 결과를 출력할 때 순서대로 출력됩니다.
한 번에 출력 결과가 나타나며 순서까지 보장된 것을 확인할 수 있습니다.
모듈화
모듈화를 진행하기 전 마지막으로 main 함수의 크기를 줄입니다. 각각의 기능들을 함수로 분리합니다.
use serde::Deserialize;
use std::time::Instant;
use std::collections::BTreeMap;
const POKEAPI_URL: &str = "https://pokeapi.co/api/v2/pokemon/";
const MAX_POKEMON: u32 = 151;
#[derive(Deserialize, Debug)]
struct Pokemon {
name: String,
id: u32,
}
async fn fetch_pokemon(id: u32) -> Result<Pokemon, Box<dyn std::error::Error>> {
let url = format!("{}{}", POKEAPI_URL, id);
let response = reqwest::get(&url).await.map_err(Box::new)?;
let pokemon = response.json::<Pokemon>().await.map_err(Box::new)?;
Ok(pokemon)
}
// 이 함수는 작업 핸들의 벡터를 반환하므로, 이후에 이들 작업이 모두 완료될 때까지 기다리는 데 사용될 수 있습니다.
fn create_fetch_tasks() -> Vec<tokio::task::JoinHandle<Option<Pokemon>>> {
(1..=MAX_POKEMON).map(|id| {
// 각 ID에 대해, fetch_pokemon 함수를 비동기적으로 호출하여 포켓몬 정보를 가져옵니다.
// tokio::spawn를 사용하여 이 함수 호출을 별도의 비동기 작업으로 만들고, 이 작업의 핸들을 반환합니다.
tokio::spawn(async move {
fetch_pokemon(id).await.ok()
})
}).collect()
}
async fn await_tasks(tasks: Vec<tokio::task::JoinHandle<Option<Pokemon>>>) -> BTreeMap<u32, Pokemon> {
let mut results = BTreeMap::new();
// 각 작업에 대해, 해당 작업이 완료될 때까지 기다립니다.
for (id, task) in (1..).zip(tasks) {
// 작업이 성공적으로 완료되었고, 결과가 Some(Pokemon)인 경우에만 맵에 추가합니다.
if let Ok(Some(pokemon)) = task.await {
results.insert(id, pokemon);
}
}
results
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let start_time = Instant::now();
let tasks = create_fetch_tasks();
let results = await_tasks(tasks).await;
for (_, pokemon) in &results {
println!("{:?}", pokemon);
}
let elapsed_time = start_time.elapsed();
println!("Elapsed time: {:.2?}", elapsed_time);
Ok(())
}
이제, 각각의 모듈로 코드를 분할시켜봅시다. 여기서는 프로젝트가 그렇게 크지 않으니 루트 디렉토리 내에서만 분리하겠습니다.
먼저, model.rs 파일에 Pokemon 구조체를 별도로 분리합니다.
// model.rs
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct Pokemon {
pub name: String,
pub id: u32,
}
다음은, api.rs 파일에 'fetch_pokemon' 함수를 분리합니다.
// api.rs
use super::model::Pokemon;
const POKEAPI_URL: &str = "https://pokeapi.co/api/v2/pokemon/";
pub async fn fetch_pokemon(id: u32) -> Result<Pokemon, Box<dyn std::error::Error>> {
let url = format!("{}{}", POKEAPI_URL, id);
let response = reqwest::get(&url).await.map_err(Box::new)?;
let pokemon = response.json::<Pokemon>().await.map_err(Box::new)?;
Ok(pokemon)
}
task.rs 파일에 'create_fetch_tasks' 및 'await_tasks' 함수 두 개를 분리합니다.
// task.rs
// task.rs
use super::model::Pokemon;
use super::api::fetch_pokemon;
use std::collections::BTreeMap;
const MAX_POKEMON: u32 = 151;
pub fn create_fetch_tasks() -> Vec<tokio::task::JoinHandle<Option<Pokemon>>> {
(1..=MAX_POKEMON).map(|id| {
tokio::spawn(async move {
fetch_pokemon(id).await.ok()
})
}).collect()
}
pub async fn await_tasks(tasks: Vec<tokio::task::JoinHandle<Option<Pokemon>>>) -> BTreeMap<u32, Pokemon> {
let mut results = BTreeMap::new();
for (id, task) in (1..).zip(tasks) {
if let Ok(Some(pokemon)) = task.await {
results.insert(id, pokemon);
}
}
results
}
마지막으로, main.rs 파일을 수정합니다.
// main.rs
mod model;
mod api;
mod task;
use std::time::Instant;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let start_time = Instant::now();
let tasks = task::create_fetch_tasks();
let results = task::await_tasks(tasks).await;
for (_, pokemon) in &results {
println!("{:?}", pokemon);
}
let elapsed_time = start_time.elapsed();
println!("Elapsed time: {:.2?}", elapsed_time);
Ok(())
}
이제 끝났습니다. 우리의 간단하지만 강력한 성능의 Multi-Thread 웹 크롤러가 완성되었습니다. 이를 바탕으로 여러분들이 좀 더 멋들어진 크롤러를 만들어보셔도 좋을 것 같습니다.
자, 정말 마지막입니다. 한 단계만 더 밟아봅시다. 이번에는 우리의 프로젝트를 배포해봅시다.
먼저, 첫 배포를 하기에 앞서, crates.io 에 계정을 만들고 API 토큰을 얻어야 합니다. crates.io 홈페이지에 방문하고 GitHub 계정을 통해 로그인 해주세요. 로그인 하셨다면 계정 설정 페이지인 https://crates.io/me/ 로 들어가 주세요. 그리고 페이지에서 API 키를 얻어온 후에, 여러분의 API 키를 이용해 cargo login 명령어를 실행해 주세요.
$ cargo login yourAPITOKENKEY!!
다음은, Cargo.toml 파일에 [package] 구절을 추가하여 메타데이터(metadata) 를 추가해야합니다.
여러분이 배포할 크레이트명은 고유해야 합니다. crates.io 에 올릴 크레이트의 크레이트명을 누군가 선점했다면 해당 크레이트명으로는 크레이트를 배포할 수 없습니다. 크레이트를 배포하기 전에 사이트에서 여러분이 사용하려는 이름을 검색해보고 해당 크레이트명이 이미 사용중인지 확인하세요. 만약 아직 사용중이지 않다는 것을 확인했다면 다음과 같이 Cargo.toml 파일의 [package] 절 아래를 수정해줍시다.
[package]
name = "multi_thread_web_crawler"
version = "0.1.0"
edition = "2021"
authors = ["The Man bibibik1697@gmail.com"]
description = "An asynchronous library for fetching Pokemon data using the PokeAPI"
license = "MIT"
이제 여러분의 깃허브에 소스도 올려줍니다. 그 후,
$ cargo publish
이 명령어를 통해 여러분의 크레이트가 이제 세상에 발을 내딛게 되었습니다.
https://crates.io/crates/multi_thread_web_crawler
모든 수련이 끝났습니다. 작별 인사를 할 때가 되었군요. 이제 여러분은 어엿한 러스타시안(Rustacean)이 되셨습니다. 떠나십시오. 지금부터는 여러분들의 몫입니다. 수고많으셨습니다.
18. 끝으로
이렇게 'Rust, 프로그래밍 언어의 코페르니쿠스적 전환' 총서가 끝났습니다. 여러분들과 함께 걸은 시간은 얼마되지 않지만, 저는 몇 주동안 최소 하루 8시간 이상을 Rust에 몰두했고 그야말로 머릿속이 Rust로 가득 차 있었던 몇 주였던 것 같습니다. 너무나 매력적인 Rust에 본능적으로 끌렸고 다가갈수록 더욱 신비로웠습니다. 물론, 아직 필드에서 Rust를 직접 사용하지는 않지만, 저는 앞으로 Rust를 이용해 몇 가지 개인적인 작업을 할 것입니다. 그것 또한 될 수 있을 때마다 글을 써보도록 할 것이니 지켜봐 주시길 바랍니다.
여러분들은 어떠셨나요? 아직 이 언어에 약간의 흥미정도만 가지고 있으신가요. 혹은 사용할 일이 없다 생각하시는 중이신가요. 괜찮습니다. 여러분들이 옳을 수도 있습니다. 하지만, 시대가 변하고 있는 것 또한 맞습니다. 관심있게 지켜보시다 만약 필요하게 되신다면 그 때에 배우셔도 충분할 수 있습니다.
이 언어가 가지고 있는 매력은 아직 끝나지 않았습니다. 여러분들도 Rust를 이용해 여러분의 프로젝트에 안전하면서도 고성능의 작업을 추가해보세요!
그럼, 여기까지 정말 수고 많으셨습니다. 안녕!
Thanks for watching, Have a nice day.
<- 이전으로
'IT > Rust' 카테고리의 다른 글
OS with Rust, OS 개발기 - 2. 운영체제로 거듭나다(Reborn OS) (0) | 2023.06.07 |
---|---|
OS with Rust, OS 개발기 - 1. 운영체제를 넘어서(Beyond OS) (0) | 2023.06.04 |
컴파일러(Compiler) 만들어보기 (with Rust) (0) | 2023.06.02 |
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 2 (0) | 2023.05.21 |
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 1 (3) | 2023.05.18 |