본문으로 건너뛰기
엔지니어링Mar 28, 2026

Deep EVM #22: Rust에서의 의존성 주입 — ServiceLocator, Arc, 트레잇 객체

OS
Open Soft Team

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 패턴은 대규모 애플리케이션에서 보일러플레이트를 줄여줍니다. 핵심 원칙은 동일합니다: 컴포넌트는 자신의 의존성을 생성하지 않으며, 이를 통해 테스트 가능하고 유연하며 유지 보수가 가능한 코드를 만듭니다.