이번 글에서 다룰 내용
Rust의 비동기는 OS 스레드가 아니라 [[future|Future]]라는 상태 기계를 중심으로 동작합니다. 이번 편은 [[async-await|async fn]], .await, 그리고 런타임이 무엇을 담당하는지 감각 위주로 설명합니다. 복잡한 프레임워크 대신 간단한 예제를 통해 흐름을 익힙니다.
아주 짧게 비유하면, thread는 "작업자를 더 늘리는 방식"에 가깝고 async는 "기다리는 동안 다른 일을 끼워 넣는 방식"에 가깝습니다.
이 차이를 표로 보면 더 직관적입니다.
| 방식 | 핵심 아이디어 | 잘 맞는 상황 |
|---|---|---|
| thread | 작업자를 여러 명 둔다 | CPU 작업 분산, 독립적인 작업 |
| async | 기다림을 효율적으로 관리한다 | 네트워크, 파일 I/O, 대기 시간이 긴 작업 |
async fn과 Future
async fn을 선언하면, 실제로는 즉시 끝까지 실행되는 것이 아니라 "나중에 완료될 작업 계획"인 Future를 반환합니다. 이 Future는 .await를 통해 실행을 진행하며, 기다리는 동안 스레드를 차단하지 않고 제어권을 런타임에 돌려줍니다.
즉, async fn을 호출했다고 해서 바로 작업이 끝난 것이 아니라, "이 작업을 나중에 실행할 준비가 된 상태"가 만들어진다고 보면 됩니다.
async fn fetch_data(id: u32) -> String {
format!("result-{id}")
}
fn main() {
let future = fetch_data(42);
println!("future created but not run: {:#?}", std::any::type_name_of_val(&future));
}
이 코드는 실제 네트워크를 호출하지 않지만, async fn이 Future 타입을 반환한다는 사실을 보여 줍니다. Future를 실행하려면 런타임이 필요합니다.
간단한 런타임: block_on
학습용으로는 futures 크레이트의 executor::block_on을 사용할 수 있습니다.
use futures::executor::block_on;
async fn double_after_delay(x: u32) -> u32 {
// 실제 비동기 I/O 대신, 연산을 통해 흐름만 보여 줍니다.
x * 2
}
fn main() {
let result = block_on(double_after_delay(5));
println!("result = {result}");
}
block_on은 Future가 완료될 때까지 현재 스레드를 잠시 차단합니다. 학습용으로는 좋지만, 실제 애플리케이션 전체를 이 방식 하나로 운영하는 도구는 아닙니다.
쉽게 말해:
- block_on: "이 Future 하나 끝날 때까지 여기서 기다리자"
- tokio::main: "비동기 작업 여러 개를 관리할 런타임을 아예 켜 두자"
async main과 tokio::main
현업에서 가장 널리 쓰이는 런타임 중 하나가 tokio입니다. #[tokio::main] 속성을 붙이면 런타임이 자동으로 초기화되고, 내부적으로 비동기 main을 실행해 주는 진입점이 만들어집니다.
#[tokio::main]
async fn main() {
let user = fetch_user().await;
println!("user = {user}");
}
async fn fetch_user() -> String {
"mathbong".to_string()
}
이 예제는 실제 네트워크 호출을 하지 않지만, .await 지점에서 다른 Future가 실행될 수 있다는 사실을 보여 줍니다. 런타임은 내부적으로 워커 스레드를 가지고 Future의 상태를 전환합니다.
여기서 런타임은 "Future들을 대신 관리하고, 지금 무엇을 깨워서 다시 실행할지 결정하는 관리자" 정도로 이해하면 충분합니다.
동기 대 비동기 사고 전환
- 동기 코드는 함수가 끝날 때까지 스레드를 붙잡습니다.
- 비동기 코드는 기다림을 Future로 표현하고, 런타임이 지연 없는 작업으로 전환합니다.
따라서 CPU 작업만 많은 코드를 async로 감싸도 이득이 거의 없습니다. 대신 네트워크 I/O나 파일 I/O처럼 대기 시간이 긴 작업에서 효율을 얻습니다.
입문 단계에서는 아래처럼 기억하면 판단이 쉽습니다.
- 계속 계산하느라 바쁨 -> thread/병렬 처리 쪽이 더 중요할 수 있음
- 대기 시간이 많음 -> async가 더 잘 맞음
await 체인과 에러 처리
async fn은 여전히 Result를 반환할 수 있고, ? 연산자를 사용할 수 있습니다.
async fn read_config() -> Result<String, std::io::Error> {
let text = tokio::fs::read_to_string("config.toml").await?;
Ok(text)
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let config = read_config().await?;
println!("config = {}", config.trim());
Ok(())
}
?는 Future의 완료 결과가 Err일 때 곧바로 현재 Future를 종료합니다. 동기 코드와 같은 문법을 유지하면서 비동기 흐름을 표현할 수 있습니다.
런타임 선택 기준
tokio: 네트워크 서버, CLI, 비동기 파일 I/O 등 범용 작업에 적합.async-std: 표준 라이브러리와 유사한 API를 제공하며 간결한 문법을 선호할 때 사용.smol: 경량 런타임, 학습이나 임베디드 환경에 적합.
이번 글에서는 다양한 런타임을 깊게 비교하지 않고, 런타임이 Future 스케줄링을 담당한다는 사실만 기억해도 충분합니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요