reqwest와 serde를 사용하여 복원력 있고 타입 안전한 Rust API 클라이언트 구축하기
Wenhao Wang
Dev Intern · Leapcell

소개
오늘날의 상호 연결된 소프트웨어 환경에서 애플리케이션은 API를 통해 외부 서비스와 자주 상호 작용합니다. HTTP 요청을 만들고 응답을 파싱하는 것은 기본적인 작업이지만, 이를 안정적으로 완전한 데이터 무결성을 가지고 수행하는 것은 놀라울 정도로 어려울 수 있습니다. 수동 파싱은 종종 상용구 코드로 이어지고, 데이터 구조에 대한 잘못된 가정으로 인한 잠재적인 런타임 오류, 어려운 디버깅 환경을 야기합니다. 특히 타입 안전성이 설계 철학의 초석인 Rust와 같은 언어에서는 더욱 그렇습니다.
이 문서는 HTTP 통신을 처리하는 두 가지 주요 크레이트인 reqwest
와 효율적이고 안정적인 데이터 직렬화 및 역직렬화를 위한 serde
의 강점을 활용하여 Rust에서 강력하고 타입 안전한 API 클라이언트를 구축하는 방법을 자세히 살펴봅니다. 이러한 강력한 도구를 결합함으로써 성능이 뛰어난 클라이언트를 만들 수 있을 뿐만 아니라 외부 API와 교환되는 데이터에 대한 컴파일 타임 보장을 제공하여 런타임 오류 가능성을 크게 줄이고 개발자 생산성을 향상시킬 수 있습니다.
핵심 개념 설명
구현에 들어가기 전에 API 클라이언트의 기반이 되는 핵심 개념을 간략하게 정의해 보겠습니다.
- HTTP 클라이언트: 기본적으로 API 클라이언트는 원격 서버에 HTTP 요청(GET, POST, PUT, DELETE 등)을 보내고 HTTP 응답을 받습니다.
reqwest
는 Rust를 위한 인기 있고 인체 공학적이며 비동기 우선 HTTP 클라이언트입니다. 이는 저수준 네트워킹 세부 정보를 처리하여 애플리케이션 로직에 집중할 수 있도록 합니다. - 직렬화: 이는 메모리 내 데이터 구조(Rust
struct
와 같은)를 네트워크를 통해 전송하거나 저장하기에 적합한 형식(예: JSON, YAML, XML)으로 변환하는 과정입니다. API에 데이터를 보낼 때 Rust 데이터를 API의 예상 형식으로 직렬화합니다. - 역직렬화: 직렬화의 반대 과정으로, 전송/저장 형식의 데이터를 메모리 내 데이터 구조로 다시 변환하는 과정입니다. API에서 응답을 받을 때 해당 콘텐츠를 Rust 타입으로 역직렬화합니다.
serde
: Rust를 위한 사실상의 직렬화/역직렬화 프레임워크입니다. 수동 파싱 로직을 작성하지 않고도 개발자가 사용자 지정 데이터 타입을 쉽게 직렬화 및 역직렬화할 수 있도록 하는 파생 매크로 시스템을 제공합니다. 이는 생태계 크레이트(예:serde_json
,serde_yaml
)를 통해 수많은 형식을 지원합니다.- 타입 안전성: Rust에서 타입 안전성은 컴파일러가 변수가 선언된 타입에 따라 사용되는지 확인한다는 것을 의미합니다. 이는 숫자 위에 문자열 연산을 시도하는 것과 같은 모든 종류의 오류를 방지합니다. API와 상호 작용할 때 타입 안전성은 보내고 받는 데이터가 예상과 일치하도록 보장하여 런타임이 아닌 컴파일 시간에 불일치를 포착합니다.
- 오류 처리: 복원력 있는 API 클라이언트는 네트워크 문제, 잘못된 서버 응답 또는 API별 오류 메시지에서 발생하는 오류를 정상적으로 처리해야 합니다. Rust의
Result
열거형은 이를 위해 완벽하게 적합하며 잠재적인 실패 경로를 명시적으로 관리할 수 있도록 합니다.
클라이언트 구축: 원칙 및 실습
우리의 목표는 API의 입력 및 출력 구조를 Rust 타입으로 명확하게 정의하는 API 클라이언트를 구축하는 것입니다. 이는 강력한 컴파일 타임 보장을 제공하고 코드를 더 쉽게 이해하고 유지 관리할 수 있도록 합니다.
간단한 "Todo" API 클라이언트를 만든다고 가정해 보겠습니다.
1. 프로젝트 설정
먼저 새 Rust 프로젝트를 만들고 Cargo.toml
에 필요한 종속성을 추가합니다.
[package] name = "todo_api_client" version = "0.1.0" edition = "2021" [dependencies] reqwest = { version = "0.12", features = ["json"] } # reqwest에 JSON 기능 활성화 serde = { version = "1.0", features = ["derive"] } # serde에 derive 기능 활성화 serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } # async 런타임용 thiserror = "1.0" # 복원력 있는 오류 처리를 위해
json
기능이 있는reqwest
를 사용하면 JSON을 쉽게 보내고 받을 수 있습니다.derive
기능이 있는serde
는 강력한#[derive(Serialize, Deserialize)]
매크로를 활성화합니다.serde_json
은 JSON에 대한 특정serde
구현입니다.tokio
는reqwest
에 필요한 비동기 런타임을 제공합니다.thiserror
는 상용구 코드를 줄여 사용자 지정 오류 타입을 만드는 데 도움이 됩니다.
2. 데이터 구조 정의
Todo API의 JSON 구조를 미러링하는 Rust struct
를 정의합니다. 이 구조체는 serde
를 사용하여 Serialize
및 Deserialize
가 됩니다.
use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct Todo { pub id: Option<u32>, // ID가 생성 시 존재하지 않을 수 있으므로 `Option` pub title: String, pub completed: bool, pub user_id: u32, } #[derive(Debug, Serialize)] pub struct CreateTodo { pub title: String, pub completed: bool, pub user_id: u32, } #[derive(Debug, Deserialize)] pub struct ApiError { pub message: String, pub code: u16, }
Todo
: API에서 검색된 todo 항목을 나타냅니다.id
는 API가 일반적으로 생성 시 ID를 할당하므로Option<u32>
입니다.CreateTodo
: 새 todo를 만드는 데 필요한 데이터를 나타냅니다.id
필드가 없다는 점에 유의하세요.ApiError
: API에서 오류 응답을 캡처하기 위한 일반적인 구조체입니다.
3. 사용자 지정 오류 처리
클라이언트를 위한 특정 오류 타입을 정의하여 다양한 잠재적 실패를 캡슐화하는 것이 중요합니다.
use thiserror::Error; #[derive(Debug, Error)] pub enum TodoClientError { #[error("HTTP 요청 실패: {0}")] Reqwest(#[from] reqwest::Error), #[error("JSON 응답 파싱 실패: {0}")] Serde(#[from] serde_json::Error), #[error("API가 오류 반환: {message} (코드: {code})")] Api { message: String, code: u16, }, #[error("잘못된 기본 URL")] InvalidBaseUrl, }
thiserror
를 사용하여 오류 열거형에 대한Display
및From
트레이트를 자동으로 구현합니다.#[from]
은reqwest::Error
및serde_json::Error
에서의 자동 변환을 가능하게 하여 오류 전파를 단순화합니다.Api
변형은 구조화된 API 오류 응답을 위한 것이며, 컨텍스트를 포함할 수 있도록 합니다.
4. 클라이언트 구조 구축
이제 TodoClient
구조체와 그 메서드를 만들어 보겠습니다.
use reqwest::Client; use std::fmt::Display; pub struct TodoClient { base_url: String, http_client: Client, } impl TodoClient { pub fn new(base_url: &str) -> Result<Self, TodoClientError> { let parsed_url = url::Url::parse(base_url) .map_err(|_| TodoClientError::InvalidBaseUrl)?; Ok(Self { base_url: parsed_url.to_string(), http_client: Client::new(), }) } // 전체 URL을 구축하는 헬퍼 fn get_url<P: Display>(&self, path: P) -> String { format!("{}/{}", self.base_url.trim_end_matches('/'), path) } pub async fn get_all_todos(&self) -> Result<Vec<Todo>, TodoClientError> { let url = self.get_url("todos"); let response = self.http_client.get(&url).send().await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let todos: Vec<Todo> = response.json().await?; Ok(todos) } pub async fn get_todo_by_id(&self, id: u32) -> Result<Todo, TodoClientError> { let url = self.get_url(format!("todos/{}", id)); let response = self.http_client.get(&url).send().await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let todo: Todo = response.json().await?; Ok(todo) } pub async fn create_todo(&self, new_todo: &CreateTodo) -> Result<Todo, TodoClientError> { let url = self.get_url("todos"); let response = self .http_client .post(&url) .json(new_todo) // `reqwest`는 `serde_json`으로 자동 직렬화 .send() .await?; if !response.status().is_success() { let api_error: ApiError = response.json().await?; return Err(TodoClientError::Api { message: api_error.message, code: api_error.code, }); } let created_todo: Todo = response.json().await?; Ok(created_todo) } // 업데이트, 삭제 등을 위한 메서드를 더 추가할 수 있습니다. }
설명:
-
TodoClient::new
: 기본 URL을 받고reqwest::Client
를 초기화하는 생성자입니다. 기본적인 URL 유효성을 검사합니다. -
get_url
: 상대 경로에서 전체 API 엔드포인트를 구성하는 비공개 헬퍼 메서드입니다. -
get_all_todos
/get_todo_by_id
:- 전체 URL을 구성합니다.
self.http_client.get(&url).send().await?
를 사용하여 GET 요청을 보냅니다.?
연산자는reqwest::Error
를 전파합니다.- 오류 처리:
response.status().is_success()
를 확인합니다. 그렇지 않은 경우 응답 본문을ApiError
구조체로 역직렬화하려고 시도하고TodoClientError::Api
를 반환합니다. 이는 구조화된 API 오류를 처리하는 강력한 방법입니다. - 성공하면
response.json().await?
가 JSON 응답을Vec<Todo>
또는Todo
구조체로 직접 역직렬화합니다.?
연산자는serde_json::Error
를 처리합니다.
-
create_todo
:- POST 요청에
post(&url)
을 사용합니다. json(new_todo)
는Serialize
타입(CreateTodo
인 경우)을 가져와serde_json
을 사용하여 JSON으로 직렬화하고Content-Type: application/json
헤더를 설정하는 강력한reqwest
메서드입니다.- 성공적인 응답의 오류 처리 및 역직렬화는 GET 요청과 유사합니다.
- POST 요청에
5. 애플리케이션 예제
클라이언트를 사용해 봅시다!
#[tokio::main] async fn main() -> Result<(), TodoClientError> { // 테스트를 위한 일반적인 공개 더미 API. // 실제 API 기본 URL로 바꾸세요. let base_url = "https://jsonplaceholder.typicode.com"; let client = TodoClient::new(base_url)?; println!("---" Fetching all Todos ---"); match client.get_all_todos().await { Ok(todos) => { for todo in todos.iter().take(5) { // 간결성을 위해 처음 5개만 출력 println!("{:?}", todo); } } Err(e) => eprintln!("Error fetching all todos: {}", e), } println!("\n---" Fetching Todo with ID 1 ---"); match client.get_todo_by_id(1).await { Ok(todo) => println!("{:?}", todo), Err(e) => eprintln!("Error fetching todo by ID: {}", e), } println!("\n---" Creating a new Todo ---"); let new_todo = CreateTodo { title: "Learn Rust API Client".to_string(), completed: false, user_id: 1, }; match client.create_todo(&new_todo).await { Ok(created_todo) => println!("Created todo: {:?}", created_todo), Err(e) => eprintln!("Error creating todo: {}", e), } // 예상된 API 오류 처리 예제(예: 존재하지 않는 ID) println!("\n---" Fetching a non-existent Todo (ID 99999) ---"); match client.get_todo_by_id(99999).await { Ok(todo) => println!("Found non-existent todo: {:?}", todo), // 발생하면 안 됨 Err(e) => { eprintln!("Expected error fetching non-existent todo: {}", e); if let TodoClientError::Api { message, code } = e { println!("API Error Details: Message='{}', Code={}", message, code); } } } Ok(()) }
이 main
함수는 다음을 시연합니다:
TodoClient
인스턴스화.- 비동기 메서드 호출.
match
를 사용하여 성공적인Ok
결과와 다양한Err
변형 모두 처리.- 구조화된 API 오류 응답을 검사하기 위해
TodoClientError::Api
를 구체적으로 처리합니다.
이 접근 방식의 주요 이점
- 타입 안전성: 모든 API 요청 및 응답은 강력하게 타이핑됩니다. API가 변경되면 Rust 컴파일러는 컴파일 타임에
struct
정의와 실제 JSON 구조 간의 불일치를 플래그 지정하여 미묘한 런타임 버그를 방지합니다. - 강력한 오류 처리: 명시적인 오류 타입과
Result
는 네트워크 문제부터 잘못된 JSON 또는 API별 오류까지 모든 잠재적인 실패 경로가 고려되도록 보장합니다. - 가독성 및 유지 관리성: 코드는 예상되는 데이터 모양과 API와의 상호 작용을 명확하게 정의하여 다른 사람들이 이해하기 쉽고 향후 수정이 용이합니다.
- 상용구 감소:
serde
의 파생 매크로와reqwest
의.json()
헬퍼는 수동 파싱 및 직렬화 코드를 크게 줄여줍니다. - 설계상 비동기: 비차단 I/O를 위해 Rust의
async/await
를 활용하며, 응답성이 뛰어난 애플리케이션에 필수적입니다.
결론
Rust에서 복원력 있고 타입 안전한 API 클라이언트를 구축하는 것은 달성 가능하며 매우 유익합니다. serde
를 사용하여 데이터 구조를 세심하게 정의하고 강력하고 인체 공학적인 HTTP 통신을 위해 reqwest
를 활용함으로써 변경에 복원력이 있고 강력한 컴파일 타임 보장을 제공하며 개발자 생산성을 크게 향상시키는 클라이언트를 구축할 수 있습니다. 이 접근 방식은 네트워크 계층에서 애플리케이션 로직에 이르기까지 데이터 무결성이 유지된다는 것을 알면서도 외부 서비스와 자신 있게 통합할 수 있도록 합니다.