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 애플리케이션이 고유한 성능 및 정확성 이점을 진정으로 빛낼 수 있으며, 견고하고 이해하기 쉬운 기반 위에 구축됩니다.