RUST 비동기 웹 서비스에서 동기 차단 코드 처리하기
Emily Parker
Product Engineer · Leapcell

소개
현대 웹 개발 세계에서 속도와 응답성은 무엇보다 중요합니다. 사용자들은 애플리케이션이 신속하게 동작하고 눈에 띄는 지연 없이 수많은 요청을 동시에 처리하기를 기대합니다. RUST는 강력한 비동기 기능으로 고성능 웹 서비스를 구축하는 데 강력한 경쟁자로 부상했습니다. Actix-web 및 Tokio와 같은 프레임워크는 개발자가 시스템 리소스를 효율적으로 활용하는 고도로 동시적인 코드를 작성할 수 있도록 지원합니다.
하지만 모든 작업을 비동기적으로 수행하거나 수행해야 하는 것은 아닙니다. 암호화 해싱(예: Argon2 또는 Bcrypt를 사용한 암호 해싱), 복잡한 데이터 처리 또는 레거시 동기 라이브러리와의 상호 작용과 같은 일부 작업은 본질적으로 차단됩니다. 이러한 차단 작업을 비동기 컨텍스트 내에서 직접 실행하면 전체 스레드가 차단되어 다른 모든 동시 작업의 진행을 중단시키고 서비스 성능을 심각하게 저하시킵니다. 이 글에서는 응답성과 효율성을 유지하기 위해 이러한 동기, 차단 작업을 비동기 RUST 웹 서비스에 올바르게 통합하는 중요한 과제에 대해 자세히 살펴보겠습니다.
핵심 개념 이해
해결책을 자세히 살펴보기 전에 관련된 핵심 개념을 명확하게 이해해 봅시다.
- 비동기 프로그래밍: RUST(및 다른 많은 언어)에서 비동기 프로그래밍을 사용하면 프로그램이 새 작업을 시작하기 전에 각 작업이 완료될 때까지 기다리지 않고 많은 작업을 시작할 수 있습니다. 비동기 작업이 I/O 작업(네트워크 요청 또는 디스크 읽기와 같은)을 만나면 스레드를 차단하는 대신 런타임에 제어권을 "양보"하여 다른 작업을 실행할 수 있습니다. I/O 작업이 완료되면 해당 작업을 다시 시작할 수 있습니다. 이는
async/await구문과 작업 스케줄링을 관리하는executor(Tokio와 같은)를 통해 달성됩니다. - 차단 작업: 차단 작업은 실행될 때 완료될 때까지 호출자에게 제어권을 반환하지 않는 작업입니다. 이 시간 동안 작업을 실행하는 스레드는 "차단"되며 다른 작업을 수행할 수 없습니다. 예로는 CPU 바운드 계산(암호 해싱과 같은), 동기 파일 I/O 또는 차단 데이터베이스 호출이 있습니다.
- Tokio 런타임: Tokio는 RUST에서 가장 인기 있는 비동기 런타임입니다. 이벤트 루프, 작업 스케줄러 및 협업 멀티태스킹 도구를 포함하여 비동기 애플리케이션을 구축하는 데 필요한 모든 구성 요소를 제공합니다. 일반적으로 작업 실행을 위해 고정된 수의 작업자 스레드(종종 CPU 코어당 하나)를 사용합니다.
- 스레드 풀: 스레드 풀은 작업을 실행하는 데 사용할 수 있는 사전 생성된 스레드 모음입니다. 모든 작업에 대해 새 스레드를 생성하는 대신, 작업이 풀에 제출되고 사용 가능한 스레드가 이를 가져갑니다. 이렇게 하면 스레드 생성 및 파괴의 오버헤드가 줄어듭니다.
Tokio의 작업자 스레드 중 하나에서 차단 작업을 직접 실행할 때 문제가 발생합니다. 작업자 스레드가 차단되므로 다른 async 작업을 실행할 수 없어 서비스의 동시성 일부를 효과적으로 중단시킵니다.
차단 코드 처리 전략
근본적인 해결책은 차단 작업을 기본 비동기 런타임의 작업자 스레드에서 이동하는 것입니다. 이렇게 하면 기본 이벤트 루프가 차단되지 않는 작업을 예약하고 실행할 수 있습니다.
1. tokio::task::spawn_blocking 사용
Tokio 기반 애플리케이션 내에서 차단 작업을 처리하는 가장 간단하고 권장되는 방법은 tokio::task::spawn_blocking을 사용하는 것입니다. 이 함수는 제공된 차단 future 또는 클로저를 차단 작업 전용으로 Tokio에서 관리하는 동적으로 크기가 조정되는 스레드 풀로 오프로드합니다.
암호 해싱 예제를 사용하여 실제 작동 방식은 다음과 같습니다.
use actix_web::{web, App, HttpServer, HttpResponse, Responder}; use tokio::time::{sleep, Duration}; use argon2::{{password_hash::SaltString, Argon2, PasswordHasher}}; use rand_core::OsRng; // cryptographic random number generator async fn hash_password_handler(password: web::Path<String>) -> impl Responder { let password_str = password.into_inner(); // 이것이 Argon2 암호 해싱과 같이 CPU 집약적인 작업이라고 가정해 봅시다. // 직접 실행하면 Actix-web 작업자 스레드가 차단됩니다. let hashed_password = tokio::task::spawn_blocking(move || {{ let salt = SaltString::generate(&mut OsRng); // Argon2는 강력한 매개변수로도 시간이 걸립니다. let argon2 = Argon2::default(); argon2.hash_password(password_str.as_bytes(), &salt) .map(|hash| hash.to_string()) .expect("Failed to hash password") }}) .await; match hashed_password { Ok(hash) => HttpResponse::Ok().body(format!("Hashed password: {}", hash)), Err(e) => {{ eprintln!("Failed to hash password in blocking thread: {:?}", e); HttpResponse::InternalServerError().body("Failed to process password") }} } } async fn hello() -> impl Responder { // 이것은 비차단 작업입니다. 동시에 실행할 수 있습니다. sleep(Duration::from_millis(100)).await; HttpResponse::Ok().body("Hello world!") } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| {{ App::new() .route("/", web::get().to(hello)) .route("/hash/{password}", web::get().to(hash_password_handler)) }}) .bind(("127.0.0.1", 8080))? // the ip address to listen on .run() .await }
이 예시에서는 다음과 같습니다.
hash_password_handler는async함수이지만 실제 암호 해싱 로직은tokio::task::spawn_blocking에 전달된 클로저 내부에 있습니다.spawn_blocking은JoinHandle을 반환하며await됩니다. 이await지점은 중요합니다.hash_password_handler자체는 다른 스레드에서 해싱이 완료되기를 기다리는 동안 비차단입니다.- 해싱 클로저는 Tokio의 차단 스레드 풀에서 제공하는 전용 스레드에서 실행됩니다. 이는 비동기 런타임의 핵심 작업자 스레드와 별개입니다.
- 순전히 비동기적인
hello엔드포인트는 여러 암호 해싱 요청이 진행 중인 경우에도 계속 빠르게 응답할 수 있습니다.
spawn_blocking을 사용할 때:
- CPU 바운드 계산: 암호 해싱, 이미지 처리, 무거운 데이터 변환.
- 동기 I/O: 레거시 라이브러리 또는 비동기 API를 제공하지 않는 파일과 상호 작용.
- 상당한 시간이 걸리고 명시적으로 제어권을 양보하지 않는 모든 코드.
2. 전용 스레드 풀 (예: rayon)
더 복잡하거나 매우 일반화된 CPU 바운드 워크로드의 경우 rayon과 같은 전용 스레드 풀 라이브러리를 고려할 수 있습니다. Rayon은 CPU 바운드 작업을 병렬화하는 데 탁월한 데이터 병렬 프레임워크를 제공하며 종종 사용자 지정 스레드 관리보다 우수합니다.
rayon 자체는 tokio::task::spawn_blocking과 같은 방식으로 async/await에 직접 통합되어 있지 않지만, 두 가지를 연결할 수 있습니다.
use actix_web::{web, App, HttpServer, HttpResponse, Responder}; use tokio::time::{sleep, Duration}; use argon2::{{password_hash::SaltString, Argon2, PasswordHasher}}; use rand_core::OsRng; use once_cell::sync::Lazy; // 스레드 풀의 지연 정적 초기화를 위해 use rayon::ThreadPoolBuilder; // 집중적인 CPU 작업 전용 전역 Rayon 스레드 풀을 만듭니다. // 애플리케이션의 요구 사항 및 서버 CPU 코어에 따라 스레드 수를 조정합니다. static CPU_POOL: Lazy<rayon::ThreadPool> = Lazy::new(|| {{ ThreadPoolBuilder::new() .num_threads(num_cpus::get()) // 일반적으로 모든 CPU 코어를 사용합니다. .build() .expect("Failed to build Rayon thread pool") }}); async fn hash_password_rayon_handler(password: web::Path<String>) -> impl Responder { let password_str = password.into_inner(); let hashed_password = tokio::task::spawn_blocking(move || {{ // 이제 차단된 Tokio 스레드 내에서 Rayon 풀에 제출할 수 있습니다. CPU_POOL.install(move || {{ let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); argon2.hash_password(password_str.as_bytes(), &salt) .map(|hash| hash.to_string()) .expect("Failed to hash password") }}) }}) .await; match hashed_password { Ok(hash) => HttpResponse::Ok().body(format!("Hashed password (Rayon): {}", hash)), Err(e) => {{ eprintln!("Failed to hash password with Rayon: {:?}", e); HttpResponse::InternalServerError().body("Failed to process password") }} } } // ... 이전과 동일한 async fn hello() 및 main 함수, 새 라우트 추가 ... #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| {{ App::new() .route("/", web::get().to(hello)) .route("/hash/{password}", web::get().to(hash_password_handler)) // spawn_blocking 사용 .route("/hash_rayon/{password}", web::get().to(hash_password_rayon_handler)) // spawn_blocking을 통해 rayon 사용 }}) .bind(("127.0.0.1", 8080))? // the ip address to listen on .run() .await }
이 향상된 예시에서는 다음과 같습니다.
rayon::ThreadPool이 지연 정적 전역으로 생성되어 한 번만 초기화됩니다.hash_password_rayon_handler는 여전히tokio::task::spawn_blocking을 사용합니다. 이것이 중요합니다.rayon::ThreadPool은 자체 구성된 스레드에서 실행됩니다.async함수 내에서 직접CPU_POOL.install을 호출하면 여전히 Tokio 비동기 작업자 스레드가 차단됩니다.CPU_POOL.install은 클로저를 받아 해당 클로저가 Rayon 스레드 중 하나에서 실행되도록 보장합니다. 이것이 실제 CPU 바운드 작업이 발생하는 곳입니다.
Rayon과 같은 전용 스레드 풀을 사용할 때:
- 작은, 독립적인 단위로 분할할 수 있는 고도로 CPU 바운드이며 데이터 집약적인 작업을 병렬화합니다.
- Tokio의 차단 풀과 별개로 특정 CPU 집약적인 워크로드에 전용되는 스레드 수를 더 세밀하게 제어할 때.
- 종종
spawn_blocking과 함께 사용하여 비동기 런타임에서 멀리 떨어진 Rayon의 병렬 계산을 안전하게 실행합니다.
3. 외부 라이브러리 비동기화
때로는 차단 작업이 외부 라이브러리(예: 동기 API만 제공하는 데이터베이스 드라이버)에서 비롯됩니다.
-
래퍼 라이브러리:
async래퍼 또는 라이브러리 포크를 찾으십시오. 예를 들어,sqlx는 RUST를 위한 비동기 ORM으로, 비차단 방식으로 설계되었습니다. 동기diesel연결에서sqlx로 전환하면 데이터베이스 작업이 진정으로 비동기화됩니다. -
수동 오프로드: 비동기 대안이 없는 경우
tokio::task::spawn_blocking을 사용하여 차단 호출을 래핑해야 합니다.// 예: 차단 데이터베이스 호출(가상) async fn fetch_user_blocking(user_id: u32) -> Result<String, String> { let user_data = tokio::task::spawn_blocking(move || {{ // 차단 데이터베이스 호출 시뮬레이션 std::thread::sleep(Duration::from_secs(1)); if user_id == 1 { Ok(format!("User data for ID {}", user_id)) } else { Err("User not found".to_string()) } }}).await; user_data.expect("Blocking task failed").map_err(|e| e.to_string()) }
결론
비동기 RUST 웹 서비스에 동기, 차단 코드를 통합하려면 응답성과 성능을 유지하기 위해 신중한 고려가 필요합니다. 핵심 규칙은 비동기 런타임의 핵심 작업자 스레드에서 결코 차단 작업을 실행하지 않는 것입니다. tokio::task::spawn_blocking을 활용하면 이러한 CPU 바운드 또는 차단 I/O 작업을 Tokio에서 관리하는 별도의 스레드 풀로 효과적으로 오프로드할 수 있습니다. 병렬화 가능한 CPU 바운드 작업의 경우 spawn_blocking과 rayon과 같은 전용 라이브러리를 결합하면 더 세부적인 제어가 가능합니다. 이러한 모범 사례를 따르면 사용자 경험을 저하시키지 않고 모든 유형의 워크로드를 우아하게 처리하는 강력하고 성능 좋은 비동기 RUST 애플리케이션을 구축할 수 있습니다. 서비스의 신속성을 유지하기 위해 항상 차단되는 작업을 오프로드하십시오.

