Published on

Rust async에서 Send/Sync 컴파일 오류 해결

Authors

서버나 워커를 Rust async로 작성하다 보면 어느 순간 컴파일러가 future cannot be sent between threads safely 같은 메시지로 발목을 잡습니다. 특히 Tokio의 멀티스레드 스케줄러에서 tokio::spawn 을 쓰기 시작하면, 평소에는 잘 돌아가던 코드가 갑자기 Send/Sync 제약 때문에 빌드가 깨집니다.

이 글은 “왜 이런 에러가 나는지”를 단순 암기가 아니라, async 변환 규칙과 스레드 스케줄링 관점에서 이해하고, 실무에서 가장 자주 쓰는 해결 패턴을 정리합니다.

  • 에러를 유발하는 전형적인 원인들
  • SendSync 의 의미를 async 문맥에서 해석하는 법
  • tokio::spawnspawn_local 선택 기준
  • Mutex/RwLock/Arc/채널로 구조를 바꾸는 실전 리팩터링
  • !Send 타입(예: Rc, RefCell)을 async에서 다루는 방법

참고로, 운영에서 문제를 “증상 기반 체크리스트”로 빠르게 좁히는 방식이 도움이 되듯, Rust의 타입/트레잇 에러도 패턴으로 분류하면 해결 속도가 크게 빨라집니다. 비슷한 접근으로 장애를 정리한 글로는 AWS ALB 502·504 난사 - 원인별 해결 체크리스트 도 함께 참고할 만합니다.

Rust async에서 Send/Sync 에러가 터지는 진짜 이유

Rust의 async fn 은 내부적으로 “상태 머신”으로 컴파일됩니다. 이 상태 머신은 Future 를 구현하는 익명 타입이 되고, await 지점마다 내부 필드를 들고 있다가 다음 폴링 때 이어서 실행됩니다.

핵심은 다음입니다.

  • tokio::spawn 은 기본적으로 멀티스레드 실행기에서 작업을 스레드 간 이동시킬 수 있습니다.
  • 따라서 tokio::spawn(async move { ... }) 에 들어가는 FutureSend + 'static 이어야 합니다.
  • FutureSend 가 되려면, 그 상태 머신이 들고 있는 모든 캡처 값과 await 를 가로지르는 로컬 값들이 Send 여야 합니다.

즉, “내 코드가 스레드를 직접 만들지 않았는데 왜 스레드 에러가 나지”가 아니라, spawn 이 “스레드 이동 가능한 작업”을 요구하기 때문에 Send 가 강제되는 것입니다.

대표 에러 메시지 패턴

  • future cannot be sent between threads safely
  • the trait bound ...: Send is not satisfied
  • ... is not Sync
  • ... cannot be shared between threads safely

이 메시지는 대개 “어떤 값이 Send 또는 Sync 가 아닌데 async 상태 머신에 포함됐다”는 뜻입니다.

Send vs Sync 를 async 문맥에서 빠르게 구분하기

  • Send: 값을 다른 스레드로 “이동(move)”할 수 있다.
  • Sync: &T 를 여러 스레드에서 동시에 참조해도 안전하다.

async에서 자주 만나는 상황을 기준으로 보면:

  • tokio::spawn 에서 주로 문제 되는 것은 Send 입니다. 스레드 간 이동이 가능해야 하니까요.
  • 전역 공유 상태(static, 글로벌 클라이언트, 공유 캐시 등)를 여러 태스크가 동시에 참조하면 Sync 문제가 섞여 나옵니다.

가장 흔한 원인 5가지와 해결법

1) Rc/RefCell/Cell 같은 !Send 타입을 캡처함

싱글스레드 자료구조인 Rc 와 내부 가변성을 제공하는 RefCell 은 기본적으로 Send 가 아닙니다. 멀티스레드 런타임에서 tokio::spawn 을 쓰면 바로 터집니다.

문제 코드

use std::cell::RefCell;
use std::rc::Rc;

#[tokio::main]
async fn main() {
    let state = Rc::new(RefCell::new(0));

    tokio::spawn(async move {
        *state.borrow_mut() += 1;
    })
    .await
    .unwrap();
}

해결 1: Arc + tokio::sync::Mutex 로 교체

use std::sync::Arc;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    let state = Arc::new(Mutex::new(0));

    let state2 = Arc::clone(&state);
    tokio::spawn(async move {
        let mut guard = state2.lock().await;
        *guard += 1;
    })
    .await
    .unwrap();

    let v = *state.lock().await;
    println!("{v}");
}
  • Arc 는 스레드 안전한 참조 카운팅
  • tokio::sync::Mutex 는 async 친화적 락(락 대기 중 스레드를 막지 않음)

해결 2: 정말 싱글스레드가 목적이면 spawn_localLocalSet

GUI, WASM, 또는 싱글스레드 전제가 강한 라이브러리라면 멀티스레드로 강제하지 않는 편이 낫습니다.

use std::cell::RefCell;
use std::rc::Rc;
use tokio::task::LocalSet;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let local = LocalSet::new();

    local
        .run_until(async {
            let state = Rc::new(RefCell::new(0));
            let state2 = Rc::clone(&state);

            tokio::task::spawn_local(async move {
                *state2.borrow_mut() += 1;
            })
            .await
            .unwrap();

            println!("{}", *state.borrow());
        })
        .await;
}

포인트는 current_thread 런타임과 spawn_local 조합입니다. 이 경우 Send 가 필요 없습니다.

2) std::sync::Mutex 를 잡은 채로 await

이건 Send/Sync 에러뿐 아니라 데드락/성능 문제로도 이어집니다.

  • std::sync::MutexGuardawait 를 가로지르며 들고 있으면 문제가 됩니다.
  • 이유: await 는 태스크를 일시 중단하고, 같은 스레드에서 다른 작업을 돌릴 수 있어야 하는데, 블로킹 락을 들고 있으면 런타임이 막힙니다.

문제 코드(전형적)

use std::sync::{Arc, Mutex};

async fn do_work(shared: Arc<Mutex<u64>>) {
    let mut guard = shared.lock().unwrap();
    *guard += 1;

    // 여기서 await 하면 위험
    some_io().await;
}

async fn some_io() {}

해결: async 락 사용 + 락 범위 최소화

use std::sync::Arc;
use tokio::sync::Mutex;

async fn do_work(shared: Arc<Mutex<u64>>) {
    {
        let mut guard = shared.lock().await;
        *guard += 1;
    } // await 전에 guard drop

    some_io().await;
}

async fn some_io() {}

락을 잡는 구간을 블록으로 감싸 “반드시 await 전에 드롭”되게 만드는 습관이 중요합니다.

3) ThreadRng/비동기 안전하지 않은 핸들을 await 너머로 들고 감

일부 타입은 Send 이지만, 특정 라이프타임/가드 타입이 Send 가 아니거나, await 를 넘기면 상태 머신에 남아 문제가 됩니다.

해결 요령은 간단합니다.

  • await 를 경계로 로컬 변수를 분리
  • await 전에 필요한 값만 추출해서 소유권을 단순화

예시 패턴

async fn handler() {
    let token = make_token();

    // token만 들고 await를 넘긴다
    call_remote(token).await;
}

fn make_token() -> String {
    "abc".to_string()
}

async fn call_remote(_token: String) {}

복잡한 핸들(락 가드, 트랜잭션 가드, borrow 등)을 await 너머로 넘기지 않는 것이 핵심입니다.

4) 에러 타입이 Send 가 아니라서 tokio::spawn 결과를 못 올림

tokio::spawn 의 반환은 JoinHandle<T> 이고, 내부적으로 T: Send + 'static 제약이 걸립니다. 여기서 TResult<_, E> 인데 ESend 가 아니면 실패합니다.

문제 코드

use std::rc::Rc;

#[derive(Debug)]
struct MyError {
    _rc: Rc<u8>,
}

async fn work() -> Result<(), MyError> {
    Err(MyError { _rc: Rc::new(1) })
}

#[tokio::main]
async fn main() {
    let h = tokio::spawn(async move { work().await });
    let _ = h.await;
}

해결: 에러를 Send 로 만들기(대개 Arc 로)

use std::sync::Arc;

#[derive(Debug, Clone)]
struct MyError {
    _arc: Arc<u8>,
}

async fn work() -> Result<(), MyError> {
    Err(MyError { _arc: Arc::new(1) })
}

#[tokio::main]
async fn main() {
    let h = tokio::spawn(async move { work().await });
    let _ = h.await;
}

실무에서는 커스텀 에러에 RcRefCell 같은 것을 넣지 않는 편이 안전합니다. 필요하면 Arc 로 바꾸거나, 스레드 경계를 넘기기 전에 문자열로 평탄화(to_string)하는 것도 방법입니다.

5) 트레잇 객체가 Send/Sync 바운드를 누락함

DI 스타일로 Box<dyn Trait> 를 넘기다 보면 자주 발생합니다.

문제 코드

trait Repo {
    fn name(&self) -> &str;
}

async fn run(repo: Box<dyn Repo>) {
    tokio::spawn(async move {
        println!("{}", repo.name());
    });
}

dyn Repo 는 기본적으로 Send 가 아닙니다.

해결: 트레잇 객체에 바운드 추가

trait Repo: Send + Sync {
    fn name(&self) -> &str;
}

async fn run(repo: Box<dyn Repo>) {
    tokio::spawn(async move {
        println!("{}", repo.name());
    });
}

공유까지 고려하면 Arc<dyn Repo + Send + Sync> 패턴이 더 흔합니다.

use std::sync::Arc;

trait Repo: Send + Sync {
    fn name(&self) -> &str;
}

async fn run(repo: Arc<dyn Repo>) {
    let repo2 = Arc::clone(&repo);
    tokio::spawn(async move {
        println!("{}", repo2.name());
    });
}

디버깅을 빠르게 하는 체크리스트

1) 스폰 지점부터 본다

에러는 대개 tokio::spawn (또는 spawn_blocking) 호출 라인에 붙습니다. 그 라인에서 캡처되는 값들을 나열하고, 다음을 확인합니다.

  • 캡처된 값 타입이 Send 인가
  • await 를 넘기며 살아남는 로컬이 Send 인가
  • 반환 타입(특히 에러)이 Send 인가

2) await 를 경계로 “값의 생존 범위”를 끊는다

가장 실전적인 해결법은 이겁니다.

  • await 전에 필요한 값만 복사/클론/추출
  • 가드/borrow/락은 await 전에 드롭

3) 런타임 선택을 점검한다

  • 멀티스레드가 필요 없으면 #[tokio::main(flavor = "current_thread")]
  • !Send 를 써야만 한다면 LocalSetspawn_local

단, 서버 애플리케이션에서 멀티코어 활용이 목표라면, current_thread 로 “회피”하기보다 Arc/async 락/채널로 구조를 바꾸는 게 장기적으로 안전합니다.

실전 리팩터링 예시: 공유 상태를 채널로 바꾸기

공유 상태를 락으로 둘러싸는 대신, 단일 소유 태스크로 몰아넣고 메시지로만 접근하면 Send/Sync 고민이 크게 줄어듭니다.

use tokio::sync::{mpsc, oneshot};

enum Cmd {
    Incr { reply: oneshot::Sender<u64> },
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);

    tokio::spawn(async move {
        let mut value: u64 = 0;
        while let Some(cmd) = rx.recv().await {
            match cmd {
                Cmd::Incr { reply } => {
                    value += 1;
                    let _ = reply.send(value);
                }
            }
        }
    });

    let (reply_tx, reply_rx) = oneshot::channel();
    tx.send(Cmd::Incr { reply: reply_tx }).await.unwrap();
    let v = reply_rx.await.unwrap();
    println!("{v}");
}

이 패턴은 락 경합을 줄이고, 상태 변경을 한 곳으로 모아 추론을 쉽게 만듭니다.

마무리: 해결 전략을 한 문장으로 요약

Rust async에서 Send/Sync 컴파일 오류는 “스폰된 Future 가 스레드 간 이동 가능한 상태 머신이어야 하는데, 그 안에 !Send 또는 비스레드-안전한 값이 들어갔다”는 신호입니다. 해결은 보통 다음 셋 중 하나로 귀결됩니다.

  • 멀티스레드로 보낼 값은 Arc/async 락/Send + Sync 트레잇 객체로 바꾸기
  • await 경계에서 가드/borrow/락을 끊어서 상태 머신에 남지 않게 하기
  • 정말 싱글스레드 작업이면 current_thread + LocalSet + spawn_local 로 모델링하기

추가로, 동시성 문제는 컴파일 단계에서 잡히는 것과 런타임에서 터지는 것이 섞여 나타납니다. 예를 들어 락을 잘못 잡아 데드락이 나면 컴파일은 되지만 운영에서 장애가 됩니다. DB 데드락을 원인별로 추적하는 관점이 필요하다면 MySQL 8.0 InnoDB 데드락 원인추적·해결 실전 같은 글의 접근도 참고가 됩니다.

원하시면, 실제로 겪고 있는 컴파일 에러 로그(에러 메시지 전체)와 spawn 주변 코드 블록을 붙여주시면, 어떤 값이 !Send 인지와 최소 수정으로 고치는 리팩터링 방향을 함께 짚어드릴게요.