[Rust 시리즈 19편] 비동기 기초 맛보기

English version

이번 글에서 다룰 내용

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 fnFuture 타입을 반환한다는 사실을 보여 줍니다. 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 checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

연습 과제

  1. tokio::time::sleep을 이용해 두 개의 async fn을 동시에 실행하고, 더 빠른 작업이 먼저 끝나는지 로그로 확인해 보세요.
  2. futures::join! 매크로를 사용해 두 Future 결과를 동시에 기다려 보세요.
  3. Result를 반환하는 비동기 함수를 작성하고, 에러 메시지를 사용자에게 친절하게 바꿔 출력해 보세요.

마무리

비동기는 스레드를 무작정 늘리는 대신, 기다림을 명시적으로 표현해 효율을 끌어올리는 방식입니다. async fn은 Future를 반환하고, 런타임이 이를 실행합니다. 다음 편에서는 지금까지 배운 모듈, 테스트, I/O를 모아 안전한 CLI 캡스톤을 만들어 봅니다.

💬 댓글

이 글에 대한 의견을 남겨주세요