Rust 웹 서비스에서 강력한 백그라운드 작업 처리 구축
Lukas Schneider
DevOps Engineer · Leapcell

소개
현대의 웹 서비스 환경에서는 동시 요청을 처리하고 신속한 사용자 경험을 제공하는 것이 무엇보다 중요합니다. 하지만 모든 작업이 사용자 요청 수명 주기 동안 즉각적이고 동기적인 실행에 적합한 것은 아닙니다. 매일 뉴스레터 보내기, 복잡한 보고서 생성, 오래된 데이터 주기적으로 정리, 대규모 이미지 업로드 처리 등을 생각해 보세요. 이러한 작업은 종종 오래 실행되거나, 리소스 집약적이거나, 즉각적인 사용자 피드백이 필요하지 않습니다. 이러한 작업을 요청-응답 주기 내에서 직접 실행하면 응답이 느려지고, 시간이 초과되며, 전반적으로 사용자 경험이 저하될 수 있습니다. 바로 여기서 강력한 백그라운드 작업 처리의 필요성이 대두됩니다. 이러한 작업을 오프로드함으로써 웹 서비스는 응답성이 뛰어나고 확장 가능하며 효율적으로 유지될 수 있습니다. 이 글에서는 Rust 기반 웹 서비스에 강력한 스케줄링 메커니즘, 특히 tokio-cron-scheduler
통합 또는 사용자 지정 작업 프로세서 구축을 통해 이러한 백그라운드 작업을 우아하게 관리하는 방법을 자세히 살펴봅니다.
핵심 개념 및 구현
실질적인 내용으로 들어가기 전에 Rust에서 백그라운드 작업 처리의 근간을 이루는 핵심 개념을 명확히 이해해 봅시다.
핵심 용어
- 비동기 프로그래밍: 프로그램이 특정 작업(예: I/O)이 완료될 때까지 기다리는 동안 다른 작업을 실행할 수 있도록 하는 프로그래밍 패러다임입니다. Rust에서는
tokio
를 기반으로 하는async
/await
가 사실상 표준입니다. - 백그라운드 작업/잡: 웹 서비스의 주요 요청-응답 흐름 외부에서 실행되는 작업입니다. 이러한 작업은 일반적으로 별도의 스레드 또는 비동기 컨텍스트에서 실행됩니다.
- 스케줄러: 미리 정의된 일정(예: cron 표현식) 또는 조건에 따라 작업을 시작하는 책임을 맡은 구성 요소입니다.
- 작업 프로세서: 백그라운드 작업을 실제로 실행하는 로직입니다. 이는 간단한
async fn
부터 메시지 큐를 포함하는 복잡한 시스템까지 다양할 수 있습니다. - Cron 표현식: 반복 작업의 일정을 정의하는 데 사용되는 표준 문자열 형식(예:
"0 0 * * *"
)으로, 분, 시, 월 일, 월, 요일을 지정합니다. tokio-cron-scheduler
: 특정 시간 또는 간격으로async
함수를 실행할 수 있도록 하는,tokio
기반의 강력한 cron 스케줄러를 제공하는 Rust 크레이트(crate)입니다.- 메시지 큐 (예: Redis, RabbitMQ): 애플리케이션의 다른 부분 또는 다른 애플리케이션 간에 메시지를 전달하는 시스템입니다. 작업 생산자와 작업 소비자(worker)를 디커플링하고 안정성을 제공하는 데 자주 사용됩니다.
- 작업자 풀 (Worker Pool): 큐에서 작업을 소비하고 실행하는 데 전용으로 할당된 프로세스 또는 스레드 그룹입니다.
백그라운드 작업의 필요성
전자 상거래 플랫폼을 생각해 봅시다. 사용자가 주문을 하면 여러 작업이 필요할 수 있습니다.
- 즉시: 재고 차감, 주문 확인 이메일 전송.
- 백그라운드 (예약/지연): 송장 PDF 생성, 판매 분석 업데이트, 3일 후에 "감사" 이메일 전송, 야간 데이터 백업 처리.
주문 시 이러한 모든 작업을 동기적으로 실행하면 주문 확인이 매우 느려집니다. 중요하지 않은 작업을 백그라운드 작업으로 오프로드하면 사용자는 즉각적인 확인을 받는 동시에 시스템은 다른 작업을 비동기적으로 처리합니다.
옵션 1: tokio-cron-scheduler
통합
특정, 반복되는 간격(예: 일일 보고서 또는 시간별 데이터 동기화)으로 실행해야 하는 작업의 경우 tokio-cron-scheduler
는 훌륭한 선택입니다. tokio
의 비동기 런타임을 활용하여 효율적이고 차단되지 않습니다.
먼저 Cargo.toml
에 필요한 종속성을 추가하십시오.
[dependencies] tokio = { version = "1", features = ["full"] } tokio-cron-scheduler = "0.7" chrono = { version = "0.4", features = ["serde"] } # 시간 관련 작업용 anyhow = "1.0" # 오류 처리용
이제 간단한 actix-web
또는 warp
서비스에 통합해 보겠습니다 (웹 프레임워크 선택은 스케줄러 통합의 기본을 바꾸지 않습니다).
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio::time::{sleep, Duration}; use anyhow::Result; use chrono::Local; // --- 예제 웹 서비스 (설명을 위해 Actix-Web 사용) --- use actix_web::{get, App, HttpServer, Responder}; #[get("/")] async fn hello() -> impl Responder { "Hello from our web service!" } // --- 스케줄러 및 백그라운드 작업 로직 --- async fn daily_report_task() { let now = Local::now(); println!("Running daily report at: {}", now); // 약간의 작업 시뮬레이션 sleep(Duration::from_secs(3)).await; println!("Daily report finished at: {}", Local::now()); } async fn hourly_cleanup_task() { let now = Local::now(); println!("Running hourly cleanup at: {}", now); // 약간의 작업 시뮬레이션 sleep(Duration::from_secs(1)).await; println!("Hourly cleanup finished at: {}", Local::now()); } async fn setup_scheduler() -> Result<JobScheduler> { let sched = JobScheduler::new().await?; sched.start().await?; // 매일 오전 2시에 실행되도록 작업 예약 // Cron 문자열: 분 시 월_일 월 요일 // "0 0 2 * * *"는 0분, 0초, 오전 2시, 매일, 매월, 매주를 의미합니다. let daily_job = Job::new("0 0 2 * * *", |_uuid, _l| { Box::pin(async move { daily_report_task().await; }) })?; sched.add(daily_job).await?; println!("Scheduled daily report for 2 AM."); // 매시간 30분에 실행되도록 작업 예약 let hourly_job = Job::new("0 30 * * * *", |_uuid, _l| { Box::pin(async move { hourly_cleanup_task().await; }) })?; sched.add(hourly_job).await?; println!("Scheduled hourly cleanup for minute 30 past every hour."); Ok(sched) } #[tokio::main] async fn main() -> Result<()> { // 스케줄러 초기화 let _scheduler = setup_scheduler().await?; // 스케줄러를 계속 실행 // 웹 서버 시작 println!("Starting web server on http://127.0.0.1:8080"); HttpServer::new(|| { App::new().service(hello) }) .bind("127.0.0.1:8080")?; .run() .await?; Ok(()) }
이 예제에서는 JobScheduler
를 초기화하고 일일 보고서와 시간별 정리라는 두 개의 작업을 추가합니다. _scheduler
변수는 중요합니다. 범위를 벗어나면 스케줄러가 중지됩니다. tokio-cron-scheduler
는 actix-web
서버를 차단하지 않고 이러한 작업을 백그라운드에서 실행합니다. 이는 시간 기반 반복 작업에 이상적입니다.
옵션 2: 사용자 지정 작업 프로세서 (메시지 큐 기반)
이벤트(예: 사용자 등록, 파일 업로드)에 의해 트리거되거나 재시도, 데드 편지 큐, 분산 처리와 같은 보다 강력한 처리 기능이 필요한 작업의 경우, 사용자 지정 작업 프로세서와 메시지 큐를 사용하는 것이 더 적합합니다.
일반적인 아키텍처는 다음과 같습니다.
- 생산자 (Producer): 메시지 큐에 작업 메시지를 게시하는 웹 서비스입니다.
- 메시지 큐: 메시지를 안정적으로 저장하는 브로커(예: Redis, Kafka, RabbitMQ)입니다.
- 소비자/작업자 (Consumer/Worker): 메시지 큐를 수신 대기하고 작업을 가져와 실행하는 별도의 프로세스 또는 스레드 풀입니다. 이 작업자는 동일한 애플리케이션 프로세스 내에 있거나 완전히 별도의 마이크로서비스일 수 있습니다.
간단하게 하기 위해 메시지 큐를 대신하는 기본 인메모리 채널을 사용하여 설명한 다음, 이를 실제 메시지 큐로 확장하는 방법을 논의하겠습니다.
use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; use anyhow::Result; use chrono::Local; use std::sync::Arc; // --- 예제 웹 서비스 (설명을 위해 Actix-Web 사용) --- use actix_web::{post, web, App, HttpServer, Responder, HttpResponse}; // 백그라운드 작업 메시지 정의 #[derive(Debug, serde::Serialize, serde::Deserialize)] enum BackgroundTask { ProcessImage { url: String, user_id: u32 }, SendWelcomeEmail { email: String, username: String }, // 더 많은 작업 유형 } // 작업을 게시하기 위한 전역 송신자 struct AppState { task_sender: mpsc::Sender<BackgroundTask>, } #[post("/process_image")] async fn process_image_endpoint( data: web::Data<AppState>, info: web::Json<serde_json::Value>, // 이미지 업로드 정보 시뮬레이션 ) -> impl Responder { let url = info["url"].as_str().unwrap_or("unknown").to_string(); let user_id = info["user_id"].as_u64().unwrap_or(0) as u32; let task = BackgroundTask::ProcessImage { url, user_id }; match data.task_sender.send(task).await { Ok(_) => HttpResponse::Accepted().body("Image processing task sent!"), Err(e) => { eprintln!("Failed to send task: {:?}", e); HttpResponse::InternalServerError().body("Failed to send image processing task") } } } #[post("/send_welcome_email")] async fn send_welcome_email_endpoint( data: web::Data<AppState>, info: web::Json<serde_json::Value>, // 사용자 등록 정보 시뮬레이션 ) -> impl Responder { let email = info["email"].as_str().unwrap_or("").to_string(); let username = info["username"].as_str().unwrap_or("").to_string(); let task = BackgroundTask::SendWelcomeEmail { email, username }; match data.task_sender.send(task).await { Ok(_) => HttpResponse::Accepted().body("Welcome email task sent!"), Err(e) => { eprintln!("Failed to send task: {:?}", e); HttpResponse::InternalServerError().body("Failed to send welcome email task") } } } // --- 사용자 지정 작업 프로세서 (작업자) 로직 --- // #[tokio::main]으로 인해 중복됨. 이 함수는 웹 서버와 분리되어야 함. // async fn task_worker(mut receiver: mpsc::Receiver<BackgroundTask>, worker_id: u32) { // println!("[Worker {}] Starting...", worker_id); // while let Some(task) = receiver.recv().await { // let now = Local::now(); // println!("[Worker {}] Received task: {:?} at {}", worker_id, task, now); // match task { // BackgroundTask::ProcessImage { url, user_id } => { // // 이미지 처리 시뮬레이션 // sleep(Duration::from_secs(5)).await; // println!("[Worker {}] Processed image {} for user {}", worker_id, url, user_id); // } // BackgroundTask::SendWelcomeEmail { email, username } => { // // 이메일 전송 시뮬레이션 // sleep(Duration::from_secs(2)).await; // println!("[Worker {}] Sent welcome email to {} for user {}", worker_id, email, username); // } // } // println!("[Worker {}] Task finished at {}", worker_id, Local::now()); // } // println!("[Worker {}] Shutting down...", worker_id); // } #[tokio::main] async fn main() -> Result<()> { // MPSC 채널 생성 let (tx, rx) = mpsc::channel::<BackgroundTask>(100); // 100 버퍼 크기 // 여러 작업자가 메시지를 처리하도록 작업(task) 스폰 // mpsc::channel은 단일 소비자이므로, 여러 소비자가 동일한 채널 수신자를 공유하려면 Mutex를 사용해야 합니다. let shared_rx = Arc::new(tokio::sync::Mutex::new(rx)); for i in 0..3 { // 3개의 작업자 스레드 let receiver_clone = Arc::clone(&shared_rx); tokio::spawn(async move { let mut receiver_guard = receiver_clone.lock().await; while let Some(task) = receiver_guard.recv().await { let now = Local::now(); println!("[Worker {}] Received task: {:?} at {}", i, task, now); match task { BackgroundTask::ProcessImage { url, user_id } => { // 이미지 처리 시뮬레이션 sleep(Duration::from_secs(5)).await; println!("[Worker {}] Processed image {} for user {}", i, url, user_id); } BackgroundTask::SendWelcomeEmail { email, username } => { // 이메일 전송 시뮬레이션 sleep(Duration::from_secs(2)).await; println!("[Worker {}] Sent welcome email to {} for user {}", i, email, username); } } println!("[Worker {}] Task finished at {}", i, Local::now()); } println!("[Worker {}] Shutting down...", i); }); } let app_state = web::Data::new(AppState { task_sender: tx }); println!("Starting web server on http://127.0.0.1:8080"); HttpServer::new(move || { App::new() .app_data(app_state.clone()) // 핸들러 간에 송신자 공유 .service(process_image_endpoint) .service(send_welcome_email_endpoint) }) .bind("127.0.0.1:8080")?; .run() .await?; Ok(()) }
mpsc::channel
의 다중 작업자 사용에 대한 수정: mpsc::channel
(multi-producer, single-consumer)은 Tokio에서 단일 Receiver
만 존재할 수 있음을 의미합니다. 여러 작업자가 공유 풀에서 작업을 가져오려면 다음 중 하나가 필요합니다.
- 브로드캐스트 채널 (예:
tokio::sync::broadcast
): 모든 작업자가 모든 메시지를 처리해야 하는 경우(팬아웃) 이상적입니다. 작업 큐에는 일반적이지 않습니다. - Mutex로 보호되는 공유
mpsc::Receiver
: 각 작업자는 Mutex를 잠그고, 메시지를 하나 가져오고, 잠금을 해제하고, 처리합니다. 이는 메시지 검색을 직렬화하지만 동시 처리를 허용합니다. 이는 수정된 예제에 구현되어 있습니다. - 실제 메시지 큐 (Redis, RabbitMQ, Kafka 등): 이러한 시스템은 여러 소비자가 큐에서 고유한 작업을 안전하게 가져오도록 설계되었습니다.
실제 메시지 큐로 확장하기:
mpsc::channel
대신 선택한 메시지 큐의 클라이언트 라이브러리를 사용합니다.
- Redis:
redis-rs
및 작업 큐용 사용자 지정 스트림 또는BLPOP
을 사용합니다. - RabbitMQ: AMQP용
lapin
을 사용합니다. - Kafka: 고처리량 메시징용
rdkafka
를 사용합니다.
핵심 아이디어는 동일하게 유지됩니다.
- 웹 핸들러(
process_image_endpoint
,send_welcome_email_endpoint
)는 생산자가 되어BackgroundTask
메시지를 (예: JSON으로) 직렬화하고 큐로 푸시합니다. task_worker
함수는 소비자가 되어 큐에 연결하고, 메시지를 역직렬화하고, 해당 로직을 실행합니다. 이 작업자는 동일한 프로세스 내에서 실행될 수도 있고, 더 일반적으로는 별도의 전용 작업자 애플리케이션에서 실행될 수도 있습니다.
이 사용자 지정 작업 프로세서 접근 방식은 엄청난 유연성을 제공합니다.
- 디커플링: 웹 서비스는 작업을 어떻게 처리하는지 신경 쓰지 않고, 단지 전송된다는 것만 알면 됩니다.
- 확장성: 웹 서비스 인스턴스와 독립적으로 더 많은 작업자 인스턴스를 추가할 수 있습니다.
- 안정성: 메시지 큐는 영속성, 재시도 및 데드 편지 큐를 제공하여 작업이 손실되지 않도록 보장합니다.
- 복잡한 워크플로: 처리 파이프라인 및 서비스 간 통신을 가능하게 합니다.
스케줄러와 사용자 지정 프로세서 선택
tokio-cron-scheduler
: 고정 시간 간격(예: 일일 백업, 야간 조정)으로 실행되는 예약된 반복 작업에 가장 적합합니다. 이러한 특정 사용 사례에 대한 설정이 간단합니다.- 사용자 지정 작업 프로세서 (메시지 큐): 이벤트 기반, 임시 또는 안정성, 확장성 및 느슨한 결합이 필요한 장기 실행 작업(예: 이미지 처리, 이메일 전송, 사용자 작업에 의해 트리거되는 복잡한 보고서 생성)에 가장 적합합니다. 외부 종속성(메시지 큐)으로 인해 설정이 더 복잡합니다.
실제 애플리케이션에서는 이 둘을 모두 사용할 수 있습니다. tokio-cron-scheduler
는 주기적인 유지 관리를 위해, 메시지 큐 시스템은 이벤트 트리거 백그라운드 작업을 위해 사용합니다.
결론
Rust 웹 서비스에 백그라운드 작업 처리를 통합하는 것은 확장 가능하고 응답성이 뛰어나며 강력한 애플리케이션을 구축하는 데 중요한 단계입니다. 시간 기반 반복 작업을 위해 tokio-cron-scheduler
의 단순성을 선택하든, 이벤트 기반 및 탄력적인 워크플로를 위해 메시지 큐를 기반으로 하는 사용자 지정 작업 프로세서의 성능과 유연성을 활용하든, Rust의 비동기 기능은 훌륭한 기반을 제공합니다. 리소스 집약적이고 중요하지 않은 작업을 오프로드함으로써 웹 서비스는 가장 잘하는 일, 즉 즉각적인 사용자 요청을 효율적으로 처리하는 데 집중할 수 있으며, 동시에 모든 필요한 백그라운드 작업이 안정적으로 완료되도록 보장합니다.