견고한 Rust 개발을 위한 외부 종속성 모킹
Grace Collins
Solutions Engineer · Leapcell

소개
소프트웨어 개발 세계에서 안정적이고 유지보수 가능한 애플리케이션을 구축하는 것은 효과적으로 테스트하는 능력에 크게 좌우됩니다. 그러나 코드가 데이터베이스, 타사 API 또는 메시지 큐와 같은 외부 서비스와 상호 작용할 때 직접 테스트하는 것은 느리고 예측 불가능하거나 비용이 발생할 수 있습니다. 이러한 외부 종속성은 비결정성을 도입하여 테스트 중인 단위를 격리하고 일관된 결과를 보장하기 어렵게 만듭니다. 여기서 모킹이 도움이 됩니다. 실제 종속성을 제어된 시뮬레이션 버전으로 대체함으로써 빠르고 결정론적이며 격리된 테스트를 달성할 수 있습니다. Rust에서는 이 문제를 해결할 수 있는 강력한 전략이 있습니다. 이 글에서는 트레이트 기반 모킹과 강력한 mockall 크레이트라는 두 가지 주요 접근 방식을 탐구하여 더 견고한 Rust 애플리케이션을 작성하는 명확한 경로를 제공할 것입니다.
핵심 개념 이해
구현 세부 사항에 들어가기 전에 Rust에서 모킹 전략을 이해하는 데 중요한 몇 가지 기본 개념을 명확히 해 봅시다.
모킹 (Mocking): 소프트웨어 테스트에서 모킹은 실제 종속성의 동작을 모방하는 시뮬레이션된 객체를 만드는 것을 포함합니다. 이러한 모크 객체는 미리 정의된 방식으로 호출에 응답하도록 설계되어 테스터가 실제 외부 시스템에 의존하지 않고 환경을 제어하고 상호 작용을 확인할 수 있습니다.
트레이트 (Traits): Rust의 다형성과 추상화의 핵심인 트레이트는 타입이 공유할 수 있는 동작 세트를 정의합니다. 타입이 반드시 준수해야 하는 계약을 제공하여 특정 트레이트를 구현하는 모든 타입에 대해 작동하는 제네릭 코드를 작성할 수 있게 합니다. 이것은 트레이트 기반 모킹의 기초입니다.
의존성 주입 (Dependency Injection): 구성 요소가 자체적으로 종속성을 생성하는 대신 외부 소스에서 종속성을 받는 디자인 패턴입니다. 이는 느슨한 결합을 촉진하고 테스트 중에 종속성의 다른 구현(모크 객체 포함)을 쉽게 대체할 수 있게 합니다.
테스트 더블 (Test Doubles): 테스트 목적으로 실제 객체를 대체하는 데 사용되는 모든 객체에 대한 일반 용어입니다. 모크는 상호 작용과 동작을 검증할 수 있게 해주는 특정 유형의 테스트 더블입니다.
트레이트 기반 모킹: Rust 네이티브 접근 방식
트레이트 기반 모킹은 Rust의 강력한 트레이트 시스템을 활용하여 종속성 역전을 달성하고 종속성 교체를 쉽게 할 수 있습니다. 핵심 아이디어는 외부 서비스에 대한 작업을 설명하는 트레이트를 정의하는 것입니다. 그런 다음 실제 구현(예: 데이터베이스 클라이언트)이 이 트레이트를 구현합니다. 테스트를 위해 동일한 트레이트를 구현하는 별도의 "모크" 구조체를 생성하지만, 해당 메서드에는 제어되고 미리 정의된 동작이 포함됩니다.
원칙 및 구현
- 트레이트 정의: 애플리케이션이 외부 서비스에 대해 수행하는 작업을 나타내는 트레이트를 생성합니다.
- 구체적인 서비스 구현: 실제 서비스 구현(예: PostgreSQL 데이터베이스와 상호 작용)이 이 트레이트를 구현합니다.
- 모크 서비스 구현: 동일한 트레이트를 구현하는 모크 구조체를 생성합니다. 해당 메서드에는 미리 정의된 값을 반환하거나 메서드 호출을 기록하는 것과 같이 테스트별 로직이 포함됩니다.
- 의존성 주입: 일반적으로 생성자 또는 함수 인수를 통해 애플리케이션 코드에 적절한 구현(실제 또는 모크)을 주입합니다.
코드 예제
사용자 데이터베이스와 상호 작용해야 하는 서비스가 있다고 가정해 보겠습니다.
// src/lib.rs // 1. 데이터베이스 작업에 대한 트레이트 정의 pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } // 2. 구체적인 구현 (예: 실제 데이터베이스 클라이언트) // 실제 애플리케이션에서는 이 코드가 DB에 연결됩니다. #[derive(Debug)] pub struct RealDbRepository; impl UserRepository for RealDbRepository { fn get_user(&self, id: u32) -> Option<String> { println!("Real DB: Fetching user with ID {}", id); // 데이터베이스 조회 시뮬레이션 match id { 1 => Some("Alice".to_string()), _ => None, } } fn save_user(&self, id: u32, name: String) -> bool { println!("Real DB: Saving user ID {} with name {}", id, name); // 데이터베이스 저장 시뮬레이션 true } } // UserRepository를 사용하는 애플리케이션 서비스 pub struct UserService<R: UserRepository> { repository: R, } impl<R: UserRepository> UserService<R> { pub fn new(repository: R) -> Self { UserService { repository } } pub fn fetch_and_display_user(&self, user_id: u32) -> String { match self.repository.get_user(user_id) { Some(name) => format!("User found: {}", name), None => format!("User with ID {} not found", user_id), } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; // 동시 테스트 실행을 위해 // 3. 테스트를 위한 모크 구현 pub struct MockUserRepository { // Mutex를 사용하여 테스트 간에 가변 액세스를 허용 // 그리고 호출을 기록하여 검증합니다. pub users: Mutex<HashMap<u32, String>>, pub get_user_calls: Mutex<Vec<u32>>, pub save_user_calls: Mutex<Vec<(u32, String)>>, } impl MockUserRepository { pub fn new(initial_users: HashMap<u32, String>) -> Self { MockUserRepository { users: Mutex::new(initial_users), get_user_calls: Mutex::new(Vec::new()), save_user_calls: Mutex::new(Vec::new()), } } } impl UserRepository for MockUserRepository { fn get_user(&self, id: u32) -> Option<String> { self.get_user_calls.lock().unwrap().push(id); self.users.lock().unwrap().get(&id).cloned() } fn save_user(&self, id: u32, name: String) -> bool { self.save_user_calls.lock().unwrap().push((id, name.clone())); self.users.lock().unwrap().insert(id, name); true } } #[test] fn test_fetch_existing_user() { let mut initial_users = HashMap::new(); initial_users.insert(1, "Alice".to_string()); let mock_repo = MockUserRepository::new(initial_users); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 1); } #[test] fn test_fetch_non_existing_user() { let mock_repo = MockUserRepository::new(HashMap::new()); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 99); } }
애플리케이션 시나리오
트레이트 기반 모킹은 다음과 같은 시나리오에 이상적입니다.
- 강력한 타입 시스템과 Rust의 보장을 활용하고자 할 때.
- 모크의 내부 상태 및 동작에 대한 완전한 제어가 필요할 때.
- 모킹 요구 사항이 비교적 간단하고 각 모크에 대한 상용구 코드를 작성하는 것을 마다하지 않을 때.
- 외부 모킹 프레임워크 없이 "제로 비용 추상화" 접근 방식을 선호할 때.
Mockall: 강력한 모킹 프레임워크
트레이트 기반 모킹은 효과적이지만, 복잡한 인터페이스의 경우 또는 메서드 호출에 대한 동적 예견을 정의해야 할 때 장황해질 수 있습니다. mockall은 컴파일 시 모크 객체 생성을 자동화하여 트레이트의 모크 객체 생성을 단순화하는 인기 있는 Rust 크레이트입니다. 메서드 호출에 대한 예견을 지정하고, 미리 정의된 값을 반환하며, 호출을 기록할 수 있습니다.
원칙 및 구현
mockall은 절차적 매크로를 사용하여 컴파일 시 모크 구조체 및 해당 구현을 생성합니다. 트레이트에 #[automock]을 사용하여 주석을 달면 mockall이 해당 모크 구조체를 생성하는 데 필요한 작업을 수행합니다.
mockall종속성 추가:Cargo.toml에mockall을 포함합니다.- 트레이트 주석: 트레이트 정의 위에
#[automock]을 추가합니다. - 모크 객체 생성:
mockall은 자동으로MockTraitName구조체를 생성하여 트레이트를 구현합니다. - 예견 설정: 모크 객체에서
expect_*()메서드를 사용하여 특정 메서드 호출에 대해 어떻게 동작할지 정의합니다. 여기에는 반환 값, 인수 및 호출 횟수 지정이 포함됩니다.
코드 예제
mockall을 사용하여 사용자 리포지토리 예제를 다시 구현해 보겠습니다.
// Cargo.toml // [dev-dependencies] // mockall = "0.12" // src/lib.rs // UserService 또는 RealDbRepository는 변경되지 않음 #[cfg(test)] // mockall은 일반적으로 dev-dependency입니다. mod tests { use super::*; use mockall::{automock, predicate::*}; // automock 및 predicate 가져오기 // 1. #[automock]으로 트레이트에 주석 달기 #[automock] pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } #[test] fn test_fetch_existing_user_with_mockall() { // 2. mockall이 MockUserRepository를 생성합니다. let mut mock_repo = MockUserRepository::new(); // 3. 예견 설정 // get_user()가 1과 함께 호출되면 Some("Alice".to_string())을 반환해야 합니다. // 그리고 정확히 한 번 호출되어야 합니다. mock_repo.expect_get_user() .with(eq(1)) // 인수를 일치시키기 위해 predicate 사용 .times(1) .returning(|_| Some("Alice".to_string())); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); // 이 검증은 UserService의 출력에 대한 것입니다. // mock_repo는 떨어질 때 또는 .checkpoint()이 호출될 때 예견을 검증합니다. } #[test] fn test_fetch_non_existing_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); // get_user()가 임의의 u32와 함께 호출되면 None을 반환해야 합니다. // 정확히 한 번 호출되어야 합니다. mock_repo.expect_get_user() .with(always()) // 모든 입력을 일치시킴 .times(1) .returning(|_| None); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); } #[test] fn test_save_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); mock_repo.expect_save_user() .with(eq(101), eq("Bob".to_string())) .times(1) .returning(|_, _| true); let user_service = UserService::new(mock_repo); // 실제 시나리오에서는 UserService가 일부 로직에 따라 save_user를 호출합니다. // 이 간단한 테스트에서는 모크의 능력을 보여주기 위해 직접 호출합니다. // 참고: 이 예제의 UserService는 공개된 메서드에서 `save_user`를 직접 호출하지 않습니다. // 일반적으로 그것을 *하는* 메서드를 테스트합니다. // 이 테스트를 위해 모크가 응답할 수 있는지 확인한다고 가정해 보겠습니다. let saved = user_service.repository.save_user(101, "Bob".to_string()); assert!(saved); } }
애플리케이션 시나리오
mockall은 다음과 같은 상황에서 탁월합니다.
- 복잡한 인터페이스: 여러 메서드를 가진 트레이트가 있고 수동으로 모크를 구현하면 번거로울 때.
- 동적 예견: 인수에 따라 동일한 메서드에 대해 다른 동작을 정의하거나 호출 순서/횟수를 확인해야 할 때.
- 리팩터링: 수동으로 모크 구현을 업데이트할 필요가 없으므로 리팩터링이 더 쉬워집니다.
- 상용구 코드 감소: 모킹에 필요한 상용구 코드의 양을 크게 줄입니다.
- 동작 검증: 특정 메서드가 특정 인수로 호출되었는지, 그리고 몇 번 호출되었는지 확인하고 싶을 때.
결론
트레이트 기반 모킹과 mockall 모두 Rust에서 데이터베이스 및 외부 서비스를 모킹하기 위한 강력한 솔루션을 제공하며, 각각 고유한 강점을 가지고 있습니다. 트레이트 기반 모킹은 Rust의 관용적인 경량 접근 방식을 제공하여 수동 구현의 비용으로 세분화된 제어를 제공합니다. 반면 mockall은 모킹 프로세스의 많은 부분을 자동화하여 더 복잡하고 동적인 모킹 시나리오를 위한 강력하고 기능이 풍부한 프레임워크를 제공하며 상용구 코드의 양을 크게 줄입니다. 둘 사이의 선택은 프로젝트의 복잡성, 팀 선호도 및 테스트의 특정 요구 사항에 따라 달라집니다. 궁극적으로 어떤 접근 방식을 선택하든 효과적인 모킹은 외부 종속성으로부터 코드를 격리하여 테스트 용이성, 유지보수성 및 안정성이 뛰어난 Rust 애플리케이션을 작성할 수 있도록 합니다.

