- Published on
Rust async에서 Send 트레잇 오류 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust로 비동기 코드를 작성하다 보면 어느 순간 컴파일러가 아주 단호하게 막아서는 에러를 만납니다. 대표적으로 future cannot be sent between threads safely 또는 the trait bound ...: Send is not satisfied 같은 메시지입니다. 특히 tokio::spawn 같은 API를 쓰는 순간 갑자기 터지기 때문에, 원인이 내 코드의 어디에 있는지 찾기 어렵습니다.
이 글에서는 Rust async에서 Send 오류가 왜 발생하는지(런타임 스레딩 모델과 Future의 캡처 규칙 관점), 그리고 어떤 패턴으로 해결하는지(타입 교체, 스코프 분리, spawn_local, 락 선택, 채널 분리, async_trait 주의점)까지 한 번에 정리합니다.
Send 오류가 의미하는 것
Rust의 Send는 “값을 다른 스레드로 안전하게 이동(move)할 수 있다”는 마커 트레잇입니다. async에서는 조금 더 구체적으로 이렇게 이해하면 좋습니다.
async fn은 내부적으로Future를 반환합니다.- 그
Future는await지점 사이에 필요한 로컬 변수들을 상태로 “캡처”해서 들고 있습니다. - 멀티스레드 런타임(예: Tokio 기본 멀티스레드 스케줄러)에서 실행되는 작업은 스레드를 옮겨 다닐 수 있습니다.
- 따라서
tokio::spawn같은 함수는 “이Future가 스레드 간 이동 가능(Send)해야 한다”는 제약을 겁니다.
즉, 에러의 본질은 “이 async 작업이 들고 있는(캡처한) 값 중 하나가 Send가 아니라서, 멀티스레드에서 안전하게 실행할 수 없다”입니다.
가장 흔한 에러 형태
다음과 같은 코드에서 자주 발생합니다.
use tokio::task;
async fn do_work() {
// ...
}
#[tokio::main]
async fn main() {
task::spawn(async {
do_work().await;
})
.await
.unwrap();
}
do_work() 내부에서 !Send 타입을 잡고 있거나, await를 가로질러 !Send 값이 살아있으면 spawn 지점에서 컴파일 에러가 납니다.
Tokio의 task::spawn 시그니처는 대략 이런 제약을 가집니다.
F: Future + Send + 'staticF::Output: Send + 'static
여기서 Send와 'static이 핵심입니다. 특히 'static은 “스폰된 작업이 호출자 스코프보다 오래 살 수 있으니, 참조를 들고 있지 말고 소유권으로 가져와라”라는 의미로 자주 등장합니다.
원인 1: Rc, RefCell 등 !Send 타입을 캡처함
싱글스레드에서만 안전한 타입은 대표적으로 Rc, RefCell이 있습니다. 이들은 Send가 아닙니다.
use std::rc::Rc;
#[tokio::main]
async fn main() {
let v = Rc::new(1);
tokio::spawn(async move {
// Rc는 Send가 아니라서 멀티스레드 spawn에서 실패
println!("{}", v);
});
}
해결: Arc, Mutex(또는 RwLock)로 교체
멀티스레드 공유가 필요하면 보통 Arc를 사용합니다. 내부 가변성이 필요하면 Mutex나 RwLock을 함께 씁니다.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let v = Arc::new(Mutex::new(1));
let v2 = v.clone();
tokio::spawn(async move {
let mut guard = v2.lock().await;
*guard += 1;
})
.await
.unwrap();
println!("{}", *v.lock().await);
}
여기서 중요한 실전 팁은 “std::sync::Mutex 대신 tokio::sync::Mutex를 고려하라”입니다. async 컨텍스트에서 std::sync::Mutex는 락 획득 시 스레드를 블로킹할 수 있어 런타임 전체 지연의 원인이 됩니다.
원인 2: await를 가로질러 !Send 값이 살아있음
Send 오류는 단순히 “어떤 값이 !Send다”가 아니라, “그 값이 await 이후에도 살아있다” 때문에 발생하는 경우가 많습니다.
예를 들어, 어떤 라이브러리의 가드(guard) 타입이 !Send인 경우가 있습니다. 대표적으로 일부 동기 락 가드나 특정 FFI 핸들 등이 그렇습니다.
use std::rc::Rc;
async fn bad() {
let rc = Rc::new("hello".to_string());
// rc가 아래 await 이후에도 사용되거나 스코프가 유지되면
// Future가 rc를 상태로 들고 있게 되어 Send 요구사항을 깨뜨릴 수 있음
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", rc);
}
#[tokio::main]
async fn main() {
tokio::spawn(async {
bad().await;
})
.await
.unwrap();
}
해결: await 이전에 !Send 값을 스코프에서 드롭(drop)시키기
핵심은 “await 전에 끝내라”입니다. 즉, !Send 값이 await를 넘지 않도록 스코프를 쪼갭니다.
use std::sync::Arc;
async fn good() {
// Send 타입으로 바꾸는 것이 가장 깔끔하지만,
// 불가피하게 !Send를 써야 한다면 await를 넘기지 않게 구조화
let msg = {
// 이 블록 안에서만 임시 값 사용
let local = Arc::new("hello".to_string());
local.as_str().to_owned()
}; // 여기서 local 드롭
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", msg);
}
실무에서는 “락 가드를 잡은 채로 await하지 않기”가 특히 중요합니다. tokio::sync::MutexGuard는 Send일 수 있어도, 락을 잡은 채 await하면 교착/지연이 생기기 쉽습니다. Send 에러가 아니더라도 성능/안정성 측면에서 동일한 안티패턴입니다.
원인 3: tokio::spawn과 spawn_local의 차이를 모름
Tokio에는 로컬 태스크 개념이 있습니다.
tokio::spawn: 멀티스레드 실행 가능,Future: Send필요tokio::task::spawn_local: 현재 스레드에 고정,Send불필요
만약 Rc 같은 !Send 타입을 꼭 써야 한다면, 멀티스레드 런타임에서 무작정 spawn을 쓰기보다 “로컬 태스크”로 격리하는 방법이 있습니다.
해결: LocalSet과 spawn_local 사용
use std::rc::Rc;
use tokio::task::LocalSet;
#[tokio::main]
async fn main() {
let local = LocalSet::new();
local
.run_until(async {
let v = Rc::new(123);
tokio::task::spawn_local(async move {
// 여기서는 Send가 필요 없음
println!("{}", v);
})
.await
.unwrap();
})
.await;
}
이 패턴은 UI 이벤트 루프, 싱글스레드 액터, 특정 스레드에 붙어야 하는 라이브러리(예: 스레드 로컬 상태 의존)와 함께 쓸 때 유용합니다.
원인 4: async_trait로 만든 트레잇 메서드가 Send를 강제함
async fn을 트레잇에 직접 넣는 것은 최근 Rust에서 점점 개선되고 있지만, 여전히 async_trait 크레이트를 쓰는 코드가 많습니다. 이때 기본 설정이 Send를 요구하는 형태로 확장되는 경우가 흔합니다.
예를 들어 “핸들러 트레잇”을 만들고, 이를 tokio::spawn으로 실행하려고 하면 Send 요구가 전파됩니다.
해결 1: 트레잇 객체/구현체가 Send가 되도록 타입을 정리
- 내부 필드에서
Rc를 제거하고Arc로 변경 RefCell대신Mutex/RwLock또는 메시지 패싱으로 변경
해결 2: 정말 로컬만 필요하면 async_trait의 ?Send 옵션 고려
아래처럼 Send 요구를 끌 수 있습니다(단, 그 작업을 멀티스레드 spawn에 올릴 수 없게 됩니다).
use async_trait::async_trait;
#[async_trait(?Send)]
trait Handler {
async fn handle(&self);
}
이 선택은 “설계 결정”입니다. 서버 사이드에서 워커 풀로 굴릴 작업이라면 대개 Send를 만족시키는 쪽이 장기적으로 안전합니다.
원인 5: 에러 메시지는 spawn에서 나지만, 범인은 더 안쪽에 있음
Send 에러는 보통 tokio::spawn(async move { ... }) 줄에서 터집니다. 하지만 실제 원인은 그 async 블록이 캡처한 값, 또는 그 안에서 호출한 async 함수가 캡처한 값에 있습니다.
디버깅 체크리스트
async move블록이 캡처하는 변수 목록을 확인- 그 변수들의 타입이
Send인지 확인 (Arc인지Rc인지, 동기 가드인지 등) await를 기준으로 “살아있는 변수”를 줄이기(스코프 분리)- 외부 라이브러리 타입이
Send인지 문서/시그니처 확인 - 필요한 경우 작업을
spawn_local로 분리
추가로, 병렬 처리나 리소스 고갈 문제도 함께 발생할 때가 많습니다. 예를 들어 작업을 과도하게 spawn하다 파일 디스크립터가 고갈되면 전혀 다른 장애가 나기도 합니다. 운영 환경에서 태스크 수/소켓 수가 늘어나는 상황을 다룬 글로는 리눅스 Too many open files 해결 - ulimit·systemd·Nginx도 함께 참고할 만합니다.
실전 해결 패턴 모음
패턴 A: !Send 상태는 로컬로, I/O는 워커로 분리
싱글스레드 상태(Rc, 특정 핸들)를 다루는 로컬 태스크와, 네트워크/디스크 I/O 같은 워커 태스크를 채널로 분리합니다.
use tokio::sync::mpsc;
#[derive(Debug)]
struct Job(String);
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(100);
// 워커: Send인 Job만 받게 설계
let worker = tokio::spawn(async move {
while let Some(job) = rx.recv().await {
// 여기서는 멀티스레드 안전한 작업만
println!("processing: {:?}", job);
}
});
tx.send(Job("a".into())).await.unwrap();
tx.send(Job("b".into())).await.unwrap();
drop(tx);
worker.await.unwrap();
}
핵심은 “spawn 경계 밖으로는 Send 가능한 데이터만 넘긴다”입니다.
패턴 B: 블로킹 작업은 spawn_blocking으로 격리
동기 라이브러리 호출(압축, 이미지 처리, 레거시 DB 드라이버 등)을 async 태스크에서 직접 돌리면 런타임 스레드를 막을 수 있습니다. 이때는 tokio::task::spawn_blocking을 고려합니다.
#[tokio::main]
async fn main() {
let handle = tokio::task::spawn_blocking(move || {
// CPU 바운드 또는 블로킹 I/O
let mut sum = 0u64;
for i in 0..50_000_000u64 {
sum += i;
}
sum
});
let sum = handle.await.unwrap();
println!("sum={}", sum);
}
spawn_blocking은 Send 이슈를 “직접 해결”하진 않지만, async 태스크 설계를 올바르게 분리하는 데 큰 도움이 됩니다.
패턴 C: std::sync 대신 tokio::sync를 우선 검토
tokio::sync::Mutex는await기반 락std::sync::Mutex는 스레드 블로킹 락
Send 에러가 났을 때 무조건 tokio::sync로 바꾸는 게 정답은 아니지만, async 컨텍스트에서의 병목과 교착을 피하는 데 유리합니다.
정리: Send는 컴파일러가 알려주는 “스레드 경계” 신호
Rust async에서 Send 오류는 귀찮지만, 사실상 “이 작업을 멀티스레드로 안전하게 실행할 수 없다”는 중요한 설계 피드백입니다. 해결은 보통 아래 중 하나로 귀결됩니다.
Rc/RefCell같은!Send타입을Arc/Mutex로 교체await를 넘겨 살아있는!Send값을 스코프 분리로 제거- 멀티스레드
spawn대신LocalSet과spawn_local로 격리 - 트레잇/
async_trait경계에서Send요구가 전파되는지 점검 spawn경계 밖으로는Send가능한 데이터만 전달하도록 구조화
이 원칙을 잡아두면 Send 에러는 “막막한 컴파일 오류”가 아니라 “경계가 잘못 그어진 설계”를 빠르게 찾아주는 도구가 됩니다.