Rust 함수형 매크로로 API 호출 간소화하기
Emily Parker
Product Engineer · Leapcell

소개
현대 소프트웨어 개발 세계에서 애플리케이션은 종종 API를 통해 외부 서비스와 상호 작용합니다. 필수적이지만 반복적인 API 호출 패턴은 상당한 상용구 코드로 이어지는 경우가 많으며, 이는 유지 관리 부담이 되고 핵심 비즈니스 논리를 모호하게 만들 수 있습니다. 수십 개의 다른 엔드포인트에 대해 URL을 반복적으로 구성하고, 요청 본문을 처리하고, 응답을 역직렬화하고, 잠재적 오류를 관리하는 것을 상상해 보세요. 이 중복은 개발자의 인지 부하를 증가시킬 뿐만 아니라 불일치 및 버그의 기회를 제공합니다. 이 기사에서는 Rust의 절차적 매크로의 강력함에 대해 자세히 알아보고, 특히 함수형 매크로를 만들어 API 상호 작용을 극적으로 단순화하여 장황한 API 호출을 간결하고 표현력 있는 코드로 변환하는 방법을 중점적으로 다룰 것입니다. 이 기술을 이해하고 적용함으로써 Rust 개발자는 복잡하고 상호 연결된 시스템을 다룰 때 더 깨끗하고 유지 관리하기 쉬운 코드베이스를 작성할 수 있습니다.
함수형 매크로 뒤의 마법 해독
구현에 대해 자세히 알아보기 전에 솔루션의 기본이 되는 핵심 개념에 대한 공통된 이해를 확립해 봅시다.
- 절차적 매크로: 선언적 매크로(
macro_rules!
)와 달리 절차적 매크로는 Rust의 추상 구문 트리(AST)를 작동합니다. 이를 적용하는 코드의 원시 토큰 스트림에 액세스할 수 있어 동적으로 Rust 코드를 구문 분석, 분석 및 생성할 수 있습니다. 이는 특성 파생(#[derive(Debug)]
), 속성 매크로(#[test]
) 및 오늘날의 초점인 함수형 매크로와 같은 작업에 매우 강력합니다. - 함수형 매크로 (함수형 매크로): 이것은 일반 함수처럼 괄호(예:
my_macro!(...)
)를 사용하여 호출되는 절차적 매크로의 한 유형입니다. 임의의 토큰 스트림을 입력으로 받아 새 토큰 스트림을 출력으로 생성하여 컴파일 시 Rust 코드 한 조각을 다른 조각으로 효과적으로 변환합니다. - TokenStream: 절차적 매크로가 Rust 코드를 나타내는 데 사용하는 기본 데이터 구조입니다. 이는
TokenTree
의 이터레이터이며, 각TokenTree
는Group
(괄호, 중괄호, 대괄호),Punct
(,
또는;
와 같은 구두점),Ident
(변수 이름과 같은 식별자) 또는Literal
(문자열, 숫자 등)일 수 있습니다. syn
Crate: 이 필수 불가결한 크레이트는 Rust 구문의 파서입니다. 절차적 매크로를 사용하여TokenStream
을 다양한 Rust 구성 요소(예: 함수, 구조체, 표현식, 유형)를 나타내는 구조화된 데이터 유형으로 구문 분석할 수 있으므로 입력 코드를 분석하고 조작하기가 훨씬 쉬워집니다.quote
Crate:syn
을 보완하는quote
크레이트는 Rust 구문을 기반으로 새TokenStream
을 생성하는 편리한 방법을 제공합니다. 매크로 내에서 Rust 코드를 거의 그대로 작성할 수 있으며 변수를 직접 대체할 수 있습니다.
API 호출을 위한 함수형 매크로의 원리는 공통 스캐폴딩(HTTP 클라이언트 초기화, 요청 구성(메서드, URL, 헤더, 본문), 응답 역직렬화 및 오류 처리)을 단일 선언적 매크로 호출로 추상화하는 것입니다. 매크로에는 HTTP 메서드, 엔드포인트 경로, 요청 본문(있는 경우) 및 예상 응답 유형과 같은 필수 세부 정보만 제공됩니다. 그러면 매크로는 컴파일 시 전체의 장황한 Rust 코드로 확장되어 API 호출을 수행하는 데 필요한 모든 것이 제공됩니다.
API 호출 매크로 구현
JSON을 반환하는 REST API와 자주 상호 작용한다고 상상해 봅시다. 매크로를 사용하여 다음과 같이 작성할 수 있기를 바랍니다.
let user: User = api_call!( GET, // HTTP method "/users/123", // Endpoint path None, // No request body User // Expected response type )?; let new_post: Post = api_call!( POST, // HTTP method "/posts", // Endpoint path Some(json!({"title": "My Post", "body": "...", "userId": 1})), // Request body Post // Expected response type )?;
이를 달성하기 위해 절차적 매크로 크레이트를 만들 것입니다.
먼저 매크로 크레이트(api_macros
등)에 대한 Cargo.toml
을 설정합니다.
[package] name = "api_macros" version = "0.1.0" edition = "2021" [lib] proc-macro = true [dependencies] syn = { version = "2.0", features = ["full"] } quote = "1.0" proc-macro2 = "1.0" # 종종 타사 종속성, 교육적 명확성을 위해 명시적으로 포함하는 것이 좋습니다.
다음으로 api_macros
크레이트의 src/lib.rs
에서 핵심 매크로 논리를 작성해 봅시다.
use proc_macro::TokenStream; use quote::quote; use syn::{ parse::{Parse, ParseStream}, parse_macro_input, Ident, Expr, LitStr, Type, Token, }; /// 우리의 `api_call!` 매크로에 대한 구문 분석된 입력을 나타냅니다. struct ApiCallInput { method: Ident, path: LitStr, body: Option<Expr>, response_type: Type, } impl Parse for ApiCallInput { fn parse(input: ParseStream) -> syn::Result<Self> { let method: Ident = input.parse()?; // 예: GET, POST input.parse::<Token![,]>()?; let path: LitStr = input.parse()?; // 예: "/users/123" input.parse::<Token![,]>()?; let body_expression: Expr = input.parse()?; // `None` 또는 `Some(...)` 둘 다 input.parse::<Token![,]>()?; let response_type: Type = input.parse()?; // 예: User, Post // 본문 표현식이 실제로 `None` 또는 `Some(...)`인지 확인합니다. // 여기서 더 강력한 구문 분석을 추가할 수 있지만 단순화를 위해 `None`을 본문 없음으로 처리합니다. let body = if let Expr::Path(expr_path) = &body_expression { if let Some(segment) = expr_path.path.segments.last() { if segment.ident == "None" { None } else { Some(body_expression) } } else { Some(body_expression) // `None`이 아니면 본문으로 취급 } } else { Some(body_expression) // 간단한 `None` 경로가 아닌 경우 본문으로 취급 }; Ok(ApiCallInput { method, path, body, response_type, }) } } /// API 호출을 단순화하는 함수형 매크로. /// /// 예 사용법: /// ```ignore /// let user: User = api_call!(GET, "/users/123", None, User)?; // Rust API 호출 /// let new_post: Post = api_call!( /// POST, /// "/posts", /// Some(json!({"title": "My Post", "body": "...", "userId": 1})), /// Post /// )?; /// ``` #[proc_macro] pub fn api_call(input: TokenStream) -> TokenStream { let ApiCallInput { method, path, body, response_type } = parse_macro_input!(input as ApiCallInput); // 요청 본문 부분을 준비합니다. let body_sending_code = if let Some(body_expr) = body { quote! { .json(  #body_expr) } } else { quote! {} }; let expanded = quote! { { let client = reqwest::Client::new(); let url = format!("https://api.example.com{}",   #path); // 기본 URL 구성 let request_builder = client.#method(  url); let response = request_builder #body_sending_code .send() .await? .json::<#response_type>() .await?; response } }; expanded.into() }
이제 소비자 크레이트(예: my_app
)에서 이 매크로를 사용하는 방법을 보여드리겠습니다.
먼저 my_app
의 Cargo.toml
에 api_macros
를 포함합니다.
[package] name = "my_app" version = "0.1.0" edition = "2021" [dependencies] api_macros = { path = "../api_macros" } # 필요에 따라 경로 조정 reqwest = { version = "0.11", features = ["json"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1.0" # `serde_json::json!`와 함께 사용하기 위한 `json!` 매크로
그런 다음 my_app
의 src/main.rs
에서:
use api_macros::api_call; use serde::{Deserialize, Serialize}; // 우리의 API 모델 시뮬레이션 #[derive(Debug, Deserialize, Serialize)] struct User { id: u32, name: String, email: String, } #[derive(Debug, Deserialize, Serialize)] struct Post { id: u32, title: String, body: String, #[serde(rename = "userId")] user_id: u32, } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // 예제 1: GET 요청 println!("사용자 123 가져오는 중..."); let user: User = api_call!(GET, "/users/123", None, User)?; println!("가져온 사용자: {:?}", user); // 예제 2: 본문이 있는 POST 요청 println!("\n새 게시물 생성 중..."); let new_post: Post = api_call!( POST, "/posts", Some(serde_json::json!({ "title": "My Rust Macro Post", "body": "This post was created using a functional macro!", "userId": 1, })), Post )?; println!("생성된 게시물: {:?}", new_post); // 예제 3: 또 다른 GET 요청 println!("\n게시물 1 가져오는 중..."); let fetched_post: Post = api_call!(GET, "/posts/1", None, Post)?; println!("가져온 게시물: {:?}", fetched_post); Ok(()) }
코드 설명:
-
ApiCallInput
구조체 및Parse
구현:- 이 구조체는
api_call!
매크로가 예상하는 인수 구조를 정의합니다. impl Parse for ApiCallInput
블록은syn
에게 원시TokenStream
을 구조화된ApiCallInput
으로 구문 분석하는 방법을 알려줍니다. HTTPIdent
(예:GET
), 문자열 리터럴LitStr
(경로),Expr
(본문,None
또는Some(...)
일 수 있음),Type
(응답 유형)을 순차적으로 구문 분석하고 그 사이에 쉼표를 소비합니다.
- 이 구조체는
-
#[proc_macro] pub fn api_call(input: TokenStream) -> TokenStream
:- 함수형 매크로의 진입점입니다.
parse_macro_input!(input as ApiCallInput)
은syn
을 사용하여 매크로 호출에서 원시TokenStream
을ApiCallInput
구조체로 변환하고 구문 분석 오류를 처리합니다.
-
quote!
블록:- 매크로의 핵심입니다. 실제 Rust 코드를 나타내는 새
TokenStream
을 구성합니다. let client = reqwest::Client::new();
:reqwest
클라이언트를 초기화합니다.let url = format!("https://api.example.com{}", #path);
: 기본 URL과 제공된 경로를 연결합니다. 이것은 매크로의 구성 가능한 부분입니다.client.#method(&url)
: HTTP 메서드(예:client.get(&url)
또는client.post(&url)
)를 동적으로 호출합니다.#body_sending_code
: 이 조건부quote!
블록은body
가 매크로 호출에서 제공된 경우에만.json(&#body_expr)
를 삽입합니다. 그렇지 않으면 아무것도 삽입하지 않습니다..send().await?.json::<#response_type>().await?
: 요청을 보내고, 응답을 기다리고, 지정된#response_type
으로 역직렬화하는 표준reqwest
패턴입니다.response
: 생성된 코드는 역직렬화된 응답을 반환합니다.
- 매크로의 핵심입니다. 실제 Rust 코드를 나타내는 새
적용 시나리오:
- REST API 클라이언트: 이 예제는 엔드포인트 경로, 메서드 및 요청/응답 구조가 반복적일 수 있는 내부 또는 외부 REST API 클라이언트를 구축하는 데 직접 적용할 수 있습니다.
- 마이크로서비스 통신: 마이크로서비스 아키텍처에서 이와 같은 매크로는 서비스 간 통신 패턴을 표준화하여 서비스 호출을 일관되게 만들고 오류를 줄일 수 있습니다.
- 타사 SDK 생성: API에 대한 SDK를 구축하는 경우 매크로를 사용하여 상용구 클라이언트 코드를 더 효율적으로 생성할 수 있습니다.
이 매크로는 각 API 호출에 대한 코드 줄 수를 크게 줄이고, 가독성을 향상하며, HTTP 클라이언트 구성 및 오류 처리 로직을 매크로 자체 내에 중앙 집중화합니다. 기본 HTTP 클라이언트 라이브러리 또는 일반 오류 처리에 대한 변경 사항은 모든 API 호출을 수동으로 수정할 필요 없이 한 곳(매크로)에서 수행되어 전체 코드 베이스에 전파될 수 있습니다.
결론
Rust의 강력한 절차적 매크로, 특히 함수형 매크로를 활용하여 API 상호 작용의 평범한 복잡성을 추상화하는 방법을 살펴보았습니다. syn
으로 매크로 인수를 구문 분석하고 quote
로 코드를 생성함으로써 장황한 reqwest
연결을 간결한 선언적 호출로 변환하는 api_call!
매크로를 만들었습니다. 이 접근 방식은 코드 크기를 줄일 뿐만 아니라 유지 관리성을 향상시키고, 일관성을 보장하며, 개발자가 애플리케이션 로직의 고유한 측면에 집중할 수 있도록 하여 반복적인 인프라가 아닌 다른 점에 집중할 수 있습니다. API 통신과 같은 반복적인 패턴을 다룰 때 우아하고 효율적이며 강력한 Rust 코드를 작성하기 위해 함수형 매크로를 사용하십시오.