Rust 웹 애플리케이션에서 async-trait를 사용하여 비동기 서비스 계층 인터페이스 정의하기
Emily Parker
Product Engineer · Leapcell

Rust 웹 앱에서 견고한 비동기 서비스 구축
비동기 프로그래밍은 고성능의 확장 가능한 웹 애플리케이션을 구축하는 데 필수적인 패러다임이 되었습니다. Rust 생태계에서 async/await는 동시성 코드를 작성하는 방식을 혁신하여 I/O 바운드 작업을 처리하는 강력하고 편리한 방법을 제공합니다. 그러나 비동기 서비스 계층에 대한 재사용 가능하고 테스트 가능한 인터페이스를 정의하는 데 있어서는 종종 고전적인 Rust의 한계에 부딪히게 됩니다. 즉, 트레이트(trait)는 구현에 따라 반환 타입이 달라지는 async 함수를 동적 디스패치(dyn Trait 사용)를 강요하지 않고는 직접 포함할 수 없다는 것입니다. 이 문제는 명확한 아키텍처 설계와 효과적인 단위 테스트를 방해할 수 있습니다. 이 블로그 게시물은 async-trait 크레이트가 이 문제를 어떻게 우아하게 해결하여 Rust 웹 애플리케이션에서 진정한 비동기 불가지론적인 서비스 인터페이스를 정의할 수 있게 하는지 살펴보고, 이를 통해 더 모듈화되고 유지 관리 가능하며 테스트 가능한 코드베이스를 만들 수 있습니다.
async-trait의 기초 이해하기
async-trait의 실용적인 적용을 살펴보기 전에, 그 유용성을 이해하는 데 필수적인 몇 가지 핵심 개념을 명확히 하겠습니다.
- 비동기 프로그래밍 (
async/await): Rust에서async/await는 동기식처럼 보이는 비동기 코드를 작성하기 위한 구문을 제공합니다.async fn은Future를 반환하며, 이는 완료될 때까지 폴링(poll)할 수 있는 상태 머신입니다.await는 현재async블록의 실행을 일시 중지하고 기다리는Future가 완료될 때까지 기다립니다. - 트레이트 (Traits): Rust의 트레이트는 공유 동작을 정의하는 기본 메커니즘입니다. 트레이트는 타입이 구현해야 하는 메서드 집합을 지정할 수 있게 해주며, 다형성과 제네릭 프로그래밍을 가능하게 합니다.
- 트레이트 객체 (
dyn Trait): 트레이트 메서드가Self-참조 타입이나 컴파일 시간에 크기를 알 수 없는 타입(예: 구현에 따라 구체적인 타입이 달라지는Future)을 반환하는 경우, 트레이트 객체에 의존하는 경우가 많습니다.dyn Trait은 런타임에 해당 구체적인 구현으로 호출을 디스패치할 수 있게 해주지만, 동적 디스패치로 인한 오버헤드가 발생하며async컨텍스트에서 스레드 간 안전한 공유를 위해Send및Sync바운드가 필요합니다. - 트레이트 내
async fn의 문제: 핵심 문제는async fn이 개념적으로impl Future<Output = T>를 반환한다는 것입니다. 이Future의 구체적인 타입이 트레이트의 특정 구현자에 의해 결정될 때, 트레이트(정적)는 구체적인 반환 타입과 그 크기를 알 수 없어 트레이트 정의 내에서 직접 사용하는 것을 방지합니다. Rust의 타입 시스템은 트레이트 내에서 이러한 크기 불명의 타입 트릭을 직접적으로 방지하도록 설계되었습니다. async-trait크레이트:async-trait크레이트는trait정의 내의async fn선언을 사용 가능한 형태로 변환하는 절차적 매크로입니다. 이는async fn을BoxFuture(Box및Pin으로 래핑된Future)를 반환하는 일반fn으로 분해하여 반환 타입을 일관되고 크기가 지정되도록 만들어 트레이트 시스템을 만족시킵니다. 덕분에dyn Trait을 트레이트 객체로 필요할 때 지원하면서도, 트레이트 자체에서는dyn Trait을 사용하지 않고async메서드를 정의할 수 있습니다.
비동기 서비스 인터페이스 구현하기
async-trait가 깨끗하고 비동기적인 서비스 계층을 설계하는 데 어떻게 도움이 되는지 예를 들어 설명해 보겠습니다. 사용자 관리를 위해 데이터베이스와 상호 작용해야 하는 일반적인 웹 애플리케이션 시나리오를 생각해 봅시다.
async-trait 없이 (과제):
// BoxFuture를 수동으로 사용하거나 async-trait를 사용하지 않으면 컴파일되지 않습니다. // trait UserRepository { // async fn find_user_by_id(&self, id: u64) -> Result<User, UserError>; // async fn create_user(&self, user: User) -> Result<(), UserError>; // }
컴파일러는 트레이트 내의 async fn이 아직 안정화되지 않았거나 Future의 반환 타입이 알려지지 않았다고 불평할 것입니다. BoxFuture를 수동으로 사용할 수는 있지만, 장황하고 반복적입니다.
async-trait 사용 (해결책):
먼저 Cargo.toml에 async-trait를 추가합니다.
[dependencies] async-trait = "0.1" tokio = { version = "1", features = ["full"] } # 예제 런타임
이제 서비스 인터페이스를 정의할 수 있습니다.
use async_trait::async_trait; use tokio::sync::Mutex; // 예제 인메모리 스토어용 use std::collections::HashMap; use std::sync::Arc; // User 및 UserError 타입을 정의하세요 #[derive(Debug, Clone, PartialEq, Eq)] pub struct User { pub id: u64, pub name: String, pub email: String, } #[derive(Debug, thiserror::Error)] pub enum UserError { #[error("사용자를 찾을 수 없습니다")] NotFound, #[error("ID {0}를 가진 사용자가 이미 존재합니다")] AlreadyExists(u64), #[error("데이터베이스 오류: {0}")] DatabaseError(String), } #[async_trait] pub trait UserRepository: Send + Sync { async fn find_user_by_id(&self, id: u64) -> Result<User, UserError>; async fn create_user(&self, user: User) -> Result<(), UserError>; }
트레이트 정의 위의 #[async_trait] 속성을 주목하세요. 이 매크로는 트레이트 내에서 async fn을 작동하게 만드는 마법입니다. Send + Sync 바운드는 여기에 중요하며, 분해된 메서드가 반환하는 Future는 async 애플리케이션에서 흔히 요구되는 스레드 간에 안전하게 이동하고 공유될 수 있어야 합니다.
트레이트 구현 (예: 인메모리 저장소):
인메모리 해시 맵을 사용하여 구체적인 구현을 만들어 보겠습니다.
pub struct InMemoryUserRepository { store: Arc<Mutex<HashMap<u64, User>>>, } impl InMemoryUserRepository { pub fn new() -> Self { Self { store: Arc::new(Mutex::new(HashMap::new())) } } } #[async_trait] impl UserRepository for InMemoryUserRepository { async fn find_user_by_id(&self, id: u64) -> Result<User, UserError> { let store = self.store.lock().await; // 뮤텍스 잠금 store.get(&id).cloned().ok_or(UserError::NotFound) } async fn create_user(&self, user: User) -> Result<(), UserError> { let mut store = self.store.lock().await; // 뮤텍스 잠금 if store.contains_key(&user.id) { return Err(UserError::AlreadyExists(user.id)); } store.insert(user.id, user); Ok(()) } }
웹 핸들러에서 서비스 사용하기 (예: Axum):
Axum 웹 서버에서 이를 통합하는 방법과 트레이트 객체를 사용한 의존성 주입을 보여주는 예시입니다.
// axum 및 serde가 구성되었다고 가정 use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, Router, }; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct UserRequest { pub name: String, pub email: String, } impl From<UserRequest> for User { fn from(req: UserRequest) -> Self { User { id: rand::random(), // 단순함을 위해 무작위 ID 생성 name: req.name, email: req.email, } } } pub type SharedUserRepository = Arc<dyn UserRepository>; // 편의를 위한 타입 별칭 async fn get_user( Path(user_id): Path<u64>, State(repo): State<SharedUserRepository>, ) -> Result<Json<User>, StatusCode> { match repo.find_user_by_id(user_id).await { Ok(user) => Ok(Json(user)), Err(UserError::NotFound) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } async fn create_user( State(repo): State<SharedUserRepository>, Json(payload): Json<UserRequest>, ) -> Result<Json<User>, StatusCode> { let new_user: User = payload.into(); match repo.create_user(new_user.clone()).await { Ok(_) => Ok(Json(new_user)), Err(UserError::AlreadyExists(_)) => Err(StatusCode::CONFLICT), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } #[tokio::main] async fn main() { let user_repo: SharedUserRepository = Arc::new(InMemoryUserRepository::new()); let app = Router::new() .route("/users/:id", axum::routing::get(get_user)) .route("/users", axum::routing::post(create_user)) .with_state(user_repo); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); }
적용 및 이점:
- 모듈성:
UserRepository트레이트는 구체적인 저장 메커니즘과 독립적으로 사용자 관련 데이터 작업에 대한 계약을 명확하게 정의합니다.InMemoryUserRepository를PgUserRepository(Postgres),MongoUserRepository등으로 쉽게 교체할 수 있으며 웹 핸들러는 변경할 필요가 없습니다. - 테스트 용이성:
InMemoryUserRepository는UserRepository트레이트를 구현하므로, 실제 데이터베이스 연결이 필요 없이 웹 핸들러나 비즈니스 로직을 테스트하는 데 사용할 수 있습니다. 이를 통해 빠르고 격리된 단위 테스트가 가능합니다. - 클린 아키텍처: 이 패턴은 관심사를 웹 계층, 서비스 계층 (트레이트로 정의됨), 데이터 액세스 계층 (트레이트 구현) 사이에 분리하여 깔끔한 아키텍처 설계를 촉진합니다.
- 의존성 주입:
Arc<dyn UserRepository>를 사용하면 런타임에 저장소 구현을 다르게 주입할 수 있어 애플리케이션 구성 요소가 느슨하게 결합됩니다.
결론
async-trait 크레이트는 Rust 웹 개발자에게 없어서는 안 될 도구입니다. Rust의 비동기 스토리에서 중요한 격차를 해소하여 서비스 계층에 대한 진정한 비동기 불가지론적인 트레이트 인터페이스를 정의할 수 있게 합니다. 트레이트 내에서 async fn을 직접 허용함으로써 async-trait는 매우 모듈화되고, 테스트 가능하며, 유지 관리 가능한 웹 애플리케이션을 촉진하며, 견고한 아키텍처 패턴을 일관되게 지원합니다. async-trait를 사용하면 유연하고 확장 가능한 Rust 서비스를 자신 있게 구축할 수 있습니다.

