Deep EVM #22: Rust에서의 의존성 주입 — ServiceLocator, Arc, 트레잇 객체
Engineering Team
Rust에서의 DI 문제
의존성 주입은 기본적인 설계 원칙입니다: 컴포넌트가 내부에서 의존성을 생성하는 대신 외부에서 의존성을 받아야 합니다. Java나 C#에서는 Spring이나 Autofac과 같은 DI 프레임워크가 이를 자동으로 처리합니다. Rust에는 표준 DI 프레임워크가 없으며, 소유권 모델이 순진한 DI 패턴을 어색하게 만듭니다.
데이터베이스 연결, 캐시, 로거가 필요한 서비스를 생각해봅시다:
// 나쁨: 하드코딩된 의존성
struct UserService {
db: PgPool, // 구체적 타입, 모킹 불가
cache: RedisClient, // 구체적 타입
}
impl UserService {
fn new() -> Self {
// 최악의 패턴: 자체 의존성 생성
Self {
db: PgPool::connect("postgres://...").await.unwrap(),
cache: RedisClient::new("redis://...").unwrap(),
}
}
}
이것은 테스트 불가능합니다. 실제 PostgreSQL과 Redis 인스턴스 없이는 유닛 테스트를 실행할 수 없습니다. Rust 관용적 DI로 이를 수정합시다.
패턴 1: Arc를 사용한 트레잇 객체
동작을 트레잇으로 정의한 다음, 런타임 다형성을 위해 Arc<dyn Trait>를 받습니다:
use std::sync::Arc;
use async_trait::async_trait;
#[async_trait]
trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: i64) -> anyhow::Result<Option<User>>;
async fn save(&self, user: &User) -> anyhow::Result<()>;
}
#[async_trait]
trait CacheService: Send + Sync {
async fn get(&self, key: &str) -> anyhow::Result<Option<String>>;
async fn set(&self, key: &str, value: &str, ttl: u64) -> anyhow::Result<()>;
}
struct UserService {
repo: Arc<dyn UserRepository>,
cache: Arc<dyn CacheService>,
}
impl UserService {
fn new(
repo: Arc<dyn UserRepository>,
cache: Arc<dyn CacheService>,
) -> Self {
Self { repo, cache }
}
async fn get_user(&self, id: i64) -> anyhow::Result<Option<User>> {
// 먼저 캐시 확인
let cache_key = format!("user:{}", id);
if let Some(cached) = self.cache.get(&cache_key).await? {
return Ok(Some(serde_json::from_str(&cached)?));
}
// 캐시 미스 — 데이터베이스 조회
if let Some(user) = self.repo.find_by_id(id).await? {
let json = serde_json::to_string(&user)?;
self.cache.set(&cache_key, &json, 300).await?;
return Ok(Some(user));
}
Ok(None)
}
}
이제 테스트에서 구현을 교체할 수 있습니다:
struct MockUserRepository {
users: Vec<User>,
}
#[async_trait]
impl UserRepository for MockUserRepository {
async fn find_by_id(&self, id: i64) -> anyhow::Result<Option<User>> {
Ok(self.users.iter().find(|u| u.id == id).cloned())
}
async fn save(&self, _user: &User) -> anyhow::Result<()> {
Ok(())
}
}
struct MockCache;
#[async_trait]
impl CacheService for MockCache {
async fn get(&self, _key: &str) -> anyhow::Result<Option<String>> {
Ok(None) // 항상 캐시 미스
}
async fn set(&self, _key: &str, _value: &str, _ttl: u64) -> anyhow::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn test_get_user() {
let repo = Arc::new(MockUserRepository {
users: vec![User { id: 1, name: "Alice".into() }],
});
let cache = Arc::new(MockCache);
let service = UserService::new(repo, cache);
let user = service.get_user(1).await.unwrap();
assert_eq!(user.unwrap().name, "Alice");
}
패턴 2: 제네릭을 사용한 컴파일 타임 디스패치
Arc<dyn Trait>는 런타임 비용(vtable 간접 참조, 힙 할당)이 있습니다. 성능이 중요한 코드에서는 제네릭을 사용하여 비용을 제거합니다:
struct UserService<R: UserRepository, C: CacheService> {
repo: R,
cache: C,
}
impl<R: UserRepository, C: CacheService> UserService<R, C> {
fn new(repo: R, cache: C) -> Self {
Self { repo, cache }
}
}
트레이드오프:
- Arc
: 런타임 다형성, 유연하지만 약간의 오버헤드 - 제네릭: 컴파일 타임 다형성, 제로 비용이지만 더 긴 컴파일 시간과 단형화
경험 법칙: 핫 경로(초당 수백만 호출)에서는 제네릭, 나머지에서는 Arc<dyn Trait>.
패턴 3: ServiceLocator 패턴
의존성이 많은 대규모 애플리케이션에서는 모든 것을 개별 매개변수로 전달하는 것이 지루해집니다. ServiceLocator 패턴이 이를 단순화합니다:
use std::any::{Any, TypeId};
use std::collections::HashMap;
#[derive(Default)]
struct ServiceLocator {
services: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}
impl ServiceLocator {
fn register<T: Send + Sync + 'static>(&mut self, service: T) {
self.services.insert(TypeId::of::<T>(), Box::new(service));
}
fn resolve<T: Send + Sync + 'static>(&self) -> Option<&T> {
self.services
.get(&TypeId::of::<T>())
.and_then(|s| s.downcast_ref::<T>())
}
}
사용 예:
let mut locator = ServiceLocator::default();
locator.register(Arc::new(PostgresUserRepo::new(pool.clone())) as Arc<dyn UserRepository>);
locator.register(Arc::new(RedisCache::new(redis_client)) as Arc<dyn CacheService>);
// 해결
let repo = locator.resolve::<Arc<dyn UserRepository>>().unwrap();
let cache = locator.resolve::<Arc<dyn CacheService>>().unwrap();
let service = UserService::new(Arc::clone(repo), Arc::clone(cache));
컴포지션 루트 패턴
의존성 트리를 하나의 장소에서 조립합니다 — 보통 main() 함수에서:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 인프라 계층
let db_pool = PgPool::connect(&env::var("DATABASE_URL")?).await?;
let redis = RedisClient::open(env::var("REDIS_URL")?)?;
// 리포지토리 계층
let user_repo: Arc<dyn UserRepository> = Arc::new(
PostgresUserRepo::new(db_pool.clone())
);
let order_repo: Arc<dyn OrderRepository> = Arc::new(
PostgresOrderRepo::new(db_pool.clone())
);
// 서비스 계층
let cache: Arc<dyn CacheService> = Arc::new(
RedisCache::new(redis)
);
let user_service = Arc::new(
UserService::new(Arc::clone(&user_repo), Arc::clone(&cache))
);
let order_service = Arc::new(
OrderService::new(Arc::clone(&order_repo), Arc::clone(&user_service))
);
// HTTP 계층
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/orders", post(create_order))
.with_state(AppState {
user_service,
order_service,
});
axum::serve(listener, app).await?;
Ok(())
}
이 패턴은 의존성 구성을 단일 장소에 집중시키므로 전체 애플리케이션의 배선을 한눈에 이해할 수 있습니다.
Axum의 State 추출자와의 통합
Axum은 State 추출자를 통해 자연스러운 DI 메커니즘을 제공합니다:
#[derive(Clone)]
struct AppState {
user_service: Arc<UserService>,
order_service: Arc<OrderService>,
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
state.user_service
.get_user(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
테스트에서의 DI 활용
DI의 진정한 가치는 테스트에서 드러납니다. 모의 구현을 주입하면 데이터베이스 없이도 비즈니스 로직을 격리하여 테스트할 수 있습니다:
#[tokio::test]
async fn test_order_creation_validates_user() {
let user_repo = Arc::new(MockUserRepository::empty());
let cache = Arc::new(MockCache);
let user_service = Arc::new(UserService::new(user_repo, cache));
let order_repo = Arc::new(MockOrderRepository::new());
let order_service = OrderService::new(order_repo, user_service);
// 존재하지 않는 사용자로 주문 생성 시도
let result = order_service.create_order(999, items).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "User not found");
}
이 테스트는 데이터베이스나 Redis가 필요하지 않습니다. 밀리초 내에 실행되며, CI에서 수백 개를 동시에 실행할 수 있습니다.
결론
Rust에서의 의존성 주입은 프레임워크가 필요하지 않습니다. 트레잇이 인터페이스 역할을 하고, Arc<dyn Trait>가 런타임 다형성을 제공하며, 컴포지션 루트가 배선을 중앙화합니다. 성능이 중요한 경로에서는 제네릭이 제로 비용 추상화를 제공합니다. ServiceLocator 패턴은 대규모 애플리케이션에서 보일러플레이트를 줄여줍니다. 핵심 원칙은 동일합니다: 컴포넌트는 자신의 의존성을 생성하지 않으며, 이를 통해 테스트 가능하고 유연하며 유지 보수가 가능한 코드를 만듭니다.