Rust 비동기 프로그래밍 | async/await, Tokio

이 글의 핵심

Rust의 async fn은 호출만으로는 아무 일도 하지 않고, 런타임이 poll해야 진행되는 Future를 돌려줍니다. 이 차이를 이해하는 데서 시작해 Tokio로 비동기 I/O와 병렬 HTTP 요청을 구현하고, std::sync::Mutex를 await 너머로 잡는 실수 같은 흔한 함정과 Tokio·async-std 비교를 짚습니다.

시리즈 안내

#08 | 📋 전체 목차 | 이전: #07 동시성 · 다음: #09 웹 개발


들어가며

async/await는 Future를 조합해 I/O 대기를 스레드 한 개로 효율적으로 처리하는 쪽에 가깝습니다. 실행기(Tokio 등)가 준비된 작업만 깨우므로, 블로킹 호출과 섞이지 않게 구성하는 것이 중요합니다. Node.js 이벤트 루프·JavaScript Promise와 같이 “한 스레드에서 많은 I/O를 겹친다”는 인상은 비슷하지만, Tokio는 워커 스레드 풀을 쓰는 경우가 많습니다. Kotlin 코루틴의 suspend와도 자주 비교되고, 스레드에 바로 태우는 C++ std::async와는 역할이 다릅니다.


async fn과 .await 기초

기본 사용

async fn fetch_data() -> String {
    String::from("데이터")
}
#[tokio::main]
async fn main() {
    let data = fetch_data().await;
    println!("{}", data);
}

async fn을 호출하는 것만으로는 아무 일도 일어나지 않는다는 점이 JavaScript의 Promise와 가장 크게 다른 부분입니다. JavaScript에서는 fetchData()를 호출하는 순간 작업이 시작되지만, Rust의 fetch_data()는 실행되지 않은 상태 머신(Future)을 돌려줄 뿐이고, .await하거나 런타임에 넘겨야 비로소 실행됩니다. 그래서 .await를 빠뜨리면 코드가 조용히 건너뛰어지는데, 컴파일러가 unused implementer of Future that must be used 경고와 함께 “futures do nothing unless you .await or poll them”이라고 알려 주므로 경고를 무시하지 않는 것이 중요합니다.

여러 비동기 함수

async fn fetch_user(id: u32) -> String {
    format!("사용자 {}", id)
}
async fn fetch_posts(user_id: u32) -> Vec<String> {
    vec![format!("포스트 1"), format!("포스트 2")]
}
#[tokio::main]
async fn main() {
    let user = fetch_user(1).await;
    println!("{}", user);
    
    let posts = fetch_posts(1).await;
    println!("{:?}", posts);
}

이 예제는 두 요청을 순서대로 기다립니다. fetch_user가 끝나야 fetch_posts가 시작되므로, 두 작업이 서로 독립적이라면 뒤의 tokio::join!으로 동시에 진행시키는 편이 빠릅니다. 반대로 게시글 조회에 사용자 정보가 필요하다면 이처럼 순서대로 기다리는 것이 맞습니다. .await를 어디에 두느냐가 곧 실행 순서를 정한다는 점이 비동기 코드를 읽는 핵심입니다.


Tokio 런타임에서 태스크 생성과 병렬 실행

프로젝트 설정

[dependencies]
tokio = { version = "1", features = ["full"] }

태스크 생성

use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
    let task1 = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("Task 1 완료");
        1
    });
    
    let task2 = tokio::spawn(async {
        sleep(Duration::from_secs(2)).await;
        println!("Task 2 완료");
        2
    });
    
    let (result1, result2) = tokio::join!(task1, task2);
    println!("결과: {:?}, {:?}", result1, result2);
}

병렬 실행

use tokio::time::{sleep, Duration};
async fn task(id: u32, duration: u64) -> u32 {
    sleep(Duration::from_secs(duration)).await;
    println!("Task {} 완료", id);
    id
}
#[tokio::main]
async fn main() {
    let (r1, r2, r3) = tokio::join!(
        task(1, 1),
        task(2, 2),
        task(3, 1),
    );
    
    println!("결과: {}, {}, {}", r1, r2, r3);
}

두 예제의 차이를 알아 두면 좋습니다. tokio::spawn은 작업을 런타임에 독립된 태스크로 넘기므로, 멀티스레드 런타임에서는 다른 워커 스레드에서 실제로 병렬 실행될 수 있고, 대신 넘기는 Future가 Send + 'static이어야 합니다(지역 변수를 빌려 쓸 수 없어 async move로 소유권을 넘기는 이유입니다). tokio::join!은 새 태스크를 만들지 않고 현재 태스크 안에서 여러 Future를 번갈아 poll합니다. 그래서 빌린 값을 그대로 쓸 수 있고 오버헤드가 작지만, 모두 한 태스크 안이므로 CPU를 쓰는 작업을 여러 개 넣어도 병렬로 돌지는 않습니다. I/O 대기가 대부분인 작업 몇 개를 동시에 기다릴 때는 join!, 오래 걸리거나 독립적으로 살아야 하는 작업은 spawn이 맞습니다. 개수가 정해지지 않은 작업은 tokio::task::JoinSet으로 모아 완료되는 순서대로 결과를 받을 수 있습니다.


tokio::fs와 TcpStream으로 비동기 I/O

파일 읽기

use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut file = File::open("test.txt").await?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    
    println!("{}", contents);
    Ok(())
}

TCP 연결

use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> std::io::Result<()> {
    let stream = TcpStream::connect("127.0.0.1:8080").await?;
    println!("연결됨");
    Ok(())
}

tokio::fs는 이름과 달리 운영체제의 비동기 파일 I/O를 쓰는 것이 아니라, 내부적으로 블로킹 파일 호출을 별도의 블로킹 스레드 풀에서 실행합니다(대부분의 OS에서 일반 파일은 epoll로 “준비됨”을 기다릴 수 없기 때문입니다). 파일 하나를 읽는 데는 문제가 없지만, 작은 파일 수천 개를 처리할 때는 호출마다 스레드 풀로 넘기는 비용이 쌓이므로 spawn_blocking 안에서 std::fs로 한꺼번에 처리하는 편이 빠를 수 있습니다.


예제: 병렬 HTTP 요청

use tokio::time::{sleep, Duration};
async fn fetch_url(url: &str) -> Result<String, String> {
    sleep(Duration::from_secs(1)).await;
    Ok(format!("{}의 데이터", url))
}
#[tokio::main]
async fn main() {
    let urls = vec![
        "https://api.example.com/users",
        "https://api.example.com/posts",
        "https://api.example.com/comments",
    ];
    
    let mut tasks = vec![];
    
    for url in urls {
        let task = tokio::spawn(async move {
            fetch_url(url).await
        });
        tasks.push(task);
    }
    
    for task in tasks {
        match task.await {
            Ok(Ok(data)) => println!("받음: {}", data),
            Ok(Err(e)) => println!("에러: {}", e),
            Err(e) => println!("태스크 에러: {}", e),
        }
    }
}

타임아웃·재시도가 있는 HTTP GET (reqwest)

Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
use reqwest::StatusCode;
use serde::Deserialize;
use std::time::Duration;
use tokio::time::sleep;
#[derive(Debug, Deserialize)]
struct Repo {
    name: String,
    stargazers_count: u64,
}
async fn fetch_with_retry(url: &str, max: u32) -> reqwest::Result<Repo> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(5))
        .build()?;
    for attempt in 0..max {
        let resp = client.get(url).send().await?;
        let status = resp.status();
        if status == StatusCode::OK {
            return resp.json::<Repo>().await;
        }
        if status.is_server_error() && attempt + 1 < max {
            sleep(Duration::from_millis(200 * (attempt as u64 + 1))).await;
            continue;
        }
        return Err(resp.error_for_status().unwrap_err());
    }
    unreachable!()
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let url = "https://api.github.com/repos/rust-lang/rust";
    let repo = fetch_with_retry(url, 3).await?;
    println!("{} ★ {}", repo.name, repo.stargazers_count);
    Ok(())
}

이 예제는 구조를 보여 주기 위한 것이라 실무에서는 몇 가지를 보완해야 합니다. send().await?의 ? 때문에 타임아웃이나 연결 실패 같은 네트워크 오류는 재시도 없이 즉시 반환되고, 재시도는 5xx 응답에만 적용됩니다. 실제로 재시도가 가장 필요한 것은 일시적인 네트워크 오류이므로, reqwest::Error::is_timeout()과 is_connect()로 구분해 재시도 대상에 넣는 것이 보통입니다. 또 Client는 내부에 커넥션 풀을 가지고 있어서 함수 호출마다 새로 만들면 연결 재사용이 되지 않습니다. 앱 시작 시 한 번 만들어 Arc나 상태 객체로 공유하세요. max가 0이면 반복문을 건너뛰고 unreachable!()로 패닉하는 경계 조건도 있습니다. 재시도 간격에는 무작위 지터를 더해, 여러 클라이언트가 같은 시점에 동시에 재시도하며 서버를 다시 몰아붙이지 않게 하는 것이 좋습니다.

블로킹 호출, JoinHandle, std Mutex에서 생기는 실수

  • async fn 안에서 std::thread::sleep이나 블로킹 I/O를 써서 런타임 워커를 막는 경우. 워커 스레드 수만큼만 태스크가 동시에 진행되므로, 블로킹 호출 몇 개로 서버 전체가 응답하지 않는 것처럼 보입니다. tokio::time::sleep을 쓰고, 블로킹 라이브러리는 spawn_blocking으로 감쌉니다.
  • JoinHandle을 버리면 태스크가 취소된다고 생각하는 경우. 실제로는 반대라서, JoinHandle을 drop해도 태스크는 백그라운드에서 계속 실행됩니다(detach). 취소하려면 handle.abort()를 명시적으로 호출해야 하고, main이 먼저 끝나면 런타임이 종료되며 남은 태스크는 끝까지 실행되지 못합니다.
  • std::sync::Mutex를 .await 너머로 들고 있는 경우. 멀티스레드 런타임에서 tokio::spawn에 넣으면 가드가 Send가 아니어서 future cannot be sent between threads safely 컴파일 에러가 나고, 단일 스레드 런타임이나 block_on에서는 컴파일은 되지만 같은 락을 기다리는 태스크끼리 교착될 수 있습니다.
  • 블로킹 I/O(일부 파일 API, DNS)는 spawn_blocking으로 격리하는 편이 안전합니다.
  • Tokio 런타임 스레드 수와 CPU 바운드 작업 분리는 rayon 등과 역할을 나눕니다.

select!·Client 재사용·tracing으로 다루기

  • tokio::select!로 취소·타임아웃을 한 곳에서 표현합니다. select!는 먼저 끝난 분기 외의 Future를 drop해서 취소하므로, 읽다 만 데이터가 버려져도 괜찮은지(취소 안전성, cancel safety)를 확인해야 합니다. Tokio 문서는 메서드마다 취소 안전 여부를 적어 두고 있으며, 루프 안에서 select!를 쓸 때 특히 중요합니다. 단순 타임아웃은 tokio::time::timeout(Duration, fut)이 더 읽기 쉽습니다.
  • 연결 풀·타임아웃은 reqwest::Client 빌더에서 앱 전역으로 재사용합니다.
  • 로그는 tracing으로 스팬을 남겨 비동기 호출 경로를 추적합니다.

Tokio, async-std, 스레드+채널 비교

런타임/스타일특징
Tokio생태계 최대, 네트워크·서버와 궁합
async-stdAPI가 표준 라이브러리 느낌 (2025년 개발 중단, 후속으로 smol 권장)
스레드 + 채널CPU 바운드·간단 파이프라인에 여전히 유효

참고 자료


Future가 동작하는 방식과 런타임·에러·동시성 제어

async/await 동작 원리

  • async fn은 즉시 완료되지 않는 연산을 나타내는 타입(구현체는 컴파일러가 생성한 상태 머신)을 반환합니다.
  • .await는 “이 Future가 완료될 때까지 실행기(executor)에게 양보한다”는 의미입니다. 스레드가 막히는 것이 아니라, 다른 태스크로 CPU를 넘깁니다.
  • 런타임(Tokio 등)은 poll을 반복해 Pending이면 나중에 깨우고, Ready면 다음 단계로 진행합니다. 요약하면: async/await는 문법 설탕이며, 실제로는 Future + 실행기 + I/O 드라이버의 조합입니다.

Future 트레이트

std::future::Future는 “나중에 값이 나올 수 있는 것”을 모델합니다.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// Future는 poll로 진행 상황을 묻는다 (실제 코드는 보통 async로 작성)
// type Output = T;  // 완료 시 타입
// fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
  • Poll::Pending: 아직 준비 안 됨 → 런타임이 waker로 다시 스케줄링.
  • Poll::Ready(val): 완료. async 블록은 이 poll 상태 머신으로 변환되므로, .await 지점이 자연스러운 양보 지점이 됩니다.

Tokio vs async-std

항목Tokioasync-std
생태계사실상 표준에 가깝게 넓음 (hyper, tonic, axum 등)경량·std 스타일 API 지향
API 스타일tokio::fs, tokio::net 등 자체 모듈async_std::가 std와 비슷한 이름 공간
선택 가이드새 프로젝트·서버·네트워크는 Tokio가 의존성 정합성이 좋음기존 코드 유지보수용. 새 프로젝트에는 권장되지 않음

async-std는 2025년 3월에 개발 중단이 공지되었고, 프로젝트는 후속으로 smol을 권하고 있습니다. 두 런타임의 실행 모델은 비슷하지만, 라이브러리가 특정 런타임의 타이머나 소켓 타입에 의존하면 다른 런타임에서 there is no reactor running, must be called from the context of a Tokio 1.x runtime 같은 패닉이 납니다. 이 메시지는 Tokio 전용 기능을 Tokio 런타임 밖(다른 런타임이나 일반 스레드)에서 호출했다는 뜻이므로, 의존성 중 어느 것이 어떤 런타임을 가정하는지 확인해 한 런타임으로 통일하는 것이 해결책입니다.

실전 에러 처리

비동기에서도 Result는 그대로입니다. 다만 ?가 여러 .await를 거치므로 에러 타입을 앱 전역으로 통일하면 편합니다.

async fn load() -> anyhow::Result<String> {
    let s = tokio::fs::read_to_string("config.toml").await?;
    Ok(s)
}
  • anyhow::Result: 애플리케이션 코드에서 빠르게 에러를 전파할 때.
  • thiserror: 라이브러리에서 도메인 에러 타입을 정의할 때 (#[derive(Error)]). JoinHandle은 task.await 시 Result<T, JoinError>를 돌려주므로, 스폰 실패·패닉과 본문 Result를 이중으로 매칭하는 패턴이 흔합니다.
match handle.await {
    Ok(Ok(value)) => { /* 성공 */ }
    Ok(Err(e)) => { /* async 블록 내부 Err */ }
    Err(join_err) => { /* 태스크 자체 실패 */ }
}

동시성 제어: Mutex, RwLock, 채널 (Tokio)

비동기 컨텍스트에서는 가능하면 tokio::sync를 사용합니다. std::sync::Mutex를 잡은 채 .await를 호출하면 같은 런타임 워커가 막혀 데드락·처리량 저하가 날 수 있습니다.

use tokio::sync::{Mutex, RwLock, mpsc};
// 공유 상태
let counter = std::sync::Arc::new(Mutex::new(0u64));
let c = counter.clone();
tokio::spawn(async move {
    let mut g = c.lock().await;
    *g += 1;
});
// 읽기 많은 경우 RwLock
let cache = std::sync::Arc::new(RwLock::new(Vec::<String>::new()));
// 태스크 간 메시지 전달
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move {
    while let Some(msg) = rx.recv().await {
        println!("{msg}");
    }
});
tx.send("hello".into()).await.unwrap();
  • Mutex::lock().await: 비동기 친화적 락.
  • RwLock: 읽기/쓰기 비율에 따라 선택.
  • mpsc::channel: 생산자–소비자, 백프레셔(버퍼 크기) 조절. std::sync의 뮤텍스는 잠그는 구간이 짧고 그 안에 await가 없을 때만 고려합니다. 흥미롭게도 Tokio 문서는 이 조건을 만족한다면 오히려 std::sync::Mutex(또는 parking_lot)를 권합니다. tokio::sync::Mutex는 내부적으로 더 무거워서, 카운터를 올리는 정도의 짧은 임계 구역에는 표준 뮤텍스가 더 빠르기 때문입니다. 제 경험상 판단 기준은 단순했습니다. 락을 잡은 채 .await해야 한다면 tokio::sync::Mutex, 그렇지 않다면 표준 뮤텍스를 쓰되 가드가 .await 전에 확실히 drop되도록 { } 블록 안에 가두는 것입니다. 공유 상태 자체를 없애고 소유 태스크 하나에 채널로 요청을 보내는 액터 방식으로 바꾸면 락 문제가 통째로 사라지는 경우도 많습니다.

async Rust 요약

  1. async/await: 비동기 함수 정의 및 대기
  2. Future: 비동기 작업 표현
  3. Tokio: 비동기 런타임
  4. tokio::spawn: 비동기 태스크 생성
  5. tokio::join!: 병렬 실행 및 결과 대기
  6. 동작 원리: 상태 머신 + poll / waker, 실행기가 스케줄링
  7. Tokio vs async-std: 생태계·호환성은 보통 Tokio 우선
  8. 에러: anyhow/thiserror, JoinHandle과 내부 Result 구분
  9. 동시성: tokio::sync::{Mutex, RwLock, mpsc}로 await와 함께 사용

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. async 코드에서 std::sync::Mutex를 쓰면 안 되나요?

A. 락을 잡은 채 .await를 호출하면 그 태스크가 멈춘 동안 락이 풀리지 않아, 같은 런타임 워커에서 도는 다른 태스크가 막히고 데드락이나 처리량 저하가 생길 수 있습니다. 비동기 컨텍스트에서는 tokio::sync::Mutex의 lock().await를 쓰는 편이 안전합니다. std::sync::Mutex는 잠그는 구간이 짧고 그 안에 await가 전혀 없을 때만 고려합니다.