Rust 웹 서비스 계층으로 견고한 비즈니스 로직 구축하기
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
끊임없이 진화하는 웹 개발 환경에서 확장 가능하고, 유지보수 가능하며, 테스트 가능한 애플리케이션을 구축하는 것은 매우 중요합니다. 프로젝트가 복잡해짐에 따라 비즈니스 규칙, 데이터 액세스 및 HTTP 처리가 단일 계층에 얽히게 되면 이해, 수정 및 테스트가 어려운 코드로 이어질 수 있습니다. 이러한 흔한 함정은 종종 '팻 컨트롤러(fat controllers)' 또는 '빈 모델(anemic models)'로 이어져 생산성을 저해하고 미묘한 버그를 발생시킵니다. Rust는 강력한 타입 시스템, 성능 특성 및 정확성에 대한 집중 덕분에 견고한 웹 서비스를 구축할 수 있는 훌륭한 기반을 제공합니다. 하지만 Rust를 사용하는 것만으로는 충분하지 않으며, 신중한 아키텍처 패턴이 여전히 중요합니다. 이 글은 Rust 웹 프로젝트에서 서비스 계층을 설계하고 구현하는 방법을 심층적으로 다룹니다. 이는 비즈니스 로직을 캡슐화하여 HTTP 인프라 및 데이터베이스 세부 정보와 분리하는 강력한 패턴입니다. 이 접근 방식을 채택함으로써 코드 구성을 개선하고, 협업을 촉진하며, 궁극적으로 더 탄력적인 애플리케이션을 제공하는 것을 목표로 합니다.
서비스 계층 설계의 핵심 기둥 이해하기
Rust에서 서비스 계층을 구축하는 구체적인 내용으로 들어가기 전에, 관련된 핵심 개념에 대한 공통된 이해를 확립해 봅시다:
-
비즈니스 로직: 이는 비즈니스가 어떻게 운영되고 데이터가 어떻게 변환되고 조작되는지를 정의하는 핵심 규칙과 프로세스를 의미합니다. 단순한 데이터 저장 및 검색을 넘어 애플리케이션의 "무엇"과 "왜"입니다. 예로는 사용자 입력 유효성 검사, 주문 총액 계산, 할인 적용 또는 복잡한 워크플로 조정 등이 있습니다.
-
서비스 계층: 서비스 계층은 프레젠테이션/HTTP 계층(예: 컨트롤러 또는 핸들러)과 데이터 액세스 계층(예: 리포지토리 또는 ORM) 사이의 중개자 역할을 합니다. 주요 책임은 비즈니스 로직을 캡슐화하고 조정하는 것입니다. 컨트롤러로부터 요청을 받아 비즈니스 규칙을 적용하고, 데이터 계층과 상호 작용하며, 결과를 반환합니다. 애플리케이션이 수행할 수 있는 작업을 명확하게 정의합니다.
-
리포지토리 패턴: 이 패턴은 기본 데이터 저장 메커니즘을 추상화합니다. 리포지토리는 데이터 집합에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행하기 위한 인터페이스를 제공하여 서비스 계층을 데이터베이스(예: SQL, NoSQL)의 특정 사항으로부터 격리합니다. 이를 통해 서비스 계층은 일관된 객체 지향 방식으로 데이터와 상호 작용할 수 있습니다.
-
의존성 주입(DI): Rust의 소유권 시스템은 본질적으로 전역 상태를 권장하지 않지만, DI는 종속성을 관리하는 데 여전히 가치 있는 패턴입니다. 컴포넌트(서비스 구조체 등)가 종속성(데이터베이스 연결, 리포지토리 구현 또는 기타 서비스와 같은)을 직접 생성하는 대신, 해당 컴포넌트에 전달하는 것을 포함합니다. 이는 더 느슨한 결합을 촉진하여 테스트와 리팩토링을 훨씬 쉽게 만듭니다.
Rust 웹 애플리케이션에서 서비스 계층 구현하기
서비스 계층의 기본 원칙은 관심사 분리입니다. 우리의 웹 핸들러는 HTTP 요청 및 응답 처리에만 집중해야 하며, 데이터 액세스 계층은 데이터베이스와의 상호 작용에 집중해야 합니다. 서비스 계층은 이 간극을 메우면서 모든 애플리케이션별 비즈니스 규칙을 수용합니다.
간단한 예시, 즉 가상의 Product 관리 애플리케이션을 통해 이를 설명해 보겠습니다. Product 구조체, ProductRepository 트레잇 및 ProductService 구조체를 사용합니다.
먼저 데이터 모델과 에러 타입을 정의합니다:
// src/models.rs #[derive(Debug, Clone, PartialEq, Eq)] pub struct Product { pub id: String, pub name: String, pub description: String, pub price: u32, pub stock: u32, } // src/errors.rs #[derive(Debug, thiserror::Error)] pub enum ServiceError { #[error("Product not found: {0}")] NotFound(String), #[error("Invalid product data: {0}")] InvalidData(String), #[error("Database error: {0}")] DatabaseError(ProductRepositoryError), #[error("Insufficient stock for product {0}. Available: {1}, Requested: {2}")] InsufficientStock(String, u32, u32), // ... potentially other errors } #[derive(Debug, thiserror::Error)] pub enum ProductRepositoryError { #[error("Failed to connect to database")] ConnectionError, #[error("Record not found")] RecordNotFound, #[error("Database operation failed: {0}")] OperationFailed(String), // ... other repository specific errors } // Convert ProductRepositoryError to ServiceError impl From<ProductRepositoryError> for ServiceError { fn from(err: ProductRepositoryError) -> Self { ServiceError::DatabaseError(err) } }
다음으로 ProductRepository 트레잇을 정의합니다. 이 트레잇은 제품 리포지토리 역할을 하려는 모든 타입에 대한 계약을 간략하게 설명하며, 다양한 데이터베이스 구현(예: PostgreSQL, MongoDB 또는 테스트를 위한 메모리 내 모크)을 쉽게 교체할 수 있도록 합니다.
// src/repositories.rs use async_trait::async_trait; use crate::models::Product; use crate::errors::ProductRepositoryError; #[async_trait] pub trait ProductRepository: Send + Sync + 'static { // 'static is good practice for traits passed around async fn find_all(&self) -> Result<Vec<Product>, ProductRepositoryError>; async fn find_by_id(&self, id: &str) -> Result<Option<Product>, ProductRepositoryError>; async fn create(&self, product: Product) -> Result<Product, ProductRepositoryError>; async fn update(&self, product: Product) -> Result<Product, ProductRepositoryError>; async fn delete(&self, id: &str) -> Result<(), ProductRepositoryError>; // Method to update stock (could be part of update, but explicit is good) async fn update_stock(&self, id: &str, new_stock: u32) -> Result<(), ProductRepositoryError>; }
이제 시연 및 테스트 목적으로 ProductRepository의 메모리 내 버전을 구현할 수 있습니다:
// src/repositories.rs (continued) use std::collections::HashMap; use std::sync::{Arc, Mutex}; pub struct InMemoryProductRepository { products: Arc<Mutex<HashMap<String, Product>>>, } impl InMemoryProductRepository { pub fn new() -> Self { let mut products_map = HashMap::new(); products_map.insert("p1".to_string(), Product { id: "p1".to_string(), name: "Laptop".to_string(), description: "Powerful portable computer".to_string(), price: 1200, stock: 10, }); products_map.insert("p2".to_string(), Product { id: "p2".to_string(), name: "Mouse".to_string(), description: "Wireless optical mouse".to_string(), price: 25, stock: 50, }); InMemoryProductRepository { products: Arc::new(Mutex::new(products_map)), } } } #[async_trait] impl ProductRepository for InMemoryProductRepository { async fn find_all(&self) -> Result<Vec<Product>, ProductRepositoryError> { let products_guard = self.products.lock().unwrap(); Ok(products_guard.values().cloned().collect()) } async fn find_by_id(&self, id: &str) -> Result<Option<Product>, ProductRepositoryError> { let products_guard = self.products.lock().unwrap(); Ok(products_guard.get(id).cloned()) } async fn create(&self, product: Product) -> Result<Product, ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if products_guard.contains_key(&product.id) { return Err(ProductRepositoryError::OperationFailed(format!("Product with ID {} already exists", product.id))); } products_guard.insert(product.id.clone(), product.clone()); Ok(product) } async fn update(&self, product: Product) -> Result<Product, ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if !products_guard.contains_key(&product.id) { return Err(ProductRepositoryError::RecordNotFound); } products_guard.insert(product.id.clone(), product.clone()); Ok(product) } async fn delete(&self, id: &str) -> Result<(), ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if products_guard.remove(id).is_none() { return Err(ProductRepositoryError::RecordNotFound); } Ok(()) } async fn update_stock(&self, id: &str, new_stock: u32) -> Result<(), ProductRepositoryError> { let mut products_guard = self.products.lock().unwrap(); if let Some(product) = products_guard.get_mut(id) { product.stock = new_stock; Ok(()) } else { Err(ProductRepositoryError::RecordNotFound) } } }
리포지토리가 준비되었으므로 이제 ProductService를 정의할 수 있습니다. 비즈니스 로직이 여기에 있습니다.
// src/services.rs use std::sync::Arc; use crate::models::Product; use crate::repositories::ProductRepository; use crate::errors::ServiceError; pub struct CreateProductDto { pub id: String, pub name: String, pub description: String, pub price: u32, pub stock: u32, } pub struct UpdateProductDto { pub name: Option<String>, pub description: Option<String>, pub price: Option<u32>, pub stock: Option<u32>, } pub struct ProductService<R: ProductRepository> { repository: Arc<R>, } impl<R: ProductRepository> ProductService<R> { pub fn new(repository: Arc<R>) -> Self { ProductService { repository } } pub async fn get_all_products(&self) -> Result<Vec<Product>, ServiceError> { self.repository.find_all().await.map_err(ServiceError::from) } pub async fn get_product_by_id(&self, id: &str) -> Result<Product, ServiceError> { self.repository.find_by_id(id).await? .ok_or_else(|| ServiceError::NotFound(id.to_string())) } pub async fn create_product(&self, dto: CreateProductDto) -> Result<Product, ServiceError> { // Business logic: Ensure price and stock are positive if dto.price == 0 { return Err(ServiceError::InvalidData("Product price cannot be zero".to_string())); } if dto.stock == 0 { return Err(ServiceError::InvalidData("Product stock cannot be zero".to_string())); } let product = Product { id: dto.id, name: dto.name, description: dto.description, price: dto.price, stock: dto.stock, }; self.repository.create(product).await.map_err(ServiceError::from) } pub async fn update_product(&self, id: &str, dto: UpdateProductDto) -> Result<Product, ServiceError> { let mut product = self.repository.find_by_id(id).await? .ok_or_else(|| ServiceError::NotFound(id.to_string()))?; // Business logic: Apply updates and validate if let Some(name) = dto.name { product.name = name; } if let Some(description) = dto.description { product.description = description; } if let Some(price) = dto.price { if price == 0 { return Err(ServiceError::InvalidData("Product price cannot be zero".to_string())); } product.price = price; } if let Some(stock_update) = dto.stock { if stock_update == 0 { return Err(ServiceError::InvalidData("Product stock cannot be zero".to_string())); } product.stock = stock_update; } self.repository.update(product).await.map_err(ServiceError::from) } pub async fn delete_product(&self, id: &str) -> Result<(), ServiceError> { // Business logic check: maybe prevent deletion if product is part of an active order // For simplicity, we'll just delete for now. self.repository.delete(id).await? .map_err(|_| ServiceError::NotFound(id.to_string())) // Convert RepositoryError::RecordNotFound to ServiceError::NotFound } pub async fn order_product(&self, product_id: &str, quantity: u32) -> Result<(), ServiceError> { let mut product = self.get_product_by_id(product_id).await?; // Use service method for consistency // Core business logic: Check stock before decrementing if product.stock < quantity { return Err(ServiceError::InsufficientStock(product.name, product.stock, quantity)); } product.stock -= quantity; self.repository.update_stock(&product.id, product.stock).await?; // Use specific update_stock for atomicity if possible Ok(()) } }
마지막으로 Axum과 같은 웹 프레임워크에 연결합니다:
// src/main.rs use axum::{ extract::{Path, State, Json}, routing::{get, post, put, delete}, http::StatusCode, response::IntoResponse, Router, }; use std::sync::Arc; use crate::services::{ProductService, CreateProductDto, UpdateProductDto}; use crate::repositories::InMemoryProductRepository; use crate::errors::ServiceError; use crate::models::Product; mod models; mod repositories; mod services; mod errors; #[tokio::main] async fn main() { let repo = Arc::new(InMemoryProductRepository::new()); let service = ProductService::new(repo); let app = Router::new() .route("/products", get(get_all_products).post(create_product)) .route("/products/:id", get(get_product_by_id).put(update_product).delete(delete_product)) .route("/products/:id/order", post(order_product)) .with_state(Arc::new(service)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on http://0.0.0.0:3000"); axum::serve(listener, app).await.unwrap(); } type AppState = Arc<ProductService<InMemoryProductRepository>>; // HTTP handlers below async fn get_all_products( State(service): State<AppState> ) -> Result<Json<Vec<Product>>, AppError> { Ok(Json(service.get_all_products().await?)) } async fn get_product_by_id( State(service): State<AppState>, Path(id): Path<String>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.get_product_by_id(&id).await?)) } async fn create_product( State(service): State<AppState>, Json(dto): Json<CreateProductDto>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.create_product(dto).await?)) } async fn update_product( State(service): State<AppState>, Path(id): Path<String>, Json(dto): Json<UpdateProductDto>, ) -> Result<Json<Product>, AppError> { Ok(Json(service.update_product(&id, dto).await?)) } async fn delete_product( State(service): State<AppState>, Path(id): Path<String>, ) -> Result<StatusCode, AppError> { service.delete_product(&id).await?; Ok(StatusCode::NO_CONTENT) } async fn order_product( State(service): State<AppState>, Path(id): Path<String>, Json(payload): Json<OrderPayload>, ) -> Result<StatusCode, AppError> { service.order_product(&id, payload.quantity).await?; Ok(StatusCode::OK) } #[derive(serde::Deserialize)] struct OrderPayload { quantity: u32, } // Custom error handling for Axum struct AppError(ServiceError); impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let (status, error_message) = match self.0 { ServiceError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), ServiceError::InvalidData(msg) => (StatusCode::BAD_REQUEST, msg), ServiceError::InsufficientStock(name, available, requested) => { (StatusCode::BAD_REQUEST, format!("Insufficient stock for {}. Available: {}, Requested: {}", name, available, requested)) }, ServiceError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database operation failed".to_string()), // Handle other ServiceErrors accordingly }; (status, Json(serde_json::json!({{"error": error_message}}))).into_response() } } // Enable conversion from ServiceError to AppError impl From<ServiceError> for AppError { fn from(inner: ServiceError) -> Self { AppError(inner) } }
이 구조에서:
- **
ProductService**는Arc<R>를 받으며, 여기서R은ProductRepository를 구현합니다. 이것이 의존성 주입입니다. 리포지토리를 서비스에 주입하고 있습니다. ProductService의create_product및update_product메서드에는 명시적인 비즈니스 유효성 검사(예: 가격 및 재고는 0이 될 수 없음)가 포함되어 있습니다.order_product메서드는 복잡한 비즈니스 규칙을 시연합니다. 즉, 주문을 허용하기 전에 사용 가능한 재고를 확인하는 것입니다. 이 로직은 완전히 서비스 내에 있습니다.main.rs의 HTTP 핸들러는 얇습니다. 요청을 수신하고, 적절한 서비스 메서드를 호출하며, 응답을 형식화하거나 오류를 처리합니다. 비즈니스 관련 로직은 포함하지 않습니다.AppError및 해당IntoResponse구현은 서비스별 오류를 적절한 HTTP 응답으로 변환하는 방법을 보여주며, 오류 처리 관련 문제를 분리하여 유지합니다.
이 접근 방식의 이점:
- 관심사 분리: 비즈니스 로직은 웹 관련 문제(HTTP 처리) 및 데이터 액세스 관련 문제(데이터베이스 상호 작용)와 명확하게 분리됩니다.
- 테스트 용이성: 서비스 메서드는 웹 프레임워크나 실제 데이터베이스에 독립적으로 테스트할 수 있습니다.
ProductService를 단위 테스트하기 위해ProductRepository트레잇을 쉽게 모킹할 수 있습니다. - 유지보수성: 비즈니스 규칙 변경은 서비스 계층에만 영향을 미칩니다. 데이터베이스 변경은 리포지토리 구현에만 영향을 미칩니다.
- 유연성: 데이터베이스 기술을 전환하는 것은 서비스 또는 웹 계층을 건드리지 않고 새로운
ProductRepository를 구현하고 주입하는 것만으로도 가능합니다. - 재사용성: 서비스 계층 내의 비즈니스 로직은 다른 클라이언트(예: 웹 API, CLI 도구, 백그라운드 작업)에 의해 재사용될 수 있습니다.
결론
Rust 웹 프로젝트에서 견고한 서비스 계층을 설계하는 것은 매우 귀중한 아키텍처 관행입니다. 비즈니스 로직을 데이터 액세스 및 HTTP 관련 문제와 분리된 전용 서비스 구조 내에 신중하게 캡슐화함으로써, 본질적으로 더 유지보수 가능하고, 테스트 가능하며, 확장 가능한 애플리케이션을 육성하게 됩니다. 이 접근 방식은 개발을 간소화할 뿐만 아니라 복잡성에 대한 애플리케이션의 탄력성을 강화하여 핵심 비즈니스 규칙이 명확하고 잘 정의된 상태로 유지되도록 합니다. 서비스 계층을 채택하면 Rust 애플리케이션이 고유한 성능 및 정확성 이점을 진정으로 빛낼 수 있으며, 견고하고 이해하기 쉬운 기반 위에 구축됩니다.