Skip to content
Published on

Rust 비동기: async/await와 Tokio 런타임

Authors

들어가며 — Rust의 async는 왜 다른가

많은 언어에서 비동기는 런타임에 내장되어 있습니다. 자바스크립트에는 이벤트 루프가 처음부터 돌고 있고, Go에는 고루틴을 스케줄링하는 런타임이 기본으로 딸려 옵니다. 하지만 Rust는 다릅니다. Rust는 async 문법을 언어 차원에서 제공하되, 그것을 실제로 굴리는 런타임은 표준 라이브러리에 넣지 않았습니다. 런타임은 별도의 크레이트(주로 Tokio)로 골라 쓰는 구조입니다.

이 선택이 처음에는 낯설지만, 여기엔 이유가 있습니다. Rust는 임베디드 기기부터 대규모 서버까지 두루 쓰이는데, 모든 상황에 맞는 단일 런타임은 존재하지 않습니다. 그래서 언어는 최소한의 뼈대(Future 트레이트와 async/await 문법)만 제공하고, 스케줄링 정책은 사용자가 고르게 합니다.

이 글은 그 뼈대부터 시작합니다. Future 가 무엇인지, .await 가 실제로 무슨 일을 하는지, 그 위에서 Tokio가 어떻게 태스크를 굴리는지, 그리고 실무에서 반드시 부딪히는 함정들(Send 제약, 블로킹 코드)까지 예제로 짚습니다.

Future는 게으른 상태 기계다

Rust에서 async fn 을 호출하면 함수 본문이 즉시 실행될 것 같지만, 아무 일도 일어나지 않습니다. async fn 은 호출 시점에 본문을 실행하는 대신, Future 라는 값을 하나 만들어 돌려줄 뿐입니다. 이 Future 는 "아직 완료되지 않은 계산"을 나타내는 값입니다.

async fn say_hello() {
    println!("hello");
}

fn main() {
    let fut = say_hello(); // 여기서는 "hello"가 출력되지 않는다!
    // fut는 그저 Future 값일 뿐, 아직 실행되지 않았다
}

이것이 Rust async의 핵심 성질, **게으름(laziness)**입니다. Future는 누군가가 그것을 **폴링(poll)**해 주기 전까지는 한 발짝도 나아가지 않습니다. 폴링이란 "지금 진행할 수 있니?"라고 물어보는 것입니다. Future는 이 물음에 두 가지로 답합니다. 계산이 끝났으면 Poll::Ready(값) 을, 아직 기다릴 게 있으면 Poll::Pending 을 돌려줍니다.

Future 트레이트의 뼈대는 대략 이렇게 생겼습니다.

use std::pin::Pin;
use std::task::{Context, Poll};

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

컴파일러는 async 블록을 **상태 기계(state machine)**로 변환합니다. 각 .await 지점이 하나의 상태가 되고, 폴링될 때마다 이전에 멈췄던 지점에서 재개해 다음 .await 까지 진행합니다. 이 상태 기계 덕분에 스택을 쓰지 않고도 함수의 중간 상태를 보존할 수 있고, 그래서 Rust의 async는 힙 할당 없이도 동작할 만큼 가볍습니다.

.await — 중간에 양보하는 지점

.await 는 다른 Future가 완료되기를 기다리는 지점입니다. 하지만 "기다린다"고 해서 스레드를 붙잡고 멈춰 서는 것이 아닙니다. .await 의 진짜 의미는 **"이 Future가 아직 Pending이면, 제어권을 실행기에 양보하고 다른 일을 하게 하라"**입니다.

async fn fetch_and_process() {
    let data = fetch_data().await;     // 여기서 대기 중이면 다른 태스크에 양보
    let result = process(data).await;  // 완료되면 이어서 진행
    println!("{result}");
}

fetch_data().await 에서 데이터가 아직 준비되지 않았다면, 이 함수는 그 자리에서 멈추고 제어권을 놓습니다. 그러면 실행기는 그 사이 다른 태스크를 굴립니다. 나중에 데이터가 준비되면 실행기가 이 Future를 다시 폴링하고, 함수는 멈췄던 .await 지점에서 재개합니다. 이 협력적 양보가 하나의 스레드에서 수많은 동시 작업을 처리할 수 있게 하는 비결입니다.

여기서 중요한 규칙 하나. .awaitasync 문맥(async fn 이나 async 블록) 안에서만 쓸 수 있습니다. 일반 동기 함수에서는 Future를 .await 할 수 없습니다. 그렇다면 최초의 Future는 누가 폴링을 시작할까요? 바로 런타임입니다.

실행기와 런타임 — Tokio

Future는 게으르므로, 누군가 폴링을 시작하고 계속 굴려 줘야 실제로 실행됩니다. 그 역할을 하는 것이 **실행기(executor)**이고, 실행기에 타이머·네트워크 I/O 같은 부가 기능을 더해 완성한 것이 **런타임(runtime)**입니다. Rust 생태계에서 사실상 표준은 Tokio입니다.

가장 간단한 시작은 #[tokio::main] 매크로입니다. 이 매크로가 main 함수를 감싸 런타임을 세우고, 여러분의 최상위 Future를 그 위에서 폴링해 완료할 때까지 굴립니다.

#[tokio::main]
async fn main() {
    println!("start");
    say_hello().await; // 이제 실제로 실행된다
    println!("end");
}

async fn say_hello() {
    println!("hello");
}

#[tokio::main] 은 사실 다음과 같은 코드로 펼쳐집니다. 동기 main 이 런타임을 만들고, block_on 으로 async 블록을 완료될 때까지 굴리는 것입니다.

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async {
            println!("start");
            say_hello().await;
            println!("end");
        });
}

Tokio는 기본적으로 멀티스레드 스케줄러를 씁니다. CPU 코어 수만큼 워커 스레드를 두고, 태스크들을 그 위에 분배해 실행합니다. 한 워커가 놀고 있으면 다른 워커의 태스크를 훔쳐 오는 작업 훔치기(work-stealing) 방식으로 부하를 고르게 나눕니다. 가벼운 단일 스레드 런타임이 필요하면 #[tokio::main(flavor = "current_thread")] 로 바꿀 수도 있습니다.

태스크와 spawn — 동시성의 단위

지금까지는 하나의 Future를 순차적으로 .await 했습니다. 하지만 비동기의 진짜 힘은 여러 작업을 동시에 굴릴 때 나옵니다. 그 단위가 **태스크(task)**이고, tokio::spawn 으로 만듭니다.

tokio::spawn 은 Future를 받아 런타임에 독립적인 태스크로 등록합니다. 이 태스크는 즉시 백그라운드에서 실행되기 시작하며, spawn 은 그 태스크의 핸들(JoinHandle)을 곧바로 돌려줍니다. 태스크는 운영체제 스레드보다 훨씬 가벼워서, 수만 개를 띄워도 부담이 적습니다.

#[tokio::main]
async fn main() {
    let mut handles = vec![];

    for i in 0..5 {
        let handle = tokio::spawn(async move {
            // 각 태스크가 독립적으로, 동시에 실행된다
            some_async_work(i).await
        });
        handles.push(handle);
    }

    // 모든 태스크가 끝나길 기다린다
    for handle in handles {
        let result = handle.await.unwrap();
        println!("got: {result}");
    }
}

async fn some_async_work(i: u32) -> u32 {
    i * 2
}

spawn 한 태스크는 부모가 .await 하지 않아도 알아서 진행되지만, 결과를 받으려면 JoinHandle.await 하면 됩니다. 태스크가 패닉했다면 .await 결과가 Err 로 돌아오므로 여기서 확인할 수 있습니다.

메시지 큐나 워커 풀처럼 여러 생산자·소비자가 태스크로 오가며 통신하는 구조가 궁금하다면, 이 사이트의 메시지 큐 플레이그라운드에서 큐의 동작을 시각적으로 실험해 볼 수 있습니다. 비동기 태스크들이 채널을 통해 일을 주고받는 그림을 직관적으로 이해하는 데 도움이 됩니다.

Send와 정적 수명 — spawn의 제약

tokio::spawn 에 넘기는 Future에는 두 가지 제약이 붙습니다. 시그니처를 보면 이유가 드러납니다. 넘기는 Future는 Send 여야 하고 'static 수명이어야 합니다.

Send 제약부터 봅시다. 멀티스레드 런타임에서 태스크는 어느 워커 스레드에서든 실행될 수 있고, 심지어 .await 를 사이에 두고 다른 스레드로 옮겨질 수도 있습니다. 그러려면 Future가 담고 있는 모든 값이 스레드 간에 안전하게 이동할 수 있어야, 즉 Send 여야 합니다. 그래서 .await 를 가로질러 Send 가 아닌 값을 붙들고 있으면 컴파일 에러가 납니다. 대표적인 예가 앞선 스마트 포인터 글에서 본 Rc 입니다.

use std::rc::Rc;

async fn bad() {
    let data = Rc::new(5); // Rc는 Send가 아니다
    some_async_work().await; // 에러: .await를 가로질러 Rc를 붙들고 있음
    println!("{}", data);
}

이 경우 해결책은 Rc 대신 Arc 를 쓰는 것입니다. 또는 Send 아닌 값을 .await 이전에 스코프에서 떨궈, .await 지점을 가로지르지 않게 하는 방법도 있습니다.

'static 제약은 태스크의 수명이 부모 스코프보다 길어질 수 있기 때문입니다. spawn 한 태스크는 부모가 이미 반환한 뒤에도 살아 있을 수 있으므로, 부모 스코프의 참조를 빌려 가면 위험합니다. 그래서 Future는 빌린 참조가 아니라 값을 소유해야 합니다. 실무에서 이는 클로저에 move 를 붙이고, 필요한 데이터를 Arc 로 공유하는 형태로 흔히 해결됩니다.

비동기 트레이트 — async fn in traits

한동안 Rust에서 트레이트 안에 async fn 을 직접 두는 것은 불가능했습니다. async fn 이 반환하는 Future의 크기를 트레이트 수준에서 알 수 없었기 때문입니다. 그래서 오랫동안 async-trait 라는 크레이트가 이 틈을 메웠습니다. 이 크레이트는 매크로로 반환 타입을 Box<dyn Future> 로 바꿔, 힙 할당을 감수하는 대신 트레이트에 비동기 메서드를 넣을 수 있게 해 줍니다.

use async_trait::async_trait;

#[async_trait]
trait Repository {
    async fn find(&self, id: u64) -> Option<String>;
}

struct MyRepo;

#[async_trait]
impl Repository for MyRepo {
    async fn find(&self, id: u64) -> Option<String> {
        // 실제로는 DB를 비동기로 조회
        Some(format!("item {id}"))
    }
}

좋은 소식은, 비교적 최근의 Rust에서 트레이트 안의 async fn 이 언어 차원에서 지원되기 시작했다는 점입니다. 이제 많은 경우 async-trait 매크로 없이도 트레이트에 async fn 을 직접 쓸 수 있습니다. 다만 트레이트 객체(dyn)로 쓰거나 반환 Future에 Send 경계를 다는 등 일부 세부에서는 여전히 신경 쓸 부분이 있어, 프레임워크에 따라 async-trait 이 계속 쓰이기도 합니다.

select! — 여러 Future 중 먼저 끝나는 것

여러 Future를 동시에 기다리다가 가장 먼저 끝나는 하나에 반응하고 싶을 때가 있습니다. 대표적으로 "작업을 기다리되, 타임아웃이 먼저 오면 취소"하는 경우입니다. 이럴 때 tokio::select! 매크로를 씁니다.

select! 는 나열된 여러 Future를 동시에 폴링하다가, 그중 하나가 먼저 완료되면 그 가지를 실행하고 나머지는 취소합니다.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        result = do_work() => {
            println!("작업 완료: {result}");
        }
        _ = sleep(Duration::from_secs(5)) => {
            println!("타임아웃! 작업을 포기한다");
        }
    }
}

async fn do_work() -> u32 {
    sleep(Duration::from_secs(10)).await;
    42
}

위 예에서 do_work 는 10초가 걸리지만 타임아웃은 5초이므로, 5초 뒤 타이머 가지가 먼저 완료되어 그쪽이 실행되고 do_work 는 취소됩니다. 여기서 Rust async의 중요한 성질이 드러납니다. Future를 폴링하지 않으면 그냥 취소된다는 점입니다. 진행 중이던 do_work 는 그저 더 이상 폴링되지 않아 자연스럽게 버려집니다. 별도의 취소 신호를 보낼 필요가 없습니다.

select! 를 루프 안에서 쓰면, 여러 이벤트 소스(채널 수신, 타이머, 종료 신호 등)를 하나의 루프에서 동시에 감시하는 이벤트 루프를 만들 수 있습니다. 이것이 비동기 서버의 심장부에서 흔히 보이는 패턴입니다.

블로킹 vs 비동기 — 섞으면 안 되는 이유

비동기 프로그래밍에서 가장 흔하고 치명적인 실수는, 비동기 문맥 안에서 블로킹 코드를 호출하는 것입니다. 여기서 "블로킹"이란 CPU를 오래 쓰는 무거운 계산이나, std::thread::sleep, 동기 파일 I/O, 동기 네트워크 호출처럼 스레드를 실제로 멈춰 세우는 작업을 말합니다.

왜 위험할까요? 비동기 태스크는 워커 스레드를 잠깐 빌려서 폴링되고, .await 에서 양보하며 스레드를 놓아 줍니다. 그런데 태스크가 블로킹 호출을 하면 양보하지 않고 워커 스레드를 통째로 붙잡아 버립니다. 그 워커에 배정된 다른 모든 태스크는 그동안 전혀 진행되지 못하고 굶습니다. 최악의 경우 몇 개의 블로킹 태스크가 모든 워커를 점유해 런타임 전체가 멈춥니다.

// 나쁜 예: 비동기 문맥에서 스레드를 통째로 블로킹
async fn bad() {
    std::thread::sleep(std::time::Duration::from_secs(5)); // 워커 스레드를 5초간 붙잡음!
}

// 좋은 예: 비동기 타이머로 양보
async fn good() {
    tokio::time::sleep(std::time::Duration::from_secs(5)).await; // 스레드를 놓아 준다
}

정리하면 두 가지 원칙입니다. 첫째, 잠들거나 기다리는 일은 반드시 비동기 버전을 쓰세요. std::thread::sleep 대신 tokio::time::sleep, 동기 HTTP 대신 비동기 HTTP 클라이언트처럼요. 둘째, CPU를 오래 쓰는 무거운 계산이나 어쩔 수 없이 동기인 라이브러리는 tokio::task::spawn_blocking 으로 전용 블로킹 스레드 풀에 떼어 놓으세요. 이렇게 하면 무거운 일이 async 워커를 굶기지 않습니다.

async fn heavy() {
    let result = tokio::task::spawn_blocking(|| {
        // CPU를 오래 쓰는 계산이나 동기 I/O를 여기서
        expensive_sync_computation()
    })
    .await
    .unwrap();
    println!("{result}");
}

fn expensive_sync_computation() -> u64 {
    (0..1_000_000).sum()
}

마치며

Rust의 async는 처음엔 낯설지만, 몇 가지 핵심만 잡으면 일관된 그림으로 이어집니다.

  • async fn게으른 Future를 만들 뿐, 폴링되기 전엔 아무 일도 하지 않습니다.
  • .await 는 스레드를 붙잡는 대신 제어권을 양보하는 지점입니다.
  • Future를 실제로 굴리려면 **런타임(Tokio)**이 필요하고, tokio::spawn 으로 동시 태스크를 띄웁니다.
  • spawn 한 Future는 Send + 'static 이어야 하므로, 스레드 간 이동과 수명을 염두에 둬야 합니다.
  • select! 로 여러 Future를 경쟁시키고, 폴링을 멈추면 Future는 자연히 취소됩니다.
  • 비동기 문맥에서는 블로킹을 피하고, 무거운 동기 작업은 spawn_blocking 으로 격리하세요.

이 원칙들을 몸에 익히면, Rust의 async는 안전성과 성능을 동시에 주는 강력한 도구가 됩니다. 언어가 주는 것은 뼈대뿐이지만, 그 뼈대가 명확하기에 여러분은 무슨 일이 벌어지는지 정확히 이해하며 코드를 짤 수 있습니다.

참고 자료