Published on

Rust로 헥사고날 아키텍처 구현 - 의존성 역전

Authors

서버 애플리케이션을 오래 운영하다 보면 "비즈니스 규칙"이 "DB 드라이버"나 "HTTP 프레임워크"에 끌려다니는 순간이 옵니다. 초기에는 빠르게 만들 수 있지만, 시간이 지나면 기능 추가가 곧 리팩터링 폭탄이 되고 테스트는 점점 어려워집니다. 헥사고날 아키텍처(Ports and Adapters)는 이 문제를 "의존성의 방향"을 바로잡는 방식으로 해결합니다.

이 글에서는 Rust로 헥사고날 아키텍처를 구현하면서, 특히 핵심 원리인 의존성 역전(DIP, Dependency Inversion Principle) 을 어떻게 코드 구조로 강제하는지 설명합니다. 결론부터 말하면 핵심은 다음입니다.

  • 도메인과 유스케이스는 인프라를 몰라야 한다
  • 인프라는 도메인이 정의한 포트(트레이트)를 구현한다
  • 애플리케이션 조립은 가장 바깥(예: main)에서 한다

헥사고날 아키텍처에서 DIP가 중요한 이유

헥사고날 아키텍처는 크게 3가지 층으로 나눠 생각하면 이해가 쉽습니다.

  • Domain: 엔티티, 값 객체, 도메인 규칙
  • Application(Usecase): 유스케이스(서비스), 트랜잭션 경계, 포트 정의
  • Adapters(Infrastructure): DB, 메시지 큐, 외부 API, 웹 프레임워크 등

여기서 DIP는 "상위 수준 정책(유스케이스)이 하위 수준 세부사항(DB, HTTP)에 의존하지 않는다"를 의미합니다. 대신 상위에서 추상(포트) 을 정의하고, 하위에서 구현(어댑터) 합니다.

Rust에서는 이 추상을 보통 trait로 표현합니다.

  • 포트: trait UserRepository 같은 형태
  • 어댑터: struct PostgresUserRepository가 해당 traitimpl

이 구조를 지키면 DB를 Postgres에서 DynamoDB로 바꾸거나, HTTP 프레임워크를 교체해도 유스케이스 코드는 거의 건드리지 않습니다. 또한 테스트에서는 실제 DB 대신 메모리 어댑터로 쉽게 치환할 수 있습니다.


예제 시나리오: "회원 가입" 유스케이스

간단한 회원 가입을 예로 들어보겠습니다.

  • 입력: 이메일
  • 규칙: 이메일은 중복되면 안 됨
  • 처리: 사용자 저장

이 유스케이스는 "저장소"라는 의존성이 필요하지만, 그것이 Postgres인지 파일인지 외부 API인지 알 필요가 없습니다. 따라서 유스케이스는 저장소를 포트로만 바라봐야 합니다.


프로젝트 구조 제안

헥사고날을 Rust 크레이트 구조로 옮기면 아래처럼 가져갈 수 있습니다.

  • domain: 엔티티, 도메인 에러
  • application: 유스케이스와 포트(trait)
  • adapters: 포트 구현체(예: Postgres, InMemory)
  • main: 조립(wiring)

단일 크레이트로도 가능하지만, 규모가 커지면 워크스페이스로 분리하는 편이 안전합니다.


Domain: 엔티티와 도메인 에러

도메인은 인프라를 전혀 몰라야 합니다. 즉 sqlx, axum 같은 크레이트를 가져오지 않습니다.

// domain/src/user.rs

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    pub id: UserId,
    pub email: Email,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);

#[derive(Debug, thiserror::Error)]
pub enum DomainError {
    #[error("invalid email")]
    InvalidEmail,
}

impl Email {
    pub fn parse(raw: &str) -> Result<Self, DomainError> {
        // 단순화된 검증
        if raw.contains('@') {
            Ok(Self(raw.to_string()))
        } else {
            Err(DomainError::InvalidEmail)
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl UserId {
    pub fn new(raw: String) -> Self {
        Self(raw)
    }
}

도메인은 규칙만 갖고, 저장/조회 같은 행위는 직접 하지 않습니다.


Application: 포트(trait)와 유스케이스

이제 유스케이스에서 필요한 의존성을 포트로 정의합니다.

핵심 포인트는 다음입니다.

  • 포트는 trait
  • 유스케이스는 포트에만 의존
  • 구체 구현체는 모름
// application/src/ports.rs

use async_trait::async_trait;
use domain::user::{Email, User};

#[derive(Debug, thiserror::Error)]
pub enum RepoError {
    #[error("db error: {0}")]
    Db(String),
}

#[async_trait]
pub trait UserRepository: Send + Sync {
    async fn find_by_email(&self, email: &Email) -> Result<Option<User>, RepoError>;
    async fn save(&self, user: &User) -> Result<(), RepoError>;
}

유스케이스는 이 포트를 받아서 로직을 수행합니다.

// application/src/usecases/register_user.rs

use domain::user::{Email, User, UserId};
use crate::ports::{RepoError, UserRepository};

#[derive(Debug, thiserror::Error)]
pub enum RegisterError {
    #[error("email already exists")]
    EmailAlreadyExists,

    #[error("repo error: {0}")]
    Repo(#[from] RepoError),

    #[error("domain error: {0}")]
    Domain(#[from] domain::user::DomainError),
}

pub struct RegisterUser<'a, R: UserRepository> {
    repo: &'a R,
}

impl<'a, R: UserRepository> RegisterUser<'a, R> {
    pub fn new(repo: &'a R) -> Self {
        Self { repo }
    }

    pub async fn execute(&self, raw_email: &str) -> Result<User, RegisterError> {
        let email = Email::parse(raw_email)?;

        if self.repo.find_by_email(&email).await?.is_some() {
            return Err(RegisterError::EmailAlreadyExists);
        }

        // 예제 단순화를 위해 ID 생성은 문자열로 처리
        let user = User {
            id: UserId::new(format!("user-{}", email.as_str())),
            email,
        };

        self.repo.save(&user).await?;
        Ok(user)
    }
}

여기서 DIP가 성립하는 지점은 명확합니다.

  • RegisterUser는 DB를 모릅니다.
  • RegisterUser는 오직 UserRepository라는 추상에만 의존합니다.

Adapter: InMemory 구현으로 테스트 가능하게 만들기

가장 먼저 만들기 좋은 어댑터는 인메모리 저장소입니다. 이 구현체는 테스트 더블로도 그대로 쓸 수 있습니다.

// adapters/src/inmemory_user_repo.rs

use async_trait::async_trait;
use std::collections::HashMap;
use tokio::sync::RwLock;

use application::ports::{RepoError, UserRepository};
use domain::user::{Email, User};

pub struct InMemoryUserRepository {
    by_email: RwLock<HashMap<String, User>>,
}

impl InMemoryUserRepository {
    pub fn new() -> Self {
        Self {
            by_email: RwLock::new(HashMap::new()),
        }
    }
}

#[async_trait]
impl UserRepository for InMemoryUserRepository {
    async fn find_by_email(&self, email: &Email) -> Result<Option<User>, RepoError> {
        let map = self.by_email.read().await;
        Ok(map.get(email.as_str()).cloned())
    }

    async fn save(&self, user: &User) -> Result<(), RepoError> {
        let mut map = self.by_email.write().await;
        map.insert(user.email.as_str().to_string(), user.clone());
        Ok(())
    }
}

이제 유스케이스 테스트는 DB 없이 가능합니다.

// application/tests/register_user_test.rs

use adapters::inmemory_user_repo::InMemoryUserRepository;
use application::usecases::register_user::RegisterUser;

#[tokio::test]
async fn register_user_rejects_duplicate_email() {
    let repo = InMemoryUserRepository::new();
    let uc = RegisterUser::new(&repo);

    let _ = uc.execute("a@b.com").await.unwrap();
    let err = uc.execute("a@b.com").await.unwrap_err();

    let msg = format!("{}", err);
    assert!(msg.contains("email already exists"));
}

테스트가 빨라지고, 실패 원인이 인프라가 아니라 순수 로직에 집중됩니다.


Adapter: Postgres 구현은 "밖"에서만 알기

실제 DB 어댑터는 sqlx 같은 크레이트에 의존합니다. 중요한 것은 이 의존성이 유스케이스로 역류하지 않게 어댑터 크레이트 내부에 가둬두는 것입니다.

아래 코드는 개념 예시입니다.

// adapters/src/postgres_user_repo.rs

use async_trait::async_trait;
use sqlx::PgPool;

use application::ports::{RepoError, UserRepository};
use domain::user::{Email, User, UserId};

pub struct PostgresUserRepository {
    pool: PgPool,
}

impl PostgresUserRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl UserRepository for PostgresUserRepository {
    async fn find_by_email(&self, email: &Email) -> Result<Option<User>, RepoError> {
        let row = sqlx::query!(
            "SELECT id, email FROM users WHERE email = $1",
            email.as_str()
        )
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| RepoError::Db(e.to_string()))?;

        Ok(row.map(|r| User {
            id: UserId::new(r.id),
            email: Email::parse(&r.email).map_err(|e| RepoError::Db(e.to_string()))?,
        }))
    }

    async fn save(&self, user: &User) -> Result<(), RepoError> {
        sqlx::query!(
            "INSERT INTO users (id, email) VALUES ($1, $2)",
            user.id.clone().0,
            user.email.as_str()
        )
        .execute(&self.pool)
        .await
        .map_err(|e| RepoError::Db(e.to_string()))?;

        Ok(())
    }
}

주의할 점이 있습니다.

  • 도메인 타입을 DB 타입으로 매핑하는 코드는 어댑터에 둡니다.
  • RepoError는 애플리케이션 레벨 에러로 통일하고, DB 에러 문자열은 내부로 캡슐화합니다.

이렇게 해야 유스케이스가 sqlx::Error 같은 구체 타입을 알지 않아도 됩니다.


조립은 main에서: 의존성의 방향을 시각화하기

헥사고날 아키텍처는 "조립"을 가장 바깥에서 합니다. Rust에서는 보통 main.rs 또는 DI 전용 모듈에서 wiring을 수행합니다.

// src/main.rs

use adapters::postgres_user_repo::PostgresUserRepository;
use application::usecases::register_user::RegisterUser;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let pool = sqlx::PgPool::connect("postgres://...").await?;
    let repo = PostgresUserRepository::new(pool);

    let uc = RegisterUser::new(&repo);
    let user = uc.execute("hello@example.com").await?;

    println!("registered: {:?}", user);
    Ok(())
}

여기서만 "Postgres"라는 구체 구현체가 등장합니다. 유스케이스는 끝까지 추상에만 의존합니다.


Rust에서 DIP를 구현할 때 흔한 선택지: 제네릭 vs 트레이트 객체

Rust는 DI 프레임워크 없이도 DIP를 강제할 수 있지만, 표현 방식은 크게 둘입니다.

1) 제네릭 기반

  • 장점: 정적 디스패치로 성능 좋고 컴파일 타임에 타입이 확정
  • 단점: 타입 파라미터가 전파되어 시그니처가 길어질 수 있음

위 예제의 RegisterUser<'a, R: UserRepository> 가 이 방식입니다.

2) 트레이트 객체 기반

런타임 다형성이 필요하거나 핸들러에 넣기 편하게 만들고 싶다면 트레이트 객체를 씁니다.

use std::sync::Arc;
use application::ports::UserRepository;

pub struct RegisterUser {
    repo: Arc<dyn UserRepository>,
}

impl RegisterUser {
    pub fn new(repo: Arc<dyn UserRepository>) -> Self {
        Self { repo }
    }
}
  • 장점: 타입 전파가 줄고 조립이 유연
  • 단점: 동적 디스패치 비용, 객체 안전성 제약

실무에서는 HTTP 핸들러 상태 공유 때문에 Arc<dyn ...> 패턴이 자주 쓰입니다.


트랜잭션 경계는 어디에 둘까

헥사고날에서 자주 헷갈리는 부분이 트랜잭션입니다. 권장 접근은 다음 중 하나입니다.

  • 트랜잭션을 어댑터가 제공하고, 유스케이스는 "유닛 오브 워크" 포트를 통해 사용
  • 또는 유스케이스 레벨에서 트랜잭션 실행기를 포트로 두고, 인프라에서 구현

DB 데드락이나 트랜잭션 설계 이슈는 결국 운영 이슈로 이어지기 때문에, 트랜잭션 경계를 명확히 하는 것이 중요합니다. Postgres에서 데드락을 어떻게 분석하고 줄이는지에 대해서는 PostgreSQL 데드락(40P01) 원인·해결 9단계도 함께 참고하면 좋습니다.


운영 관점: 의존성 역전은 장애 대응에도 유리하다

DIP는 "코드 품질"만의 이야기가 아닙니다. 장애 대응에서도 이점이 큽니다.

  • 외부 API 장애 시: 실제 API 어댑터를 끊고, 폴백 어댑터로 교체하기 쉬움
  • DB 장애 시: 읽기 전용 모드 어댑터, 캐시 어댑터로 전환하기 쉬움
  • 배포/환경 이슈 시: 유스케이스는 그대로 두고 인프라 설정만 바꿀 여지가 커짐

예를 들어 K8s에서 애플리케이션이 CrashLoopBackOff로 반복 재시작할 때, 원인이 애플리케이션 로직이 아니라 설정/리소스/프로빙 문제인 경우가 많습니다. 이때도 핵심 로직이 인프라와 분리되어 있으면 원인 범위를 빠르게 좁힐 수 있습니다. 관련해서는 Kubernetes CrashLoopBackOff와 OOMKilled(ExitCode 137) 해결도 도움이 됩니다.

또한 비동기 호출이 많은 서비스라면 타임아웃과 재시도 정책은 어댑터 레벨의 관심사로 분리하는 편이 좋습니다. 네트워크 지연으로 DEADLINE_EXCEEDED가 터질 때 점검 포인트는 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방처럼 체크리스트 형태로 가져가면 운영 대응이 빨라집니다.


체크리스트: Rust로 헥사고날을 "진짜"로 만들기

아키텍처는 폴더 이름이 아니라 의존성 규칙입니다. 아래 체크리스트로 스스로 검증해보면 좋습니다.

  • 도메인 크레이트가 sqlx, reqwest, axum 같은 인프라 크레이트를 의존하고 있지 않은가
  • 유스케이스가 구체 타입 PgPool, Client, Router 등을 직접 받지 않는가
  • 포트(trait)가 애플리케이션 레벨에 정의되어 있는가
  • 어댑터가 포트를 구현하고, 매핑 코드를 어댑터에 가두었는가
  • 테스트가 인메모리 어댑터로 빠르게 돌아가는가
  • 조립은 main 또는 composition root 한 곳에서만 하는가

마무리

Rust로 헥사고날 아키텍처를 구현할 때 의존성 역전은 trait 하나로 끝나는 얘기가 아닙니다.

  • 도메인을 순수하게 유지하고
  • 유스케이스가 포트를 정의하며
  • 인프라가 그 포트를 구현하고
  • 가장 바깥에서만 조립한다

이 네 가지를 지키면, 코드베이스는 기능 추가와 운영 변화에 훨씬 강해집니다. 특히 Rust는 타입 시스템과 모듈 경계가 강력해서, 한 번 규칙을 잡아두면 "나도 모르게" 의존성이 역류하는 일을 크게 줄일 수 있습니다.

다음 단계로는 트랜잭션 포트, 이벤트 발행 포트, 아웃박스 패턴 같은 운영 친화적 확장을 붙여보면 헥사고날의 장점이 더 선명해집니다.