Rust Async/Await 완벽 가이드: Tokio, Futures, 비동기 동시성 심층 분석

Rust의 Async/Await를 Tokio 런타임, Future 트레이트, 태스크 스폰, 구조화된 동시성, 실전 패턴까지 깊이 있게 분석합니다.

Rust async await concurrency with Tokio runtime and futures execution flow

Rust는 메모리 안전성과 성능을 동시에 제공하는 시스템 프로그래밍 언어로, 비동기 프로그래밍에서도 독특한 접근 방식을 취합니다. JavaScript의 Promise나 Python의 asyncio와 달리, Rust의 async/await는 제로 코스트 추상화 원칙을 따르며, 런타임 오버헤드 없이 고성능 비동기 코드를 작성할 수 있습니다. 본 글에서는 Rust의 Future 트레이트부터 Tokio 런타임, 그리고 실전 동시성 패턴까지 심층적으로 살펴봅니다.

핵심 개념: Rust의 Future는 게으른(lazy) 실행 모델을 따릅니다

Rust의 Future는 생성 시점에 실행되지 않고, .await를 호출하거나 런타임에 의해 poll될 때까지 아무 작업도 수행하지 않습니다. 이는 JavaScript의 Promise가 생성 즉시 실행되는 것과 근본적으로 다른 동작 방식입니다.

Rust Future와 다른 언어의 차이점

Rust의 비동기 프로그래밍은 Future 트레이트를 기반으로 합니다. 이 트레이트는 표준 라이브러리에 정의되어 있으며, 모든 비동기 작업의 근간이 됩니다.

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

poll 메서드는 Future의 상태를 확인하고, 완료되었으면 Poll::Ready(value)를, 아직 진행 중이면 Poll::Pending을 반환합니다. 중요한 점은 Rust의 Future가 pull 기반 모델을 사용한다는 것입니다. 런타임이 Future를 반복적으로 poll하여 진행 상황을 확인하며, Future가 준비되지 않았을 때는 waker를 등록하여 나중에 다시 poll될 수 있도록 합니다.

JavaScript나 Python의 비동기 모델과 비교했을 때 주요 차이점은 다음과 같습니다. 첫째, Rust Future는 힙 할당 없이 스택에 배치될 수 있어 메모리 효율성이 높습니다. 둘째, 컴파일러가 async 함수를 상태 머신으로 변환하여 런타임 오버헤드를 최소화합니다. 셋째, 런타임이 언어에 내장되어 있지 않아 Tokio, async-std 등 여러 런타임 중 선택할 수 있습니다.

Tokio 런타임 설정

Tokio는 Rust 생태계에서 가장 널리 사용되는 비동기 런타임입니다. 프로덕션 환경에서 검증된 안정성과 풍부한 기능을 제공하며, 멀티스레드 작업 스케줄링, 타이머, I/O 드라이버 등을 포함합니다.

toml
# Cargo.toml
[dependencies]
tokio = { version = "2", features = ["rt-multi-thread", "macros", "net", "time"] }

rt-multi-thread 피처는 여러 OS 스레드에서 태스크를 실행하는 작업 스틸링 스케줄러를 활성화합니다. macros 피처는 #[tokio::main]#[tokio::test] 같은 편의 매크로를 제공하며, nettime은 각각 네트워크 I/O와 타이머 기능을 활성화합니다.

main.rsrust
#[tokio::main]
async fn main() {
    let result = fetch_data().await;
    println!("Got: {result}");
}

async fn fetch_data() -> String {
    // Simulate async I/O
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    String::from("data loaded")
}

#[tokio::main] 매크로는 Tokio 런타임을 초기화하고 async main 함수를 실행합니다. 내부적으로 이 매크로는 Runtime::new()를 호출하고 block_on으로 Future를 완료까지 실행하는 코드로 확장됩니다.

태스크 스폰과 구조화된 동시성

단일 Future를 순차적으로 실행하는 것만으로는 비동기 프로그래밍의 진정한 이점을 얻을 수 없습니다. tokio::spawn을 사용하면 독립적인 태스크를 생성하여 동시에 실행할 수 있습니다.

concurrent_tasks.rsrust
use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    // Spawn two independent tasks
    let handle_a: JoinHandle<u32> = tokio::spawn(async {
        expensive_computation("dataset_a").await
    });

    let handle_b: JoinHandle<u32> = tokio::spawn(async {
        expensive_computation("dataset_b").await
    });

    // Await both results
    let (result_a, result_b) = (
        handle_a.await.expect("task A panicked"),
        handle_b.await.expect("task B panicked"),
    );

    println!("Results: {result_a}, {result_b}");
}

async fn expensive_computation(name: &str) -> u32 {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("{name} done");
    42
}

JoinHandle은 스폰된 태스크의 결과를 기다리는 핸들입니다. 태스크가 패닉하면 JoinError를 반환하므로 적절한 에러 처리가 필요합니다.

스폰된 태스크는 Send + 'static 제약을 가집니다

tokio::spawn에 전달되는 Future는 Send'static 트레이트 바운드를 만족해야 합니다. 이는 태스크가 다른 스레드로 이동할 수 있고, 원래 스코프보다 오래 살 수 있기 때문입니다. 로컬 참조를 캡처해야 한다면 tokio::task::spawn_local이나 스코프드 태스크 라이브러리를 고려해야 합니다.

tokio::join!과 tokio::select!로 여러 Future 결합하기

여러 비동기 작업을 조합하는 방법에 따라 프로그램의 동작이 크게 달라집니다. tokio::join!은 모든 Future를 동시에 실행하고 전부 완료될 때까지 기다립니다.

join_example.rsrust
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    // All three run concurrently, total time ~200ms (not 600ms)
    let (users, orders, inventory) = tokio::join!(
        fetch_users(),
        fetch_orders(),
        fetch_inventory()
    );

    println!("Users: {}, Orders: {}, Stock: {}", users.len(), orders.len(), inventory);
}

async fn fetch_users() -> Vec<String> {
    sleep(Duration::from_millis(200)).await;
    vec!["Alice".into(), "Bob".into()]
}

async fn fetch_orders() -> Vec<String> {
    sleep(Duration::from_millis(150)).await;
    vec!["ORD-001".into()]
}

async fn fetch_inventory() -> u32 {
    sleep(Duration::from_millis(100)).await;
    84
}

위 예제에서 세 개의 fetch 함수가 동시에 실행되어 총 소요 시간은 가장 오래 걸리는 작업인 200ms에 수렴합니다. 순차 실행이었다면 450ms가 소요되었을 것입니다.

반면 tokio::select!는 여러 Future 중 가장 먼저 완료되는 하나만 처리합니다. 이는 타임아웃 구현, 취소, 또는 여러 소스 중 가장 빠른 응답을 선택할 때 유용합니다.

select_example.rsrust
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        val = fetch_from_cache() => {
            println!("Cache hit: {val}");
        }
        val = fetch_from_database() => {
            println!("DB result: {val}");
        }
    }
}

async fn fetch_from_cache() -> String {
    sleep(Duration::from_millis(5)).await;
    "cached_value".into()
}

async fn fetch_from_database() -> String {
    sleep(Duration::from_millis(50)).await;
    "db_value".into()
}

캐시 조회가 5ms로 더 빠르므로 데이터베이스 쿼리는 취소되고 캐시 값이 사용됩니다. select!는 선택되지 않은 브랜치의 Future를 드롭하므로 리소스 정리가 자동으로 이루어집니다.

Rust 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

비동기 Rust에서의 에러 처리

비동기 코드에서의 에러 처리는 동기 코드와 크게 다르지 않습니다. Result 타입과 ? 연산자를 그대로 사용할 수 있으며, 커스텀 에러 타입을 정의하여 다양한 에러 소스를 통합할 수 있습니다.

error_handling.rsrust
use std::io;

#[derive(Debug)]
enum AppError {
    Network(reqwest::Error),
    Parse(serde_json::Error),
    Io(io::Error),
}

async fn load_config(url: &str) -> Result<Config, AppError> {
    let response = reqwest::get(url)
        .await
        .map_err(AppError::Network)?;

    let text = response.text()
        .await
        .map_err(AppError::Network)?;

    let config: Config = serde_json::from_str(&text)
        .map_err(AppError::Parse)?;

    Ok(config)
}

#[derive(serde::Deserialize)]
struct Config {
    db_url: String,
    port: u16,
}

실무에서는 thiserror 크레이트를 사용하여 에러 타입을 더 간결하게 정의하거나, anyhow 크레이트로 에러를 동적으로 처리하는 방식을 많이 사용합니다. 특히 애플리케이션 코드에서는 anyhow::Result가 편리하고, 라이브러리 코드에서는 명시적인 에러 타입이 선호됩니다.

태스크 간 비동기 통신 채널

동시에 실행되는 태스크 간에 데이터를 안전하게 전달하려면 채널을 사용합니다. Tokio는 여러 종류의 채널을 제공하며, 가장 일반적인 것은 mpsc(다중 생산자, 단일 소비자) 채널입니다.

channel_example.rsrust
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    // Bounded channel with capacity 32
    let (tx, mut rx) = mpsc::channel::<String>(32);

    // Producer task
    let producer = tokio::spawn(async move {
        for i in 0..5 {
            tx.send(format!("message-{i}")).await.unwrap();
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
        // tx dropped here, closing the channel
    });

    // Consumer reads until channel closes
    while let Some(msg) = rx.recv().await {
        println!("Received: {msg}");
    }

    producer.await.unwrap();
}
동시성 프리미티브 선택 가이드

상황에 따라 적절한 동시성 프리미티브를 선택해야 합니다. 여러 생산자에서 한 소비자로 데이터를 전달할 때는 mpsc를, 브로드캐스트가 필요하면 broadcast를, 한 번만 값을 전달한다면 oneshot을, 최신 값만 중요하다면 watch 채널을 사용합니다. 공유 상태가 필요하면 RwLock이나 Mutex를 고려하되, 가능하면 채널을 통한 메시지 전달 방식을 선호합니다.

실전 패턴: 속도 제한이 있는 HTTP 요청

실제 애플리케이션에서는 외부 API 호출 시 동시 요청 수를 제한해야 하는 경우가 많습니다. Semaphore를 사용하면 동시 실행 태스크 수를 효과적으로 제어할 수 있습니다.

rate_limited_fetcher.rsrust
use std::sync::Arc;
use tokio::sync::Semaphore;

async fn fetch_all(urls: Vec<String>, max_concurrent: usize) -> Vec<Result<String, String>> {
    let semaphore = Arc::new(Semaphore::new(max_concurrent));
    let mut handles = Vec::new();

    for url in urls {
        let sem = Arc::clone(&semaphore);
        let handle = tokio::spawn(async move {
            // Acquire permit before making request
            let _permit = sem.acquire().await.unwrap();
            reqwest::get(&url)
                .await
                .map(|r| r.status().to_string())
                .map_err(|e| e.to_string())
            // permit dropped here, allowing next task to proceed
        });
        handles.push(handle);
    }

    let mut results = Vec::new();
    for handle in handles {
        results.push(handle.await.unwrap());
    }
    results
}

세마포어 permit은 _permit 변수가 스코프를 벗어날 때 자동으로 반환됩니다. 이 패턴은 데이터베이스 연결 풀, 파일 디스크립터 제한, API 레이트 리밋 준수 등 다양한 상황에서 활용됩니다.

Pin과 Unpin: 비동기 Rust에 필요한 이유

Future 트레이트의 poll 메서드 시그니처에서 Pin<&mut Self>가 등장합니다. 이는 자기 참조(self-referential) 구조체와 관련이 있습니다. async 블록이 컴파일러에 의해 상태 머신으로 변환될 때, 지역 변수와 그에 대한 참조가 같은 구조체 안에 저장될 수 있습니다.

만약 이 구조체가 메모리 내에서 이동하면, 내부 참조가 무효화되어 정의되지 않은 동작이 발생합니다. Pin은 값이 메모리에서 이동하지 않음을 보장하는 타입 시스템 수준의 장치입니다. 대부분의 타입은 Unpin 트레이트를 자동으로 구현하여 이동이 안전함을 나타내며, 일반적인 사용에서는 Pin을 직접 다룰 필요가 거의 없습니다.

저수준 Future 구현이나 커스텀 combinator를 작성할 때 Pin을 이해해야 하지만, 일반적인 async/await 사용에서는 컴파일러가 모든 것을 처리합니다.

성능 특성과 비동기 사용 시점

비동기 프로그래밍이 항상 최선의 선택은 아닙니다. CPU 바운드 작업에는 비동기가 오히려 오버헤드를 추가할 수 있습니다. 비동기는 I/O 바운드 작업, 특히 많은 동시 연결을 처리하는 네트워크 서비스에서 진가를 발휘합니다.

spawn_blocking_example.rsrust
#[tokio::main]
async fn main() {
    let hash = tokio::task::spawn_blocking(|| {
        // CPU-intensive work runs on a blocking thread
        compute_hash(b"large dataset")
    })
    .await
    .unwrap();

    println!("Hash: {hash}");
}

fn compute_hash(data: &[u8]) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut hasher = DefaultHasher::new();
    data.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

spawn_blocking은 CPU 집약적인 작업을 별도의 스레드 풀에서 실행하여 비동기 런타임을 블로킹하지 않도록 합니다. 파일 시스템 작업, 암호화 연산, 이미지 처리 등 오래 걸리는 동기 작업에 적합합니다.

결론

Rust의 비동기 프로그래밍은 처음에는 복잡해 보일 수 있지만, 그 설계 철학을 이해하면 강력하고 효율적인 동시성 코드를 작성할 수 있습니다. 핵심 내용을 정리하면 다음과 같습니다.

  • Future 트레이트는 Rust 비동기의 기반이며, poll 기반의 게으른 실행 모델을 사용합니다
  • Tokio 런타임은 멀티스레드 스케줄링, 타이머, I/O 드라이버를 제공하는 프로덕션 레디 솔루션입니다
  • tokio::spawn으로 독립적인 태스크를 생성하고, **join!**과 **select!**로 여러 Future를 조합합니다
  • 채널을 통해 태스크 간 안전한 통신이 가능하며, Semaphore로 동시성을 제어합니다
  • CPU 바운드 작업은 spawn_blocking을 사용하여 비동기 런타임을 블로킹하지 않도록 해야 합니다
  • Pin은 자기 참조 구조체의 안전성을 보장하지만, 일반적인 사용에서는 직접 다룰 필요가 없습니다

Rust의 비동기 생태계는 계속 발전하고 있으며, Tokio를 비롯한 라이브러리들이 더욱 사용하기 쉬워지고 있습니다. 타입 시스템의 도움으로 데이터 레이스와 같은 동시성 버그를 컴파일 타임에 방지할 수 있다는 점이 Rust 비동기 프로그래밍의 가장 큰 장점입니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#rust
#async
#tokio
#futures
#concurrency

공유

관련 기사