DDD 계층형 설계를 통한 유지보수 가능한 Rust 웹 앱 구축
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
강력하고 확장 가능한 웹 애플리케이션을 구축하는 것은 일반적인 과제이며, 프로젝트가 복잡해짐에 따라 명확하고 잘 구성된 코드베이스를 유지하는 것이 무엇보다 중요합니다. 종종 비즈니스 로직이 인프라 문제와 얽혀 이해, 테스트 및 진화하기 어려운 애플리케이션을 초래합니다. 여기서 도메인 주도 설계(DDD)는 강력한 접근 방식을 제공합니다. 핵심 도메인에 집중하고 관심사를 분리함으로써 DDD는 비즈니스 요구 사항에 더 잘 부합하고 변경에 더 탄력적인 시스템을 만들도록 도와줍니다.
Rust 생태계에서 안전성, 성능 및 동시성에 중점을 둔 계층형 DDD 아키텍처를 채택하는 것은 특히 유익할 수 있습니다. 이를 통해 Rust의 강점을 활용하면서 유지보수 가능하고 이해하기 쉬운 웹 서비스를 구축할 수 있습니다. 이 글은 Rust 웹 프로젝트에서 클린, 계층형 DDD를 실천하는 방법과 프로세스를 안내하는 실질적인 통찰력과 코드 예제를 제공할 것입니다.
핵심 개념 이해
구현 세부 사항을 살펴보기 전에 계층형 DDD 아키텍처의 중추를 형성하는 몇 가지 핵심 용어를 명확히 합시다.
- 도메인 주도 설계(DDD): 핵심 비즈니스 개념의 진화하는 모델에 구현을 연결하여 복잡한 요구 사항을 위한 소프트웨어 개발 접근 방식.
- 계층형 아키텍처: 구성 요소가 논리적 계층으로 구성되는 일반적인 아키텍처 패턴으로, 각 계층은 특정 책임을 가집니다. 상위 계층은 하위 계층에 의존하지만 그 반대는 아니므로 관심사 분리를 촉진합니다.
- 도메인 계층: 이것은 DDD 애플리케이션의 핵심입니다. 비즈니스 로직, 엔티티, 값 객체, 집계 및 도메인 서비스를 포함합니다. 어떤 인프라 문제에도 독립적입니다.
- 애플리케이션 계층: 특정 애플리케이션 작업을 수행하기 위해 도메인 개체를 조정합니다. 도메인 계층 위에서 얇은 인터페이스 역할을 하며 사용 사례를 처리하고 상호 작용을 조정합니다. 자체적으로 비즈니스 로직을 포함하지 않습니다.
- 인프라 계층: 지속성(데이터베이스), 외부 통신(API), 로깅 및 메시징과 같이 상위 계층을 지원하는 일반적인 기술 기능을 제공합니다.
- 사용자 인터페이스/프레젠테이션 계층: 사용자에게 정보를 표시하고 사용자 입력을 처리하는 역할을 합니다. 웹 애플리케이션에서는 종종 HTTP 엔드포인트 및 요청/응답 처리로 해석됩니다.
- 엔티티: 시간과 다른 표현을 통해 실행되는 뚜렷한 식별자를 가진 개체.
- 값 객체: 어떤 것의 특성을 설명하지만 개념적 식별자는 없는 개체. 그들의 동등성은 속성에 기반합니다.
- 집계: 데이터 변경을 위해 단위로 취급되는 관련된 개체의 클러스터. 집계에는 루트 엔티티가 있으며, 이는 외부 개체가 참조를 보유하도록 허용되는 집계의 유일한 멤버입니다.
- 리포지토리: 데이터 지속성 메커니즘에 대한 추상화로, 도메인 계층이 기본 스토리지 기술을 알지 못하고 집계를 검색하고 저장할 수 있도록 합니다.
- 도메인 서비스: 엔티티 또는 값 객체 내에서 자연스럽게 맞지 않는 작업으로, 종종 여러 도메인 개체를 조정합니다.
Rust 웹 프로젝트의 실질적인 계층화
클린, 계층형 DDD 접근 방식을 사용하여 Rust 웹 프로젝트를 구조화하는 방법을 설명해 보겠습니다. 간단한 예제, 즉 작업 관리 애플리케이션을 사용합니다.
일반적인 프로젝트 구조는 다음과 같을 수 있습니다.
├── src
│ ├── main.rs
│ ├── application // Application layer
│ │ ├── commands // For write operations
│ │ ├── queries // For read operations
│ │ └── services // Application services that orchestrate domain
│ ├── domain // Domain layer
│ │ ├── entities
│ │ ├── errors
│ │ ├── repositories
│ │ ├── services
│ │ └── value_objects
│ ├── infrastructure // Infrastructure layer
│ │ ├── database // Persistence (e.g., SQLx, Diesel)
│ │ ├── web // Web server (e.g., Actix Web, Axum)
│ │ └── ... // Other infrastructure concerns
│ └── presentation // Presentation layer (often within infrastructure/web)
│ ├── handlers
│ └── models // DTOs for presentation
└── Cargo.toml
도메인 계층
이곳에는 핵심 비즈니스 로직이 있습니다. 직접적으로 도메인과 관련이 없는 프레임워크나 외부 라이브러리와는 독립적이어야 합니다.
src/domain/entities.rs
use crate::domain::value_objects::{TaskId, TaskDescription, TaskStatus}; #[derive(Debug, PartialEq, Eq, Clone)] pub struct Task { pub id: TaskId, pub description: TaskDescription, pub status: TaskStatus, } impl Task { pub fn new(id: TaskId, description: TaskDescription, status: TaskStatus) -> Self { Self { id, description, status } } pub fn mark_as_completed(&mut self) { if self.status != TaskStatus::Completed { self.status = TaskStatus::Completed; } } pub fn update_description(&mut self, new_description: TaskDescription) { self.description = new_description; } }
src/domain/value_objects.rs
use uuid::Uuid; use std::fmt; #[derive(Debug, PartialEq, Eq, Clone, Hash)] pub struct TaskId(Uuid); impl TaskId { pub fn new() -> Self { Self(Uuid::new_v4()) } pub fn from_uuid(id: Uuid) -> Self { Self(id) } pub fn into_uuid(self) -> Uuid { self.0 } } impl fmt::Display for TaskId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } pub struct InvalidTaskDescriptionError; #[derive(Debug, PartialEq, Eq, Clone)] pub struct TaskDescription(String); impl TaskDescription { pub fn new(description: String) -> Result<Self, InvalidTaskDescriptionError> { if description.is_empty() || description.len() > 255 { return Err(InvalidTaskDescriptionError); } Ok(Self(description)) } pub fn as_str(&self) -> &str { &self.0 } } #[derive(Debug, PartialEq, Eq, Clone)] pub enum TaskStatus { Pending, InProgress, Completed, }
src/domain/repositories.rs
이 트레이트는 Task
데이터와의 상호 작용 계약을 정의합니다. 도메인 계층은 어떻게 저장되는지에 신경 쓰지 않고, 어떤 작업이 사용 가능한지에만 신경 씁니다.
use async_trait::async_trait; use crate::domain::entities::Task; use crate::domain::value_objects::TaskId; use crate::domain::errors::DomainError; use std::error::Error; #[async_trait] pub trait TaskRepository: Send + Sync { async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, Box<dyn Error>>; async fn save(&self, task: &Task) -> Result<(), Box<dyn Error>>; async fn delete(&self, id: &TaskId) -> Result<(), Box<dyn Error>>; async fn find_all(&self) -> Result<Vec<Task>, Box<dyn Error>>; }
애플리케이션 계층
이 계층에는 특정 사용 사례를 충족하기 위해 도메인 개체를 사용하는 애플리케이션 서비스가 포함됩니다. 자체적으로 비즈니스 로직을 포함하지 않고 목표를 달성하기 위해 도메인 개체를 조정합니다.
src/application/commands.rs
use crate::domain::value_objects::{TaskDescription, TaskId, TaskStatus}; pub struct CreateTaskCommand { pub description: String, } pub struct UpdateTaskDescriptionCommand { pub task_id: String, pub new_description: String, } pub struct MarkTaskCompletedCommand { pub task_id: String, }
src/application/services.rs
use std::sync::Arc; use crate::application::commands::{CreateTaskCommand, MarkTaskCompletedCommand, UpdateTaskDescriptionCommand}; use crate::domain::entities::Task; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::{TaskId, TaskDescription, TaskStatus}; use crate::domain::errors::DomainError; use uuid::Uuid; use std::error::Error; pub struct TaskService<T: TaskRepository> { task_repository: Arc<T>, } impl<T: TaskRepository> TaskService<T> { pub fn new(task_repository: Arc<T>) -> Self { Self { task_repository } } pub async fn create_task(&self, command: CreateTaskCommand) -> Result<TaskId, Box<dyn Error>> { let task_id = TaskId::new(); let description = TaskDescription::new(command.description) .map_err(|_| DomainError::ValidationError("Invalid task description".to_string()))?; let task = Task::new(task_id.clone(), description, TaskStatus::Pending); self.task_repository.save(&task).await?; Ok(task_id) } pub async fn update_task_description(&self, command: UpdateTaskDescriptionCommand) -> Result<(), Box<dyn Error>> { let task_id = TaskId::from_uuid(Uuid::parse_str(&command.task_id)?); let mut task = self.task_repository.find_by_id(&task_id).await? .ok_or(DomainError::NotFound(format!("Task with ID {} not found", task_id)))?; let new_description = TaskDescription::new(command.new_description) .map_err(|_| DomainError::ValidationError("Invalid task description".to_string()))?; task.update_description(new_description); self.task_repository.save(&task).await?; Ok(()) } pub async fn mark_task_completed(&self, command: MarkTaskCompletedCommand) -> Result<(), Box<dyn Error>> { let task_id = TaskId::from_uuid(Uuid::parse_str(&command.task_id)?); let mut task = self.task_repository.find_by_id(&task_id).await? .ok_or(DomainError::NotFound(format!("Task with ID {} not found", task_id)))?; task.mark_as_completed(); self.task_repository.save(&task).await?; Ok(()) } pub async fn get_task_by_id(&self, task_id: &str) -> Result<Option<Task>, Box<dyn Error>> { let id = TaskId::from_uuid(Uuid::parse_str(task_id)?); self.task_repository.find_by_id(&id).await } pub async fn get_all_tasks(&self) -> Result<Vec<Task>, Box<dyn Error>> { self.task_repository.find_all().await } }
인프라 계층 (지속성 예제)
이 계층은 도메인 계층에 정의된 TaskRepository
트레이트를 구현하며, 일반적으로 데이터베이스와 상호 작용합니다.
src/infrastructure/database/models.rs
데이터베이스 상호 작용을 위한 데이터 전송 개체(DTO).
use sqlx::FromRow; use uuid::Uuid; use crate::domain::value_objects::TaskStatus; #[derive(FromRow)] pub struct TaskModel { pub id: Uuid, pub description: String, pub status: String, // Stored as string in DB } impl From<TaskModel> for crate::domain::entities::Task { fn from(model: TaskModel) -> Self { crate::domain::entities::Task::new( crate::domain::value_objects::TaskId::from_uuid(model.id), crate::domain::value_objects::TaskDescription::new(model.description).expect("invalid description from db"), // Should handle better in real app TaskStatus::from(model.status.as_str()), ) } } impl From<&crate::domain::entities::Task> for TaskModel { fn from(task: &crate::domain::entities::Task) -> Self { TaskModel { id: task.id.into_uuid(), description: task.description.as_str().to_string(), status: task.status.to_string(), } } } impl From<&str> for TaskStatus { fn from(s: &str) -> Self { match s { "Pending" => TaskStatus::Pending, "InProgress" => TaskStatus::InProgress, "Completed" => TaskStatus::Completed, _ => TaskStatus::Pending, // Default or error handling } } } impl ToString for TaskStatus { fn to_string(&self) -> String { match self { TaskStatus::Pending => "Pending".to_string(), TaskStatus::InProgress => "InProgress".to_string(), TaskStatus::Completed => "Completed".to_string(), } } }
src/infrastructure/database/repositories.rs
use async_trait::async_trait; use sqlx::{PgPool, Error as SqlxError}; use std::sync::Arc; use crate::domain::entities::Task; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::TaskId; use crate::infrastructure::database::models::TaskModel; use std::error::Error; pub struct PgTaskRepository { pool: Arc<PgPool>, } impl PgTaskRepository { pub fn new(pool: Arc<PgPool>) -> Self { Self { pool } } } #[async_trait] impl TaskRepository for PgTaskRepository { async fn find_by_id(&self, id: &TaskId) -> Result<Option<Task>, Box<dyn Error>> { let task_model = sqlx::query_as!( TaskModel, "SELECT id, description, status FROM tasks WHERE id = $1", id.into_uuid() ) .fetch_optional(&*self.pool) .await?; Ok(task_model.map(Task::from)) } async fn save(&self, task: &Task) -> Result<(), Box<dyn Error>> { let task_model = TaskModel::from(task); sqlx::query!( "INSERT INTO tasks (id, description, status) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET description = $2, status = $3", task_model.id, task_model.description, task_model.status ) .execute(&*self.pool) .await?; Ok(()) } async fn delete(&self, id: &TaskId) -> Result<(), Box<dyn Error>> { sqlx::query!("DELETE FROM tasks WHERE id = $1", id.into_uuid()) .execute(&*self.pool) .await?; Ok(()) } async fn find_all(&self) -> Result<Vec<Task>, Box<dyn Error>> { let task_models = sqlx::query_as!( TaskModel, "SELECT id, description, status FROM tasks" ) .fetch_all(&*self.pool) .await?; Ok(task_models.into_iter().map(Task::from).collect()) } }
프레젠테이션 계층 (웹 핸들러 예제)
이 계층은 HTTP 요청 및 응답을 처리하며, 이를 애플리케이션 계층 명령/쿼리로 변환하고 적절한 응답을 반환합니다. 간결함을 위해 특정 웹 프레임워크 없이 단순화된 예를 사용하며, 상호 작용을 시연합니다.
src/presentation/models.rs
API 요청/응답을 위한 DTO.
use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct CreateTaskRequest { pub description: String, } #[derive(Serialize)] pub struct TaskResponse { pub id: String, pub description: String, pub status: String, }
src/presentation/handlers.rs
(개념적 - 이는 Axum 또는 Actix와 같은 웹 프레임워크와 통합될 것입니다)
use std::error::Error; use std::sync::Arc; use crate::application::commands::{CreateTaskCommand, MarkTaskCompletedCommand, UpdateTaskDescriptionCommand}; use crate::application::services::TaskService; use crate::domain::repositories::TaskRepository; use crate::domain::value_objects::TaskStatus; use crate::presentation::models::{CreateTaskRequest, TaskResponse}; // 이 구조체는 일반적으로 웹 서버의 상태 관리 일부입니다. pub struct TaskHandler<T: TaskRepository> { task_service: Arc<TaskService<T>>, } impl<T: TaskRepository> TaskHandler<T> { pub fn new(task_service: Arc<TaskService<T>>) -> Self { Self { task_service } } // 예: HTTP POST /tasks pub async fn create_task(&self, req: CreateTaskRequest) -> Result<TaskResponse, Box<dyn Error>> { let command = CreateTaskCommand { description: req.description }; let task_id = self.task_service.create_task(command).await?; Ok(TaskResponse { id: task_id.to_string(), description: req.description, // Simplified, ideally retrieve full task status: TaskStatus::Pending.to_string(), }) } // 예: HTTP GET /tasks/{id} pub async fn get_task_by_id(&self, task_id: &str) -> Result<Option<TaskResponse>, Box<dyn Error>> { let task = self.task_service.get_task_by_id(task_id).await?; Ok(task.map(|t| TaskResponse { id: t.id.to_string(), description: t.description.as_str().to_string(), status: t.status.to_string(), })) } // 예: HTTP PUT /tasks/{id}/complete pub async fn mark_task_as_completed(&self, task_id: &str) -> Result<(), Box<dyn Error>> { let command = MarkTaskCompletedCommand { task_id: task_id.to_string() }; self.task_service.mark_task_completed(command).await?; Ok(()) } }
종합 (메인 애플리케이션)
main.rs
는 종속성 주입을 수행하고 웹 서버를 시작합니다.
// src/main.rs (시연을 위해 단순화됨) use std::sync::Arc; use sqlx::PgPool; use anyhow::Result; use crate::application::services::TaskService; use crate::infrastructure::database::repositories::PgTaskRepository; use crate::presentation::handlers::TaskHandler; mod domain; mod application; mod infrastructure; mod presentation; #[tokio::main] async fn main() -> Result<()> { // 1. 인프라 초기화 (예: 데이터베이스 풀) let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); let pool = PgPool::connect(&database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; // 마이그레이션 실행 let db_pool = Arc::new(pool); // 2. 인프라 리포지토리 인스턴스화 let task_repository = Arc::new(PgTaskRepository::new(Arc::clone(&db_pool))); // 3. 리포지토리를 사용하여 애플리케이션 서비스 인스턴스화 let task_service = Arc::new(TaskService::new(Arc::clone(&task_repository))); // 4. 애플리케이션 서비스를 사용하여 프레젠테이션 핸들러 인스턴스화 let task_handler = TaskHandler::new(Arc::clone(&task_service)); // 실제 애플리케이션에서는 여기에 웹 서버를 구성하고 시작합니다. // Axum 또는 Actix와 같은 웹 프레임워크를 사용하고 `task_handler` 또는 해당 메서드를 라우트에 전달합니다. // 예: // let app = Router::new() // .route("/tasks", post(move |req| task_handler.create_task(req))) // .route("/tasks/:id", get(move |id| task_handler.get_task_by_id(id))); // // let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); // axum::serve(listener, app).await.unwrap(); println!("Application configured. Web server would start here."); println!("Example: task_handler.create_task(...) can now be called"); Ok(()) }
이 계층화된 접근 방식의 이점:
- 관심사 분리: 각 계층에는 고유한 책임이 있어 코드베이스를 더 쉽게 이해하고 관리할 수 있습니다.
- 테스트 용이성: 도메인 계층은 데이터베이스나 웹 서버 없이도 독립적으로 테스트할 수 있습니다. 애플리케이션 계층은 리포지토리를 모방하여 테스트할 수 있습니다.
- 유지보수 용이성: 한 계층의 변경(예: 인프라 계층에서 데이터베이스 전환)은 다른 계층에 미치는 영향을 최소화합니다.
- 유연성: 핵심 비즈니스 로직은 독립적으로 유지되어 동일한 도메인 및 애플리케이션 계층 위에 다른 프레젠테이션(예: CLI 또는 모바일 앱)을 구축할 수 있습니다.
- 결합도 감소: 종속성은 아래로 흐르므로 상위 계층은 하위 계층의 추상화에 의존하고 구체적인 구현에는 의존하지 않습니다.
결론
Rust 웹 프로젝트에서 명확하고 계층화된 도메인 주도 설계를 구현하는 것은 유지보수 가능하고 확장 가능하며 테스트 가능한 애플리케이션을 구축하는 강력한 전략입니다. 핵심 도메인 로직을 애플리케이션 조정 및 인프라 문제로부터 신중하게 분리함으로써 팀은 Rust의 고유한 강점을 활용하면서 비즈니스 가치에 집중할 수 있습니다. 이 아키텍처 접근 방식은 복잡한 시스템에 명확성을 더할 뿐만 아니라 미래의 성장과 적응을 위한 견고한 기반을 제공합니다. 계층화된 DDD를 채택하면 진화하기 즐거운 더 강력하고 고품질의 Rust 웹 서비스가 제공될 것입니다.