견고한 Rust 웹 프로젝트를 위한 모듈식 설계
Min-jun Kim
Dev Intern · Leapcell

소개
대규모 웹 애플리케이션 구축은 독특한 과제를 제시합니다. 프로젝트가 복잡해짐에 따라 코드베이스는 빠르게 관리 불가능해져 개발 속도 감소, 버그 수 증가, 실망스러운 개발자 경험으로 이어질 수 있습니다. 특히 Rust와 같이 성능이 중요한 언어에서는 신중한 아키텍처 결정이 컴파일 시간과 런타임 성능 모두에 지대한 영향을 미칠 수 있습니다. Actix Web 및 Axum과 같은 Rust 웹 프레임워크의 경우 잘 짜여진 모듈식 설계를 채택하는 것은 단순히 모범 사례가 아니라 유지보수성, 확장성 및 협업 개발을 촉진하기 위한 필수 요소입니다. 이 글에서는 대규모 Actix Web 및 Axum 프로젝트를 효과적으로 구성하는 방법에 대해 자세히 알아보고, 애플리케이션이 진화함에 따라 강력하고 적응력 있게 유지되도록 모듈성의 원칙과 실제 구현을 안내합니다.
Rust 웹 프로젝트에서의 모듈성 이해
구체적인 예시로 뛰어들기 전에 Rust 웹 개발 맥락에서 모듈성을 둘러싼 몇 가지 핵심 개념을 명확히 해보겠습니다.
모듈성(Modularity): 핵심적으로 모듈성은 시스템을 모듈이라는 작고 독립적이며 상호 교환 가능한 구성 요소로 분해하는 관행입니다. 각 모듈은 잘 정의된 인터페이스를 노출하면서 내부 구현 세부 정보를 숨기면서 특정 기능 조각을 캡슐화해야 합니다.
크레이트(Crates): Rust에서 크레이트는 컴파일의 기본 단위이며 버전 관리 및 배포의 단위이기도 합니다. 프로젝트는 단일 바이너리 크레이트 또는 라이브러리 크레이트로 구성되거나 여러 상호 의존적인 크레이트로 구성된 "워크스페이스"가 될 수 있습니다.
모듈(파일 시스템): 크레이트 내에서 코드는 mod
키워드와 파일 시스템 계층 구조를 사용하여 모듈로 구성됩니다. 이러한 모듈은 코드를 논리적으로 구성하고 가시성을 제어하는 데 도움이 됩니다.
도메인 주도 설계(DDD): 소프트웨어의 "도메인" 또는 주제 영역을 이해하고 모델링하는 데 중점을 둔 소프트웨어 개발 접근 방식입니다. 주요 개념은 다음과 같습니다.
- 도메인: 사용자가 프로그램에 적용하는 주제 영역.
- 경계 컨텍스트(Bounded Context): 특정 도메인 모델이 정의되고 적용되는 논리적 경계입니다. 다양한 대규모 시스템 부분을 격리하여 복잡성을 관리하는 데 도움이 됩니다.
- 엔티티(Entities): 속성뿐만 아니라 ID로 정의되는 개체(예: 고유 ID가 있는 "사용자").
- 값 객체(Value Objects): 속성으로 완전히 정의되는 개체(예: 금액과 통화를 가진 "금액" 개체).
- 집계(Aggregates): 데이터 변경에 대해 단일 단위로 처리되는 연관된 개체의 클러스터입니다. 집계에는 하나의 루트 엔티티가 있습니다.
- 리포지토리(Repositories): 집계를 가져오고 저장하기 위한 추상화입니다.
- 서비스(Services): 엔티티 또는 값 객체 내에서 자연스럽게 맞지 않고 종종 여러 집계에 걸쳐 조정되는 작업입니다.
계층형 아키텍처(Layered Architecture): 애플리케이션을 명확한 개념 계층으로 나누는 일반적인 아키텍처 패턴으로, 각 계층은 특정 책임을 갖습니다. 일반적인 웹 애플리케이션에는 다음이 포함될 수 있습니다.
- 프레젠테이션/API 계층: HTTP 요청, 인증, 데이터 직렬화/역직렬화를 처리합니다(예: Actix Web/Axum 핸들러).
- 애플리케이션/서비스 계층: 비즈니스 로직을 조정하고, 도메인 서비스를 호출하며, 리포지토리를 조작합니다.
- 도메인 계층: 핵심 비즈니스 로직, 엔티티, 값 객체 및 도메인 서비스를 포함합니다. 이 계층은 프레임워크에 구애받지 않아야 합니다.
- 인프라 계층: 데이터베이스, 파일 시스템, 외부 API, 메시지 큐와 같은 외부 문제를 처리합니다.
이러한 개념을 활용하여 매우 결합도가 낮고 유지보수 가능한 웹 서비스를 설계할 수 있습니다.
원칙 및 구현
모듈식 설계의 핵심 아이디어는 높은 응집도(모듈 내의 요소가 함께 속함)와 낮은 결합도(모듈이 독립적이며 서로에 대한 의존성이 최소화됨)를 달성하는 것입니다.
1. 워크스페이스를 사용한 프로젝트 구조
대규모 프로젝트의 경우 Rust의 워크스페이스 기능은 매우 유용합니다. 여러 관련 크레이트를 함께 관리할 수 있습니다.
이것은 서로 다른 논리적 구성 요소를 자체 크레이트로 분리하는 일반적인 전략입니다.
여러 서비스 애플리케이션 또는 명확한 논리적 경계가 있는 애플리케이션을 고려해 보세요.
my_big_project/
├── Cargo.toml # 워크스페이스 Cargo.toml
├── services/
│ ├── user-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # 사용자용 Actix Web/Axum 앱
│ ├── product-service/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs # 제품용 Actix Web/Axum 앱
│ └── order-service/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # 주문용 Actix Web/Axum 앱
└── shared_crates/
├── domain/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # 핵심 비즈니스 로직, 엔티티, 값 객체
├── infrastructure/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs # 데이터베이스 액세스(예: SQLx), 외부 API 클라이언트
└── common_types/
├── Cargo.toml
└── src/
└── lib.rs # 공통 DTO, 오류 유형
my_big_project/Cargo.toml
:
[workspace] members = [ "services/user-service", "services/product-service", "services/order-service", "shared_crates/domain", "shared_crates/infrastructure", "shared_crates/common_types", ] [workspace.dependencies] # 공통 종속성을 여기에 정의하여 일관된 버전을 보장합니다. # 예: Axum 서비스의 경우: tokio = { version = "1.36", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } # ... 기타 공통 종속성
각 services/*
크레이트는 개별 Cargo.toml
파일을 통해 관련 shared_crates/*
에 종속됩니다. 예를 들어, user-service/Cargo.toml
에는 다음과 같은 내용이 포함될 수 있습니다.
[dependencies] axum = "0.7.4" tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } domain = { path = "../../shared_crates/domain" } infrastructure = { path = "../../shared_crates/infrastructure" } common_types = { path = "../../shared_crates/common_types" } # ... 기타 서비스별 종속성
이 구조는 다음과 같이 책임을 명확하게 분리합니다.
- 각
service
크레이트는 독립적으로 배포 가능한 단위입니다. domain
은 웹 프레임워크나 데이터베이스와 독립적인 핵심 범용 비즈니스 로직을 보유합니다.infrastructure
는 외부 종속성을 캡슐화합니다.common_types
는 공유 데이터 구조의 중복을 방지합니다.
2. 크레이트 내 계층형 아키텍처
단일 바이너리 크레이트 내에서도(아직 마이크로 서비스로 분해되지 않은 간단한 웹 서비스 등) Rust의 모듈 시스템을 사용한 계층형 아키텍처가 중요합니다.
user-service
를 예로 들어 계층형 아키텍처 원칙을 적용해 보겠습니다.
user-service/
└── src/
├── main.rs # 진입점, 서버, 상태 초기화
├── api/
│ ├── mod.rs # 라우트, 상태 추출 정의
│ ├── handlers/
│ │ ├── mod.rs
│ │ └── user_handler.rs
│ └── dtos/
│ └── mod.rs
├── application/
│ ├── mod.rs
│ ├── services/
│ │ ├── mod.rs
│ │ └── user_app_service.rs
│ └── commands/
│ └── mod.rs
├── domain/
│ ├── mod.rs
│ ├── entities/
│ │ └── mod.rs
│ ├── value_objects/
│ │ └── mod.rs
│ ├── services/
│ │ └── mod.rs
│ └── repositories/
│ └── mod.rs
└── infrastructure/
├── mod.rs
├── persistence/
│ ├── mod.rs
│ └── user_repository_impl.rs
├── config.rs # 애플리케이션 구성 로딩
└── error.rs # 사용자 지정 오류 처리
예제 코드 스니펫(Axum):
domain/repositories/user_repository.rs
(리포지토리 트레이트 정의):
use async_trait::async_trait; use crate::domain::entities::User; use crate::domain::value_objects::UserId; use crate::infrastructure::error::ServiceError; #[async_trait] pub trait UserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError>; async fn save(&self, user: &mut User) -> Result<(), ServiceError>; // ... 기타 사용자 관련 데이터베이스 작업 }
infrastructure/persistence/user_repository_impl.rs
(구체적인 데이터베이스 구현):
use async_trait::async_trait; use sqlx::{PgPool, FromRow}; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::infrastructure::error::ServiceError; // 데이터베이스의 User 스키마를 나타내는 구조체 #[derive(Debug, Clone, FromRow)] struct UserDb { id: String, email: String, username: String, // ... 기타 필드 } pub struct PgUserRepository { pool: PgPool, } impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } // DB 모델에서 도메인 엔티티로 변환 impl TryFrom<UserDb> for User { type Error = ServiceError; // 또는 더 구체적인 도메인 오류 fn try_from(db_user: UserDb) -> Result<Self, Self::Error> { Ok(User::new( UserId::new(&db_user.id).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, Email::new(&db_user.email).map_err(|e| ServiceError::InternalServerError(e.to_string()))?, db_user.username, // ... )) } } // 도메인 엔티티에서 DB 모델로 변환 impl From<&User> for UserDb { fn from(user: &User) -> Self { UserDb { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), // ... } } } #[async_trait] impl UserRepository for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, ServiceError> { let user_db = sqlx::query_as!(UserDb, "SELECT id, email, username FROM users WHERE id = $1", id.to_string()) .fetch_optional(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; user_db.map(|u| u.try_into()).transpose() } async fn save(&self, user: &mut User) -> Result<(), ServiceError> { let user_db: UserDb = user.into(); sqlx::query!( "INSERT INTO users (id, email, username) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, username = $3", user_db.id, user_db.email, user_db.username ) .execute(&self.pool) .await .map_err(|e| ServiceError::DatabaseError(e.to_string()))?; Ok(()) } }
application/services/user_app_service.rs
(애플리케이션 서비스):
use std::sync::Arc; use crate::domain::entities::User; use crate::domain::value_objects::{UserId, Email}; use crate::domain::repositories::UserRepository; use crate::api::dtos::{CreateUserRequest, UserResponse}; use crate::infrastructure::error::ServiceError; pub struct UserApplicationService<R: UserRepository> { user_repository: Arc<R>, } impl<R: UserRepository> UserApplicationService<R> { pub fn new(user_repository: Arc<R>) -> Self { Self { user_repository } } pub async fn create_user(&self, request: CreateUserRequest) -> Result<UserResponse, ServiceError> { let email = Email::new(&request.email) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; // 예: 사용자가 이미 존재하는지 확인 // if self.user_repository.find_by_email(&email).await?.is_some() { // return Err(ServiceError::Conflict("User with this email already exists".to_string())); // } let mut user = User::new_with_generated_id(email, request.username); self.user_repository.save(&mut user).await?; Ok(UserResponse { id: user.id().to_string(), email: user.email().to_string(), username: user.username().to_string(), }) } pub async fn get_user_by_id(&self, id: &str) -> Result<Option<UserResponse>, ServiceError> { let user_id = UserId::new(id) .map_err(|e| ServiceError::BadRequest(e.to_string()))?; let user = self.user_repository.find_by_id(&user_id).await?; Ok(user.map(|u| UserResponse { id: u.id().to_string(), email: u.email().to_string(), username: u.username().to_string(), })) } }
api/handlers/user_handler.rs
(Axum 핸들러):
use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use std::sync::Arc; use crate::{ api::dtos::{CreateUserRequest, UserResponse}, application::services::user_app_service::UserApplicationService, domain::repositories::UserRepository, infrastructure::error::ServiceError, }; // 공유 종속성을 보유할 AppState 정의 #[derive(Clone)] pub struct AppState<R: UserRepository> { pub user_app_service: Arc<UserApplicationService<R>>, } pub async fn create_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Json(request): Json<CreateUserRequest>, ) -> Result<Json<UserResponse>, ServiceError> { let response = app_state.user_app_service.create_user(request).await?; Ok(Json(response)) } pub async fn get_user_handler<R: UserRepository>( State(app_state): State<AppState<R>>, Path(user_id): Path<String>, ) -> Result<(StatusCode, Json<UserResponse>), ServiceError> { if let Some(user_response) = app_state.user_app_service.get_user_by_id(&user_id).await? { Ok((StatusCode::OK, Json(user_response))) } else { Err(ServiceError::NotFound("User not found".to_string())) } }
main.rs
(애플리케이션 조립):
use axum::{routing::{get, post}, Router}; use std::{net::SocketAddr, sync::Arc}; use sqlx::PgPool; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod api; mod application; mod domain; mod infrastructure; use infrastructure::{ config::Config, error::ServiceError, persistence::user_repository_impl::PgUserRepository, }; use api::handlers::{ user_handler::{self, AppState}, }; use application::services::user_app_service::UserApplicationService; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "user_service=debug,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); let config = Config::load_from_env(); let pool = PgPool::connect(&config.database_url).await?; // 데이터베이스 마이그레이션 실행 sqlx::migrate!("./migrations") .run(&pool) .await .map_err(|e| ServiceError::DatabaseError(format!("Migration failed: {}", e)))?; // 리포지토리 및 서비스 인스턴스화 let user_repo = Arc::new(PgUserRepository::new(pool.clone())); let user_app_service = Arc::new(UserApplicationService::new(user_repo.clone())); let app_state = AppState { user_app_service, }; let app = Router::new() .route("/users", post(user_handler::create_user_handler::<PgUserRepository>)) .route("/users/:user_id", get(user_handler::get_user_handler::<PgUserRepository>)) .with_state(app_state.clone()); // 상태를 라우터에 전달 let addr = SocketAddr::from(([127, 0, 0, 1], config.port)); tracing::info!("listening on {}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) }
이 구조는 다음과 같이 책임을 명확하게 구분합니다.
domain
계층은 순수한 Rust이며 비즈니스 규칙 및 데이터 모델에 중점을 두고 Axum/Actix Web 또는sqlx
와 완전히 독립적입니다.infrastructure
계층은 특정 기술(예:sqlx::PgPool
)을 처리합니다.application
계층은domain
및infrastructure
구성 요소를 조정하여 사용 사례를 실행합니다.api
계층은 HTTP 요청 및 응답을 처리하고 일반 웹 형식과 애플리케이션별 입력/출력 간을 변환합니다.main.rs
는 구성 및 시작을 담당합니다.
3. 종속성 주입 및 디커플링을 위한 트레이트
UserApplicationService
와 user_handler
가 R: UserRepository
에 대해 제네릭하다는 점에 유의하세요. 이것은 종속성 주입을 위한 강력한 Rust 패턴입니다.
UserApplicationService
내에 PgUserRepository
를 직접 인스턴스화하는 대신, UserRepository
트레이트를 구현하는 모든 유형에 대한 종속성을 표현합니다.
이것은 여러 이점을 제공합니다.
- 테스트 용이성:
UserApplicationService
의 단위 테스트에서 실제 데이터베이스 호출을 우회하여 모의 또는 가짜UserRepository
구현을 제공할 수 있습니다. - 유연성:
UserApplicationService
로직을 변경하지 않고 새UserRepository
구현을 만들어 데이터베이스 구현(예: PostgreSQL에서 MongoDB로)을 쉽게 전환할 수 있습니다. - 디커플링: 계층은 구체적인 구현이 아닌 트레이트(추상화)에만 의존하여 느슨한 결합을 촉진합니다.
4. 오류 처리 전략
대규모 애플리케이션에서는 중앙 집중식 오류 처리 전략이 중요합니다.
infrastructure/error.rs
에 애플리케이션에서 발생할 수 있는 다양한 오류 유형(예: DatabaseError
, ValidationError
, NotFound
, Unauthorized
)을 캡슐화하는 사용자 지정 ServiceError
열거형을 정의합니다.
일반적인 오류 유형(예: sqlx::Error
또는 유효성 검사 오류)을 ServiceError
로 변환하는 From
변환을 구현합니다.
// infrastructure/error.rs use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; #[derive(Debug)] pub enum ServiceError { NotFound(String), BadRequest(String), Unauthorized(String), Conflict(String), DatabaseError(String), InternalServerError(String), // ... 더 구체적인 오류 가능성 } impl IntoResponse for ServiceError { fn into_response(self) -> Response { let (status, error_message) = match self { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), ServiceError::Conflict(msg) => (StatusCode::CONFLICT, msg), ServiceError::DatabaseError(msg) => { tracing::error!("Database error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) }, ServiceError::InternalServerError(msg) => { tracing::error!("Internal server error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()) }, }; // 클라이언트를 위한 구조화된 오류 페이로드가 필요할 수 있습니다. let body = serde_json::json!({ "error": error_message, }); (status, axum::Json(body)).into_response() } } // 편의를 위한 `From` 변환 구현 impl From<sqlx::Error> for ServiceError { fn from(err: sqlx::Error) -> Self { ServiceError::DatabaseError(err.to_string()) } } // ... 기타 일반적인 오류 유형에 대한 `From` 구현
결론
효과적인 모듈식 설계는 Actix Web 및 Axum과 같은 프레임워크를 사용하는 Rust 웹 애플리케이션의 확장성, 유지보수성 및 협업을 위한 기반입니다.
Rust의 워크스페이스 및 모듈 시스템을 신중하게 적용하고, 계층형 아키텍처를 준수하고, 종속성 역전을 위해 트레이트를 활용함으로써 개발자는 각 구성 요소가 명확하고 격리된 책임을 갖는 강력한 시스템을 만들 수 있습니다.
이러한 신중한 구성은 코드 명확성, 테스트 용이성 및 진화하는 요구 사항에 적응하는 능력을 크게 향상시켜 프로젝트가 규모와 복잡성이 증가함에 따라 번영하도록 보장합니다.
모듈식 설계는 대규모 Rust 웹 프로젝트를 소프트웨어 개발의 복잡한 환경을 통과하는 나침반입니다.