Rust Trait를 사용하여 유연하고 테스트 가능한 서비스 계층 구축하기
Grace Collins
Solutions Engineer · Leapcell

소개
현대 소프트웨어 개발에서 유지보수성과 확장성이 뛰어난 애플리케이션을 구축하는 것은 매우 중요합니다. 잘 구조화된 애플리케이션은 일반적으로 책임의 명확한 분리를 통해 이점을 얻으며, 비즈니스 로직은 "서비스 계층" 내에 캡슐화됩니다. 그러나 적절한 설계 없이는 이 서비스 계층이 특정 구현에 단단히 결합되어 테스트가 어렵고 향후 변경이 복잡해질 수 있습니다. 바로 여기서 추상화의 힘, 특히 의존성 주입(DI)과 테스트 용이성을 통해 얻을 수 있습니다. Rust 생태계에서 Trait는 서비스 계층 내에서 이러한 목표를 달성하기 위한 우아하고 관용적인 솔루션을 제공합니다. 이 문서에서는 Rust Trait를 사용하여 서비스 종속성을 추상화하여 보다 모듈화되고, 테스트 가능하며, 강력한 애플리케이션 아키텍처를 구축하는 효과적인 방법에 대해 자세히 알아봅니다.
핵심 개념 설명
구현 세부 사항으로 본격적으로 들어가기 전에, 이 논의의 핵심이 되는 몇 가지 주요 용어를 명확히 해보겠습니다.
- 서비스 계층: 이 아키텍처 계층은 애플리케이션의 비즈니스 로직을 캡슐화합니다. 프레젠테이션 계층(예: 웹 핸들러)이 상호 작용할 수 있는 API를 제공하고, 데이터 저장소와 같은 하위 수준 구성 요소와 관련된 작업을 오케스트레이션합니다.
- 의존성 주입 (DI): 구성 요소가 자체적으로 생성하는 대신 외부 소스에서 종속성을 받는 소프트웨어 디자인 패턴입니다. 이는 느슨한 결합을 촉진하여 구성 요소를 더 독립적으로 만들고 테스트하기 쉽게 만듭니다.
- Trait (Rust): 공유 동작을 정의하는 Rust의 메커니즘입니다. Trait는 특정 Trait를 "구현"하는 것으로 간주되기 위해 유형이 구현해야 하는 메서드 집합을 정의합니다. Trait는 다른 언어의 인터페이스와 유사하지만 더 강력한 기능을 제공합니다.
- 테스트 용이성: 구성 요소 또는 시스템을 테스트할 수 있는 용이를 말합니다. 높은 테스트 용이성은 일반적으로 느슨한 결합, 명확한 책임, 테스트를 위해 구성 요소를 격리할 수 있는 능력을 의미합니다.
Rust Trait를 사용하여 서비스 계층 추상화하기
핵심 아이디어는 서비스 계층의 종속성과 서비스 계층 자체의 계약을 나타내는 Trait를 정의하는 것입니다. 구체적인 유형을 직접 인스턴스화하는 대신, 서비스 계층은 Trait 객체 또는 이러한 Trait로 제한된 제네릭 유형을 작동합니다. 이를 통해 런타임 또는 테스트 중에 다른 구현을 "주입"할 수 있습니다.
예시 시나리오: 사용자 관리 서비스
간단한 사용자 관리 애플리케이션을 고려해 보겠습니다. 데이터베이스와 상호 작용하기 위한 UserRepository와 사용자 관련 비즈니스 로직을 처리하기 위한 UserService가 필요합니다.
단계 1: 종속성에 대한 Trait 정의
먼저 UserRepository Trait를 정의합니다. 이 Trait는 find_by_id 및 save와 같이 서비스가 사용자 저장소에서 필요로 하는 작업을 지정합니다.
// src/traits.rs 또는 유사한 곳에 use async_trait::async_trait; use crate::models::{User, UserId}; // User 모델과 UserId 유형이 있다고 가정 #[async_trait] pub trait UserRepository { async fn find_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>>; async fn save(&self, user: User) -> anyhow::Result<User>; // 기타 저장소 메서드... }
#[async_trait] 속성에 주목하십시오. Rust Trait는 Trait 객체에서 비동기 메서드를 직접 지원하지 않으므로, async_trait는 Trait에서 비동기 함수를 정의하고 사용하는 것을 가능하게 하는 널리 사용되는 크레이트입니다.
단계 2: 구체적인 종속성 구현
이제 UserRepository Trait의 구체적인 구현을 만들 수 있습니다. 예를 들어, 테스트를 위한 PostgresUserRepository와 MockUserRepository입니다.
// src/infra/mod.rs 또는 유사한 곳에 use sqlx::{PgPool, Postgres}; // 예시: 데이터베이스 상호 작용을 위해 sqlx 사용 use crate::models::{User, UserId}; use crate::traits::UserRepository; use anyhow::anyhow; pub struct PostgresUserRepository { pool: PgPool, } impl PostgresUserRepository { pub fn new(pool: PgPool) -> Self { PostgresUserRepository { pool } } } #[async_trait] impl UserRepository for PostgresUserRepository { async fn find_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>> { // 자리 표시자: 실제 데이터베이스 쿼리가 여기에 들어갑니다. println!("PostgreSQL에서 사용자 {} 조회 중", id.0); Ok(Some(User { id: id.clone(), name: "John Doe".to_string(), email: format!("{}@example.com", id.0) })) // sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id.0) // .fetch_optional(&self.pool) // .await // .map_err(|e| anyhow!("사용자 조회 실패: {}", e)) } async fn save(&self, user: User) -> anyhow::Result<User> { // 자리 표시자: 실제 데이터베이스 삽입/업데이트 println!("PostgreSQL에 사용자 {} 저장 중", user.id.0); Ok(user) // sqlx::query_as!(User, "INSERT INTO users (id, name, email) VALUES ($1, $2, $3) ON CONFLICT(id) DO UPDATE SET name=$2, email=$3 RETURNING id, name, email", // user.id.0, user.name, user.email) // .fetch_one(&self.pool) // .await // .map_err(|e| anyhow!("사용자 저장 실패: {}", e)) } } // src/tests/mocks.rs 또는 유사한 곳에 use std::collections::HashMap; use parking_lot::RwLock; // Mock에서 스레드 안전한 변경 가능한 액세스를 위해 use crate::models::{User, UserId}; use crate::traits::UserRepository; pub struct MockUserRepository { users: RwLock<HashMap<UserId, User>>, } impl MockUserRepository { pub fn new() -> Self { MockUserRepository { users: RwLock::new(HashMap::new()), } } pub fn insert_user(&self, user: User) { self.users.write().insert(user.id.clone(), user); } } #[async_trait] impl UserRepository for MockUserRepository { async fn find_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>> { println!("Mock에서 사용자 {} 조회 중", id.0); Ok(self.users.read().get(id).cloned()) } async fn save(&self, user: User) -> anyhow::Result<User> { println!("Mock에 사용자 {} 저장 중", user.id.0); self.users.write().insert(user.id.clone(), user.clone()); Ok(user) } }
참고: 간결함을 위해 User와 UserId 모델 정의는 생략되었지만 존재하는 것으로 가정합니다.
단계 3: 서비스 Trait 정의 (선택 사항이지만 권장됨)
더 복잡한 서비스 또는 전체 서비스 계층의 다른 구현을 허용하려면 UserService에 대한 Trait도 정의할 수 있습니다. 이는 동일한 서비스의 다른 전략적 버전을 가지고 있을 때 특히 유용합니다.
// src/traits.rs 또는 유사한 곳에 #[async_trait] pub trait UserService { async fn get_user_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>>; async fn create_user(&self, name: String, email: String) -> anyhow::Result<User>; // 기타 서비스 메서드... }
단계 4: Trait 객체 또는 제네릭을 사용하여 서비스 구현
이제 UserService를 구현합니다. PostgresUserRepository에 의존하는 대신 UserRepository를 구현하는 모든 유형에 종속됩니다.
옵션 A: Trait 객체 (Box<dyn Trait>)
동일한 Trait를 구현하는 다른 구체적인 구현을 저장해야 할 때 종종 가장 간단한 접근 방식입니다.
// src/services/mod.rs 또는 유사한 곳에 use std::sync::Arc; // 공유 소유권을 위해 Arc 사용 use uuid::Uuid; use crate::models::{User, UserId}; use crate::traits::{UserRepository, UserService}; pub struct UserServiceImpl { user_repo: Arc<dyn UserRepository>, // Trait 객체로 의존성 주입됨 } impl UserServiceImpl { // UserRepository를 구현하는 유형을 받아 Arc<dyn UserRepository>로 변환하는 생성자 pub fn new(user_repo: Arc<dyn UserRepository>) -> Self { UserServiceImpl { user_repo } } } #[async_trait] impl UserService for UserServiceImpl { async fn get_user_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>> { self.user_repo.find_by_id(id).await } async fn create_user(&self, name: String, email: String) -> anyhow::Result<User> { let new_user = User { id: UserId(Uuid::new_v4().to_string()), name, email, }; self.user_repo.save(new_user).await } }
옵션 B: 제네릭
제네릭은 컴파일 시간 유형 검사를 제공하며 때로는 모노모피제이션 덕분에 더 나은 성능을 제공할 수 있습니다. 종속성의 특정 구체적인 유형이 컴파일 시간에 알려져 있고 런타임에 동일한 위치에서 구현을 동적으로 교체할 필요가 없을 때 적합합니다.
// src/services/mod.rs 또는 유사한 곳에 // ... 가져오기 ... pub struct UserServiceImplGeneric<R: UserRepository> { // R은 UserRepository Trait로 제한된 제네릭 유형입니다. user_repo: R, } impl<R: UserRepository> UserServiceImplGeneric<R> { pub fn new(user_repo: R) -> Self { UserServiceImplGeneric { user_repo } } } #[async_trait] impl<R: UserRepository + Send + Sync> UserService for UserServiceImplGeneric<R> { // R도 비동기 Trait의 경우 Send + Sync여야 합니다. async fn get_user_by_id(&self, id: &UserId) -> anyhow::Result<Option<User>> { self.user_repo.find_by_id(id).await } async fn create_user(&self, name: String, email: String) -> anyhow::Result<User> { let new_user = User { id: UserId(Uuid::new_v4().to_string()), name, email, }; self.user_repo.save(new_user).await } }
서비스 계층의 경우, Box<dyn Trait> (또는 공유 소유권을 위해 Arc<dyn Trait>)는 의존성 주입 시 유연성 때문에 종종 선호됩니다. 이를 통해 컬렉션에서 다른 구체적인 유형을 혼합하거나 동적으로 교체할 수 있습니다. 제네릭은 더 기본적인 빌딩 블록이나 모노모피제이션이 허용되는 성능이 절대적으로 중요한 상황에 탁월합니다.
단계 5: 연결 (의존성 주입)
이제 애플리케이션 진입점(예: main.rs 또는 DI 컨테이너)에서 구체적인 종속성을 인스턴스화하고 서비스에 주입할 수 있습니다.
// src/main.rs 또는 웹 프레임워크의 애플리케이션 설정 use std::sync::Arc; use crate::infra::PostgresUserRepository; use crate::services::UserServiceImpl; // Trait 객체 버전을 사용한다고 가정 use crate::traits::{UserRepository, UserService}; use crate::models::UserId; #[tokio::main] async fn main() -> anyhow::Result<()> { // 1. 구체적인 종속성 초기화 // let pool = PgPool::connect("postgresql://user:password@localhost/db").await?; // let concrete_repo = PostgresUserRepository::new(pool); // 이 예시에서는 더미 저장소를 만듭니다. let concrete_repo = PostgresUserRepository::new( sqlx::PgPool::connect("postgres://user:password@localhost/db").await .unwrap_or_else(|_| panic!("예제를 위해 DB에 연결 실패")) // 더미 풀 ); // 2. 구체적인 구현에서 Arc<dyn Trait> 생성 let user_repo: Arc<dyn UserRepository> = Arc::new(concrete_repo); // 3. 서비스에 종속성 주입 let user_service = UserServiceImpl::new(user_repo.clone()); // 4. 서비스 사용 println!("--- 애플리케이션 로직 ---"); let user_id = UserId("123".to_string()); user_service.create_user("Alice".to_string(), "alice@example.com".to_string()).await?; if let Some(user) = user_service.get_user_by_id(&user_id).await? { println!("사용자 찾음: {} ({})", user.name, user.email); } else { println!("사용자 {}를 찾을 수 없습니다.", user_id.0); } Ok(()) }
단계 6: 테스트 용이성 향상
이 설정은 테스트 용이성을 크게 향상시킵니다. 이제 MockUserRepository를 주입하여 UserService를 격리하여 쉽게 테스트할 수 있습니다.
// src/services/mod.rs 또는 src/services/tests.rs #[cfg(test)] mod tests { use super::*; use crate::tests::mocks::MockUserRepository; // 우리의 Mock 구현 use crate::models::{User, UserId}; #[tokio::test] async fn test_create_user() -> anyhow::Result<()> { let mock_repo = Arc::new(MockUserRepository::new()); let user_service = UserServiceImpl::new(mock_repo.clone()); let new_user = user_service.create_user("Bob".to_string(), "bob@example.com".to_string()).await?; // Mock 저장소를 확인하여 사용자가 "저장"되었는지 확인 let fetched_user = mock_repo.find_by_id(&new_user.id).await?; assert!(fetched_user.is_some()); assert_eq!(fetched_user.unwrap().name, "Bob"); Ok(()) } #[tokio::test] async fn test_get_user_by_id_found() -> anyhow::Result<()> { let mock_repo = Arc::new(MockUserRepository::new()); let user_id = UserId("456".to_string()); mock_repo.insert_user(User { id: user_id.clone(), name: "Charlie".to_string(), email: "charlie@example.com".to_string(), }); let user_service = UserServiceImpl::new(mock_repo); let user = user_service.get_user_by_id(&user_id).await?; assert!(user.is_some()); assert_eq!(user.unwrap().name, "Charlie"); Ok(()) } #[tokio::test] async fn test_get_user_by_id_not_found() -> anyhow::Result<()> { let mock_repo = Arc::new(MockUserRepository::new()); let user_service = UserServiceImpl::new(mock_repo); let user_id = UserId("789".to_string()); let user = user_service.get_user_by_id(&user_id).await?; assert!(user.is_none()); Ok(()) } }
결론
Rust Trait를 통해 서비스 종속성과 서비스 계층 자체의 계약을 세심하게 정의함으로써, 유연하고 강력한 애플리케이션을 구축하는 강력한 패턴을 활용할 수 있습니다. 이 접근 방식은 테스트나 다른 런타임 환경을 위해 구체적인 구현을 서비스의 핵심 논리를 수정하지 않고도 교체할 수 있는 명확한 의존성 주입을 가능하게 합니다. 결과적으로 테스트 용이성이 높은 코드베이스, 구성 요소 간의 결합 감소, 더 유지보수 가능한 애플리케이션 아키텍처가 만들어집니다. 서비스 계층 추상화를 위해 Rust Trait를 채택하는 것은 시간이 지나고 변화에도 견딜 수 있는 잘 만들어진 Rust 애플리케이션을 만드는 데 초석이 됩니다.

