Rust HTTP 클라이언트: reqwest 크레이트 사용법

Rust HTTP 클라이언트: reqwest 크레이트 사용법

웹 API를 호출하거나 외부 서비스와 통신해야 하는 상황은 어떤 언어로 개발하든 빈번하게 마주치게 됩니다. Rust에서는 이런 HTTP 통신을 위해 reqwest라는 크레이트가 사실상 표준처럼 사용되고 있는데요. Python의 requests 라이브러리처럼 직관적인 API를 제공하면서도 Rust답게 타입 안전성과 비동기 처리를 지원하는 것이 특징입니다.

이 글에서는 reqwest 크레이트의 기본적인 사용법부터 실무에서 자주 쓰이는 패턴까지 예제와 함께 살펴보겠습니다.

reqwest란?

reqwest는 Rust 생태계에서 가장 널리 사용되는 HTTP 클라이언트 라이브러리입니다. 이름에서 눈치채셨을 수도 있는데, HTTP request를 보낼 때 쓰는 크레이트라서 reqwest입니다 😄

내부적으로는 hyper라는 저수준 HTTP 라이브러리 위에 구축되어 있어서 성능이 뛰어나면서도, 개발자가 사용하기 편한 고수준 API를 제공합니다. 비동기(async) 방식이 기본이고, 필요하다면 동기(blocking) 방식으로도 사용할 수 있습니다. HTTPS, 쿠키, 리다이렉트, 프록시 등 실무에서 필요한 기능도 대부분 기본 지원하고 있죠.

설치하기

reqwest 크레이트를 사용하려면 Cargo.toml에 의존성을 추가해야 합니다. JSON 데이터를 주고받는 경우가 대부분이니 Serde 관련 크레이트와 비동기 런타임인 tokio도 함께 설치합니다.

Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

reqwest의 json 피처를 활성화하면 요청/응답 본문을 JSON으로 쉽게 변환할 수 있습니다.

GET 요청 보내기

가장 기본적인 GET 요청부터 시작해보겠습니다. reqwest는 비동기 함수를 제공하기 때문에 tokio 런타임과 함께 사용합니다.

use reqwest;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let response = reqwest::get("https://jsonplaceholder.typicode.com/posts/1").await?;

    println!("Status: {}", response.status());

    let body = response.text().await?;
    println!("Body: {body}");

    Ok(())
}
결과
Status: 200 OK
Body: {
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae..."
}

reqwest::get() 함수는 간단한 GET 요청을 보내는 단축 함수입니다. 응답 객체에서 status() 메서드로 상태 코드를 확인하고, text() 메서드로 응답 본문을 문자열로 가져올 수 있습니다. 두 호출 모두 비동기이기 때문에 .await를 붙여야 한다는 점을 잊지 마세요.

JSON 응답 처리

실제로는 응답 본문을 단순한 문자열이 아니라 Rust의 구조체로 역직렬화해서 사용하는 경우가 많습니다. reqwest의 json 피처를 활성화했다면 json() 메서드를 통해 바로 구조체로 변환할 수 있습니다.

use reqwest;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Post {
    user_id: u32,
    id: u32,
    title: String,
    body: String,
}

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let post: Post = reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
        .await?
        .json()
        .await?;

    println!("제목: {}", post.title);
    println!("작성자 ID: {}", post.user_id);

    Ok(())
}
결과
제목: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
작성자 ID: 1

#[serde(rename_all = "camelCase")] 애트리뷰트를 붙이면 JSON의 camelCase 필드명이 Rust의 snake_case 필드명으로 자동 매핑됩니다. Serde 라이브러리 사용법에서 다룬 것처럼 Serde와 reqwest는 함께 쓸 때 궁합이 아주 좋죠.

Client 사용하기

reqwest::get()은 편리하지만, 요청을 보낼 때마다 새로운 HTTP 클라이언트를 생성합니다. 여러 번 요청을 보내야 한다면 Client를 미리 만들어두고 재사용하는 것이 효율적입니다.

use reqwest::Client;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Post {
    user_id: u32,
    id: u32,
    title: String,
    body: String,
}

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

    let post: Post = client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .send()
        .await?
        .json()
        .await?;

    println!("Post #1: {}", post.title);

    let post: Post = client
        .get("https://jsonplaceholder.typicode.com/posts/2")
        .send()
        .await?
        .json()
        .await?;

    println!("Post #2: {}", post.title);

    Ok(())
}

Client는 내부적으로 연결 풀(connection pool)을 관리해서 TCP 연결을 재사용합니다. 같은 서버에 반복적으로 요청을 보내는 상황이라면 성능 차이가 크게 날 수 있어요.

쿼리 파라미터

GET 요청 시 쿼리 스트링을 통해 데이터를 필터링하는 경우가 많죠. query() 메서드를 사용하면 쿼리 파라미터를 깔끔하게 추가할 수 있습니다.

use reqwest::Client;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Post {
    id: u32,
    title: String,
}

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

    // https://jsonplaceholder.typicode.com/posts?userId=1
    let posts: Vec<Post> = client
        .get("https://jsonplaceholder.typicode.com/posts")
        .query(&[("userId", "1")])
        .send()
        .await?
        .json()
        .await?;

    println!("사용자 1의 포스트 수: {}", posts.len());
    for post in &posts {
        println!("  - [{}] {}", post.id, post.title);
    }

    Ok(())
}
결과
사용자 1의 포스트 수: 10
  - [1] sunt aut facere repellat provident occaecati excepturi optio reprehenderit
  - [2] qui est esse
  - [3] ea molestias quasi exercitationem repellat qui ipsa sit aut
  ...

query() 메서드에는 튜플 슬라이스 외에도 HashMap이나 Serde로 직렬화 가능한 구조체를 넘길 수 있습니다.

POST 요청 보내기

새로운 데이터를 서버에 전송할 때는 POST 요청을 사용합니다. json() 메서드를 사용하면 Rust 구조체를 자동으로 JSON 본문으로 변환해서 보내줍니다.

use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NewPost {
    title: String,
    body: String,
    user_id: u32,
}

#[derive(Deserialize, Debug)]
struct CreatedPost {
    id: u32,
}

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

    let new_post = NewPost {
        title: String::from("Rust reqwest 튜토리얼"),
        body: String::from("reqwest로 HTTP 요청 보내기"),
        user_id: 1,
    };

    let created: CreatedPost = client
        .post("https://jsonplaceholder.typicode.com/posts")
        .json(&new_post)
        .send()
        .await?
        .json()
        .await?;

    println!("생성된 포스트 ID: {}", created.id);

    Ok(())
}
결과
생성된 포스트 ID: 101

json() 메서드를 사용하면 Content-Type 헤더가 application/json으로 자동 설정되기 때문에 따로 신경 쓸 필요가 없습니다. 만약 폼 데이터를 보내야 한다면 form() 메서드를 사용하면 됩니다.

PUT과 DELETE 요청

기존 데이터를 수정하거나 삭제할 때는 PUT과 DELETE 요청을 사용합니다. reqwest에서는 put()delete() 메서드를 제공하고, 사용법은 POST와 거의 동일합니다.

use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdatePost {
    id: u32,
    title: String,
    body: String,
    user_id: u32,
}

#[derive(Deserialize, Debug)]
struct PostResponse {
    id: u32,
    title: String,
}

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

    // PUT 요청: 포스트 수정
    let updated = UpdatePost {
        id: 1,
        title: String::from("수정된 제목"),
        body: String::from("수정된 내용"),
        user_id: 1,
    };

    let response: PostResponse = client
        .put("https://jsonplaceholder.typicode.com/posts/1")
        .json(&updated)
        .send()
        .await?
        .json()
        .await?;

    println!("수정됨: [{}] {}", response.id, response.title);

    // DELETE 요청: 포스트 삭제
    let status = client
        .delete("https://jsonplaceholder.typicode.com/posts/1")
        .send()
        .await?
        .status();

    println!("삭제 응답 상태: {status}");

    Ok(())
}
결과
수정됨: [1] 수정된 제목
삭제 응답 상태: 200 OK

요청 헤더 설정

API 호출 시 인증 토큰이나 커스텀 헤더를 보내야 하는 경우가 많습니다. header() 메서드로 개별 요청에 헤더를 추가할 수 있고, Client 생성 시 기본 헤더를 설정할 수도 있습니다.

use reqwest::header::{HeaderMap, AUTHORIZATION, USER_AGENT};
use reqwest::Client;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    // 개별 요청에 헤더 추가
    let client = Client::new();

    let response = client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .header(AUTHORIZATION, "Bearer my-secret-token")
        .header(USER_AGENT, "my-rust-app/1.0")
        .send()
        .await?;

    println!("Status: {}", response.status());

    // Client에 기본 헤더 설정
    let mut headers = HeaderMap::new();
    headers.insert(AUTHORIZATION, "Bearer my-secret-token".parse().unwrap());
    headers.insert(USER_AGENT, "my-rust-app/1.0".parse().unwrap());

    let client = Client::builder()
        .default_headers(headers)
        .build()?;

    let response = client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .send()
        .await?;

    println!("Status: {}", response.status());

    Ok(())
}

매번 같은 인증 토큰을 보내야 하는 상황이라면 Client::builder()로 기본 헤더를 설정해두는 것이 편리합니다. 코드 중복도 줄이고, 헤더를 빼먹는 실수도 방지할 수 있으니까요.

오류 처리

HTTP 요청은 네트워크 문제나 서버 오류 등 다양한 이유로 실패할 수 있습니다. reqwest는 reqwest::Error 타입을 통해 이런 오류 상황을 처리할 수 있게 해줍니다.

use reqwest::Client;

#[tokio::main]
async fn main() {
    let client = Client::new();

    match client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .send()
        .await
    {
        Ok(response) => {
            if response.status().is_success() {
                println!("요청 성공!");
                let body = response.text().await.unwrap();
                println!("{body}");
            } else {
                println!("서버 오류: {}", response.status());
            }
        }
        Err(err) => {
            if err.is_timeout() {
                println!("요청 시간 초과!");
            } else if err.is_connect() {
                println!("연결 실패!");
            } else {
                println!("기타 오류: {err}");
            }
        }
    }
}

여기서 주의할 점이 하나 있는데요. reqwest에서 send() 메서드가 반환하는 ResultErr는 네트워크 오류나 DNS 실패 같은 전송 자체의 실패만 포함합니다. HTTP 4xx나 5xx 응답은 오류가 아니라 정상적인 응답으로 취급되기 때문에, 상태 코드도 확인해야 합니다.

만약 4xx와 5xx 응답을 오류로 처리하고 싶다면 error_for_status() 메서드를 활용하면 됩니다.

let post = client
    .get("https://jsonplaceholder.typicode.com/posts/9999")
    .send()
    .await?
    .error_for_status()?  // 4xx, 5xx일 때 Err 반환
    .json::<serde_json::Value>()
    .await?;

타임아웃 설정

외부 API를 호출할 때 응답이 오지 않아 프로그램이 멈추는 상황을 방지하려면 타임아웃을 설정해야 합니다. Client::builder()를 통해 전체 타임아웃이나 연결 타임아웃을 지정할 수 있습니다.

use reqwest::Client;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let client = Client::builder()
        .timeout(Duration::from_secs(10))
        .connect_timeout(Duration::from_secs(5))
        .build()?;

    let response = client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .send()
        .await?;

    println!("Status: {}", response.status());

    Ok(())
}

timeout()은 요청 전체에 대한 타임아웃이고, connect_timeout()은 TCP 연결 수립까지만의 타임아웃입니다. 프로덕션 코드에서는 타임아웃을 반드시 설정하는 것이 좋습니다.

동기(blocking) 방식

지금까지 살펴본 예제는 모두 비동기 방식이었는데요. 간단한 스크립트나 CLI 도구처럼 비동기가 필요 없는 상황에서는 동기 방식이 더 편할 수 있습니다. reqwest는 blocking 피처를 통해 동기 API도 제공합니다.

우선 Cargo.toml에서 blocking 피처를 활성화합니다.

Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

이렇게 하면 tokio 없이도 HTTP 요청을 보낼 수 있습니다.

use reqwest::blocking::Client;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Post {
    id: u32,
    title: String,
}

fn main() -> Result<(), reqwest::Error> {
    let client = Client::new();

    let post: Post = client
        .get("https://jsonplaceholder.typicode.com/posts/1")
        .send()?
        .json()?;

    println!("[{}] {}", post.id, post.title);

    Ok(())
}

.await가 없어서 코드가 훨씬 간결해진 것을 볼 수 있죠? 비동기 처리가 꼭 필요하지 않은 상황에서는 blocking 모드를 사용하는 것도 좋은 선택입니다.

마치며

지금까지 reqwest 크레이트를 사용해서 Rust에서 HTTP 요청을 보내는 방법을 알아보았습니다. GET, POST, PUT, DELETE 같은 기본적인 HTTP 메서드 사용법부터 JSON 처리, 헤더 설정, 오류 처리, 타임아웃 설정까지 다뤘는데요. 실무에서 웹 API를 호출해야 하는 대부분의 상황을 커버할 수 있을 거라 생각합니다.

reqwest는 JSON 처리를 위한 Serde와 오류 관리를 위한 thiserror를 함께 사용하면 더 안전하고 깔끔한 코드를 작성할 수 있습니다. reqwest 공식 문서에서 더 다양한 기능을 확인해보세요.

더 자세한 내용은 reqwest 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord