Rust Async 핸들러에서 Send와 Sync 이해하기
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
Rust에서 async 핸들러를 작성하면서 생산성을 느끼다가 컴파일러 오류로 인해 Send 또는 Sync에 대한 비명을 듣는 상황을 겪어보셨을 것입니다. 이는 Rust의 동시성 모델에 익숙지 않은 개발자, 특히 async/await의 세계로 venturing하는 경우 흔히 겪는 장애물입니다. 이는 단순한 암호화된 오류 메시지가 아니라, Rust의 엄격한 타입 시스템이 동시성 코드의 안전성과 정확성을 보장하는 것입니다. 이러한 트레이트를 무시하거나 잘못 이해하면 미묘하고 디버깅하기 어려운 데이터 경쟁(data race) 또는 교착 상태(deadlock)가 발생할 수 있습니다. 이 글에서는 Send와 Sync의 레이어를 벗겨내고, async 핸들러에 왜 중요한지 설명하며, 강력하고 스레드 안전한 비동기 Rust 코드를 작성하기 위한 실용적인 솔루션을 제공합니다.
Rust 동시성의 기초
async 핸들러의 구체적인 내용으로 들어가기 전에, Rust에서 스레드 안전성의 기반이 되는 핵심 개념인 Send와 Sync에 대한 명확한 이해를 갖추도록 하겠습니다.
Send와 Sync란 무엇인가?
Rust에서 Send와 Sync는 마커 트레이트(marker trait)입니다. 이들은 메서드를 가지고 있지 않으며, 그 목적은 순전히 의미론적으로, 타입의 스레드 안전 속성에 대해 컴파일러에게 알리는 것입니다.
Send: 값이T인 값의 소유권을 한 스레드에서 다른 스레드로 이전하는 것이 안전하다면, 타입T는Send입니다. 대부분의 기본 타입(예:i32,bool),Box<T>와 같은 스마트 포인터(T가Send인 경우), 그리고 많은 컬렉션 타입(T가Send인 경우Vec<T>,HashMap<K, V>)은Send입니다. 반대로, 원시 포인터(*const T,*mut T)는 적절한 동기화 없이 이전하면 역참조 시 데이터 경쟁을 유발할 수 있으므로Send가 아닙니다. 채널과 뮤텍스는 일반적으로 스레드 간의 안전한 통신을 관리하므로Send입니다.Sync: 타입T에 대한 공유 참조(&T)를 여러 스레드에서 안전하게 가질 수 있다면, 타입T는Sync입니다. 이는T에 안전하게 동시적으로 접근할 수 있다는 것을 의미합니다.T가Sync가 되려면&T가Send여야 하며, 이는T에 대한 공유 참조가 다른 스레드로 전송되어 거기서 접근될 수 있음을 내포합니다. 불변 데이터(예:i32또는&str)는Sync입니다.Mutex<T>또는RwLock<T>(T가Send인 경우)와 같은 메커니즘을 통해 내부 가변성을 제공하는 타입도 내부 메커니즘이 안전한 동시 접근을 보장하므로Sync입니다.RefCell<T>와 원시 포인터(*const T,*mut T)는 스레드 안전한 내부 가변성을 제공하지 않으므로Sync가 아닙니다.
Rust의 대부분의 타입은 기본적으로 Send 및 Sync입니다 (impl Trait for T {}). 타입은 Send 또는 Sync가 아닌 필드를 포함하거나 ![derive(Send)] 또는 를 사용하여 명시적으로 옵트아웃하는 경우에만 Send 또는 Sync가 되지 않습니다.
Async에 Send와 Sync가 중요한 이유
퓨처(future)와 실행기(executor)로 구동되는 비동기 Rust는 겉보기에는 단일 스레드 실행처럼 보일 수 있지만, 이러한 트레이트에 크게 의존합니다. async fn 또는 async {} 블록은 Future 트레이트를 구현하는 상태 머신으로 디설거(desugar)됩니다. 이 Future는 실행기에 의해 안전하게 폴링(poll)되어야 합니다.
실행기(예: Tokio 또는 async-std)는 스레드 풀을 관리한다고 가정해 보겠습니다. Future를 spawn하거나, 다른 퓨처를 await하거나, 클로저를 async 블록으로 이동할 때, 실행기는 작업을 분산하기 위해 Future의 상태를 스레드 간에 이동시키거나, .await 호출 후 다른 스레드에서 이를 폴링할 수 있습니다.
Future는Send여야 합니다:Future가Send가 아닌 상태를 포함하는 경우, 실행기는Future의 상태를 스레드 간에 안전하게 이동할 수 없습니다. 이는 작업을 재스케줄링하는 멀티스레드 실행기에 매우 중요합니다.Future자체가Send인 작업을 나타낸다면, 내부 상태는.await지점에서 스레드 간에 이동될 수 있습니다.- 환경 변수 캡처:
async블록이 주변 환경의 변수를 캡처하면, 이러한 변수는Future의 상태 일부가 됩니다. 캡처된 변수가Send가 아니라면,Future자체도Send가 될 수 없습니다.
이것이 컴파일러 오류가 자주 발생하는 지점입니다. async 블록을 생성하고 있으며, 이 블록은 암묵적으로 Send가 아닌(또는 때때로 Sync가 아닌) 무언가를 캡처하여 컴파일러가 스레드 안전성을 보장하지 못하게 합니다.
"Send 안 됨" 오류로 이어지는 일반적인 시나리오
몇 가지 일반적인 함정과 그 해결책을 살펴보겠습니다.
시나리오 1: Rc<T> 또는 RefCell<T> 캡처
Rc<T>(Reference Counted)와 RefCell<T>(interior mutability를 위한 Reference Cell)는 단일 스레드 시나리오를 위해 설계되었습니다. 스레드 안전한 접근 제어를 제공하지 않습니다.
문제 코드:
use std::rc::Rc; use std::cell::RefCell; use tokio::task; #[tokio::main] async fn main() { let counter = Rc::new(RefCell::new(0)); // 오류: `Rc<RefCell<i32>>`는 스레드 간에 안전하게 전송될 수 없습니다 // `RefCell<i32>`는 스레드 간에 안전하게 전송될 수 없습니다 // `Rc<i32>`는 스레드 간에 안전하게 전송될 수 없습니다 let handle = task::spawn(async move { // 이 클로저는 `counter`를 소유권을 이동하여 캡처하고, // 비동기 블록이기 때문에 생성된 Future는 Send여야 합니다. for _ in 0..100 { *counter.borrow_mut() += 1; } println!("Task 내 카운터: {}", *counter.borrow()); }); handle.await.unwrap(); println!("최종 카운터: {}", *counter.borrow()); }
실패 이유: Rc는 단일 스레드 내에서 여러 소유자를 허용합니다. RefCell는 소유자에 mut가 필요하지 않고 단일 스레드 내에서 가변 참조를 허용합니다. 둘 다 멀티스레드 접근을 위한 동기화 메커니즘을 제공하지 않으므로 Send 또는 Sync가 아닙니다. task::spawn 함수는 멀티스레드 실행기를 위해 설계되었으며, 그 퓨처 인수가 Send여야 합니다.
해결책: Arc<T> 및 Mutex<T> / RwLock<T> 사용
멀티스레드 공유 소유권 및 내부 가변성의 경우, 스레드 안전한 해당 객체인 Arc<T>(Atomic Reference Counted) 및 Mutex<T>(Mutual Exclusion Lock) 또는 RwLock<T>(Read-Write Lock)를 사용하십시오.
use std::sync::{Arc, Mutex}; use tokio::task; #[tokio::main] async fn main() { let counter = Arc::new(Mutex::new(0)); // 공유 소유권을 위한 Arc, 내부 가변성을 위한 Mutex let counter_clone = Arc::clone(&counter); // 작업을 위해 Arc 복제 let handle = task::spawn(async move { for _ in 0..100 { // 내부 데이터에 대한 독점적 접근을 얻기 위해 뮤텍스 잠금 let mut num = counter_clone.lock().unwrap(); *num += 1; } println!("Task 내 카운터: {}", *counter_clone.lock().unwrap()); }); handle.await.unwrap(); println!("최종 카운터: {}", *counter.lock().unwrap()); }
여기서 Arc<Mutex<i32>>는 Send입니다. Arc는 스레드 간의 참조 카운팅을 안전하게 처리하고, Mutex는 여러 스레드가 i32 데이터를 수정하려고 할 때도 데이터를 독점적으로 접근하도록 보장하기 때문입니다.
시나리오 2: .await 지점 전반에 걸쳐 비-Send 타입 유지
때로는 Rc와 같은 타입이 문제가 아니라, .await 지점을 가로질러 비동기 블록의 상태에 암묵적으로 캡처되는 임시 비-Send 값이 원인일 수 있습니다. (일반적인 OS 수준의 비-Send 핸들 타입은 관용적인 Rust에서 드물지만, 이 개념은 적용됩니다.)
문제 코드 (개념적):
// 이 예제는 개념적입니다. std::process::Child의 stdin/stdout 핸들은 실제로 Sync이기 때문입니다. // 하지만 어떤 리소스가 *비-Send*였다면 아이디어를 보여줍니다. #[tokio::main] async fn main() { let config = String::from("some_config_data"); // `my_non_send_struct`가 이를 빌린다고 가정하면, 이 블록은 `config`를 캡처할 수 있습니다. let _handle = tokio::spawn(async move { // `MyNonSendType`이 Send가 아닌 타입이라고 가정합니다. // `MyNonSendType` 인스턴스가 여기에 생성되거나 // Send가 아닌 환경에서 무언가를 빌려왔고, await 지점을 만나면... // let some_data = MyNonSendType::new(); // 가상의 비-Send 타입 println!("await 전"); tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!("await 후. Future는 스레드를 이동했을 수 있습니다."); // 만약 'some_data'가 await를 가로질러 캡처되었고 Send가 아니라면, 오류! // some_data.do_something(); }); }
실패 이유 (개념적): async 블록(퓨처로 디설거됨)이 비-Send 변수를 캡처하고 .await 지점을 가로질러 이를 유지하면 컴파일러는 불평할 것입니다. 실행기는 스레드에서 Future를 일시 중지했다가 .await 완료 후 다른 스레드에서 다시 시작할 수 있기 때문입니다. 비-Send 변수가 Future의 상태 일부였다면, 이는 안전하지 않게 스레드 간에 이동될 것입니다.
해결책: 비-Send 변수를 로컬 스코프로 이동시키거나 올바르게 동기화되도록 보장합니다.
- 로컬 스코프로 이동: 비-
Send변수가.await지점 이전 또는 이후에만 필요한 경우, 해당 수명이.await를 가로지르지 않도록 합니다. - 동기화: 비-
Send변수가.await지점을 가로질러, 잠재적으로 다른 스레드에서도 지속되고 접근되어야 하는 경우,Arc<Mutex<T>>또는Arc<RwLock<T>>와 같은 스레드 안전한 기본 형식으로 래핑해야 합니다.
시나리오 3: 클로저 및 Fn vs FnOnce vs FnMut
async 태스크를 스폰할 때 클로저를 전달하는 것이 일반적입니다. move 키워드가 중요합니다.
#[tokio::main] async fn main() { let mut my_data = 10; // 오류: `my_data`는 암묵적으로 참조로 캡처되며, // `my_data`는 Sync하지 않습니다 (가변적이기 때문). // 스폰된 future는 Send+Sync여야 하는 `&mut i32`를 필요로 할 것입니다. // let handle = tokio::spawn(async { // println!("내부 태스크의 데이터: {}", my_data); // my_data += 1; // 이렇게 하면 'my_data'가 `&mut`로 캡처됩니다. // }); // 올바름: `move`를 사용하여 소유권을 이전합니다. let handle = tokio::spawn(async move { println!("내부 태스크의 데이터: {}", my_data); my_data += 1; // 이제 `my_data`는 클로저에 의해 소유됩니다. }); handle.await.unwrap(); // 여기서 my_data에 접근할 수 없습니다. 소유권이 스폰된 태스크로 이동했기 때문입니다. // println!("main의 데이터: {}", my_data); }
중요 이유:
move 없이, my_data는 참조(&mut my_data)로 캡처될 것입니다. 가변 참조 &mut T는 생성된 스레드에서만 유효하며, 스레드 간에 비-Send가 됩니다. async 블록을 spawn할 때, 외부 태스크와 스폰된 태스크는 다른 스레드에서 작동할 수 있습니다.
해결책: move 키워드 사용
async move { ... }에서 move를 사용하면 my_data의 소유권이 Future로 이전됩니다. i32는 Send이므로, my_data를 포함하는 Future도 Send입니다. 원본 스코프에서 접근을 유지하면서 데이터를 공유해야 하는 경우, Arc<Mutex<T>>를 참조하십시오.
for<'a> Future<&'a Context<'a>> 문제
이것은 제네릭 async 코드나 생명주기(lifetime)를 포함하는 트레이트 구현 시 종종 볼 수 있는 더 고급 사례입니다. async 핸들러가 특정 생명주기 'a를 가진 데이터를 빌려야 하고, 그 데이터가 Sync가 아니라면, Future는 Send일 수 없습니다. 이것은 실행기가 Future와 빌린 데이터를 스레드 간에 이동시켜야 하는데, 빌린 데이터를 안전하게 암묵적으로 공유할 수 없을 때 발생합니다.
결론
Send와 Sync 트레이트는 Rust의 스레드 안전 보장의 기본 기둥이며, 비동기 프로그래밍까지 그 영향력을 깊숙이 확장합니다. async 핸들러가 Send 또는 Sync에 대한 오류를 발생시키는 것은 장애물이 아니라, 잠재적인 데이터 경쟁과 미정의 동작을 방지하는 컴파일러의 유용한 경고입니다. async 블록이 실행기에 의해 스레드 간에 이동될 수 있는 Future로 디설거된다는 것을 이해함으로써, 단일 스레드에 해당하는 Rc 및 RefCell 대신 Arc와 Mutex와 같은 스레드 안전한 기본 형식을 언제 사용해야 하는지 올바르게 식별하고 move 키워드를 효과적으로 활용할 수 있습니다. 이러한 핵심 개념을 수용하는 것은 Rust에서 강력하고 고성능이며 진정한 스레드 안전한 비동기 애플리케이션을 작성하는 데 중요합니다. 여러분의 컴파일러는 더 안전한 동시성을 향해 안내하는 친구입니다.

