콘텐츠로 이동

Rust async/await

식당을 떠올려 보세요. 동시성 페이지에서 스레드를 배웠죠. 스레드는 테이블마다 종업원을 한 명씩 두는 것과 비슷해요. 잘 돌아가긴 하지만, 종업원은 비싸고, 대부분의 시간 동안 주방만 바라보며 기다리고 서 있어요.

비동기(async)는 종업원 한 명이 여러 주문을 받는 것과 비슷해요. 종업원은 1번 테이블의 주문을 받아 주방에 넘기고, 음식이 만들어지는 동안 2번 테이블로 가요. 아무도 하는 일 없이 서 있지 않아요. 이게 Rust의 async와 .await의 핵심 아이디어예요. 한 작업이 (네트워크, 파일, 타이머를) 기다리는 동안 다른 작업이 실행될 수 있어요.

비동기는 프로그램이 기다리는 데 많은 시간을 쓸 때 빛을 발해요. 웹 서버, API 클라이언트, 채팅 앱, 봇 같은 것들이요. 이 페이지에서는 Rust에서 가장 많이 쓰는 비동기 런타임인 tokio를 사용해요.

Rust 표준 라이브러리는 async와 .await 키워드는 주지만, 비동기 코드를 실제로 실행하는 “엔진”은 주지 않아요. 그 엔진을 런타임(runtime)이라고 하고, 대부분의 프로젝트가 tokio를 써요. 새 프로젝트에 추가해 볼게요.

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

그러면 Cargo.toml에 이런 줄이 추가돼요.

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

full 기능(feature)은 타이머, 태스크, 네트워킹 등 모든 걸 켜요. 배우는 동안에는 이게 가장 쉬운 선택이에요. 이 페이지의 “직접 실행해 보기” 버튼도 잘 동작해요. Rust Playground에는 tokio가 기본으로 들어 있거든요.

use std::time::Duration;
async fn make_coffee() -> String {
tokio::time::sleep(Duration::from_millis(20)).await;
String::from("커피")
}
#[tokio::main]
async fn main() {
println!("주문하는 중...");
let drink = make_coffee().await;
println!("주문하신 {} 나왔습니다", drink);
}

실행 결과:

주문하는 중...
주문하신 커피 나왔습니다
▶ 직접 실행해 보기

새로운 게 세 가지 있어요.

  • async fn은 중간에 멈췄다가 나중에 이어서 실행할 수 있는 함수를 만들어요.
  • .await는 “이게 끝날 때까지 기다려. 기다리는 동안에는 다른 작업이 실행되게 해 줘”라는 뜻이에요. .await는 async 함수 안에서만 쓸 수 있어요.
  • #[tokio::main]은 tokio 런타임을 시작하고, 그 위에서 async fn main을 실행해요. 이게 없으면 main을 async로 만들 수 없어요.

tokio::time::sleep은 “잠깐 기다리기”의 비동기 버전이에요. 스레드 전체가 아니라 이 작업만 멈춰요.

많은 사람이 놀라는 부분이 바로 여기예요. async fn을 호출해도 실행되지 않아요. 대신 퓨처(future)를 돌려줘요. 퓨처는 “나중에 일어날 작업”을 뜻하는 값이에요. .await하기 전까지는 아무 일도 일어나지 않아요.

async fn say_hello() {
println!("퓨처 안에서 인사해요!");
}
#[tokio::main]
async fn main() {
let future = say_hello(); // 아직 아무것도 실행되지 않아요
println!("퓨처를 만들었지만 아직 시작하지 않았어요");
future.await; // 이제 실행돼요
println!("끝");
}

실행 결과:

퓨처를 만들었지만 아직 시작하지 않았어요
퓨처 안에서 인사해요!
끝
▶ 직접 실행해 보기

퓨처는 주문서라고 생각하면 돼요. 주문서를 쓴다고 음식이 만들어지지는 않아요. 주방에 넘겨야(.await) 만들어지죠.

join!으로 여러 작업을 동시에 실행하기

섹션 제목: “join!으로 여러 작업을 동시에 실행하기”

지금까지는 한 번에 하나씩만 기다렸어요. 진짜 힘은 여러 개를 한꺼번에 기다릴 때 나와요. tokio::join!은 여러 퓨처를 함께 실행하고, 모두 끝날 때까지 기다려요. 결과는 튜플로 돌려줘요.

use std::time::Duration;
use tokio::time::sleep;
async fn fetch_user() -> String {
sleep(Duration::from_millis(30)).await; // 네트워크 호출이라고 상상해 보세요
String::from("민지")
}
async fn fetch_order_count() -> u32 {
sleep(Duration::from_millis(20)).await;
3
}
#[tokio::main]
async fn main() {
let (user, orders) = tokio::join!(fetch_user(), fetch_order_count());
println!("{}님의 주문은 {}건이에요", user, orders);
}

실행 결과:

민지님의 주문은 3건이에요
▶ 직접 실행해 보기

하나씩 차례로 await했다면 전체 대기 시간은 30 + 20 = 50ms쯤 돼요. join!을 쓰면 두 대기가 동시에 일어나서, 가장 느린 것의 시간인 30ms쯤만 걸려요. 실제 네트워크 호출에서는 이 차이가 금방 커져요.

퓨처의 개수가 정해져 있을 때는 join!이 좋아요. 작업 목록이 있을 때는 tokio::spawn을 쓰세요. 퓨처를 별도의 태스크(task)로 런타임에 넘기고, 바로 시작시키고, JoinHandle을 돌려줘요. 나중에 그 핸들을 .await하면 결과를 받을 수 있어요.

use std::time::Duration;
use tokio::time::sleep;
async fn download(id: u64) -> String {
sleep(Duration::from_millis(50 - id * 10)).await;
format!("파일 {} 다운로드 완료", id)
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for id in 1..=3 {
let handle = tokio::spawn(download(id)); // 지금 바로 실행을 시작해요
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
println!("{}", result);
}
}

실행 결과:

파일 1 다운로드 완료
파일 2 다운로드 완료
파일 3 다운로드 완료
▶ 직접 실행해 보기

자세히 보세요. 파일 3의 sleep이 가장 짧아서 실제로는 파일 3이 가장 먼저 끝나요. 하지만 결과는 핸들을 await하는 순서대로 출력하기 때문에, 출력은 언제나 1, 2, 3이에요. 그래도 세 다운로드는 동시에 실행됐어요.

.unwrap()은 왜 있을까요? JoinHandle을 await하면 Result가 나와요. 태스크가 패닉했을 수도 있기 때문이에요. 그런 경우 프로그램 전체가 죽는 대신 Err를 받아요. 동시성 페이지의 thread::spawn과 .join()과 많이 닮았죠. 일부러 그렇게 만든 거예요.

.await를 깜빡하기. 퓨처는 게으르기 때문에, .await를 잊으면 작업이 아예 일어나지 않아요. 그 값을 쓰려고 하면 컴파일러가 막아 줘요.

async fn get_number() -> u32 {
5
}
#[tokio::main]
async fn main() {
let n = get_number(); // .await를 깜빡했어요
println!("{}", n + 1);
}
error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>`
--> src/main.rs:8:22
|
8 | println!("{}", n + 1);
| - ^ - {integer}
| |
| impl Future<Output = u32>

이 메시지는 n이 u32가 아니라 u32를 내놓는 퓨처라는 뜻이에요. let n = get_number().await;로 고치면 돼요. 함수가 아무것도 반환하지 않고 save_file();처럼 그냥 호출만 하면 경고만 나와요. 그러니 경고를 꼭 읽으세요.

warning: unused implementer of `Future` that must be used
|
7 | save_file();
| ^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them

마지막 줄은 “퓨처는 .await하거나 폴링(poll)하지 않으면 아무 일도 하지 않는다”는 뜻이에요.

비동기 코드 안에서 std::thread::sleep 쓰기. 이건 문제없이 컴파일되기 때문에 더 교묘해요.

async fn slow_task() {
std::thread::sleep(std::time::Duration::from_secs(1)); // 스레드 전체를 멈춰 버려요!
}

std::thread::sleep은 스레드 전체를 얼려 버려요. 런타임이 다른 작업으로 넘어갈 수 없으니, 그 스레드 위의 모든 게 멈춰요. 한 테이블 앞에 선 채로 잠들어 버린 종업원 같아요. 비동기 코드 안에서는 항상 tokio::time::sleep(...).await를 쓰세요. std::fs로 거대한 파일을 읽는 것처럼 느리고 블로킹되는 다른 작업도 마찬가지예요. tokio에는 비동기 버전(tokio::fs)이 있고, tokio::task::spawn_blocking을 쓸 수도 있어요.

비동기가 CPU 작업을 빠르게 해 줄 거라고 기대하기. 비동기는 작업이 기다리고 있을 때 도움이 돼요. 이미지 크기 조절이나 숫자 계산처럼 작업이 바쁠 때는 도움이 안 돼요. 종업원이 주문을 더 많이 받는다고 요리가 빨라지지는 않잖아요. 무거운 CPU 작업에는 스레드(또는 rayon 같은 크레이트)를 쓰세요.

비동기를 쓰면 좋을 때 스레드를 쓰면 좋을 때
대부분의 시간을 기다리는 데 써요(네트워크, 데이터베이스, 타이머) 작업이 무거운 계산이에요
수천 개의 작업이 한꺼번에 필요해요(웹 서버처럼) 몇 가지만 병렬로 실행하면 돼요
reqwest나 axum 같은 비동기 크레이트를 쓰고 있어요 런타임 의존성을 추가하고 싶지 않아요

꼭 하나만 골라야 하는 건 아니에요. 실제 프로그램 중에는 네트워킹에는 비동기를, 무거운 작업에는 스레드를 쓰는 경우가 많아요.

태스크끼리 메시지를 주고받아야 하나요? tokio에는 자체 채널인 tokio::sync::mpsc가 있어요. 채널 페이지의 표준 채널과 비슷하게 동작하지만, rx.recv().await라고 써요. 그래서 메시지를 기다리는 동안 스레드가 멈추지 않아요.

비동기 Rust 생태계의 대부분은 tokio 위에 만들어져 있어요. 어디서나 보게 될 크레이트 두 개를 소개할게요.

  • reqwest (영어): 웹 API를 호출하는 HTTP 클라이언트예요. 페이지를 가져오려면 reqwest::get(url).await라고 써요.
  • axum (영어): 서버와 API를 만드는 웹 프레임워크로, tokio 팀이 만들었어요.

둘 다 JSON을 읽고 쓰는 serde_json과 잘 어울려요.

1. 아침 식사를 한꺼번에 준비하기

섹션 제목: “1. 아침 식사를 한꺼번에 준비하기”

비동기 함수 세 개를 작성하세요. make_toast(30ms 잠듦), fry_eggs(20ms 잠듦), brew_coffee(10ms 잠듦)예요. 각 함수는 음식 이름을 담은 String을 반환해요. 세 함수를 동시에 실행하고, 준비된 음식을 출력하세요. 힌트: tokio::join!은 여러 퓨처를 받아서 그 결과들을 튜플로 돌려줘요.

정답 보기
use std::time::Duration;
use tokio::time::sleep;
async fn make_toast() -> String {
sleep(Duration::from_millis(30)).await;
String::from("토스트")
}
async fn fry_eggs() -> String {
sleep(Duration::from_millis(20)).await;
String::from("달걀 프라이")
}
async fn brew_coffee() -> String {
sleep(Duration::from_millis(10)).await;
String::from("커피")
}
#[tokio::main]
async fn main() {
let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee());
println!("아침 식사: {}, {}, {}", toast, eggs, coffee);
}

실행 결과:

아침 식사: 토스트, 달걀 프라이, 커피
▶ 직접 실행해 보기

커피가 가장 먼저 준비되지만, 튜플은 언제나 퓨처를 나열한 순서대로 돌아와요. 아침 식사 전체는 60ms가 아니라 30ms(가장 느린 요리)쯤 걸려요.

2. 백그라운드에서 제곱 계산하기

섹션 제목: “2. 백그라운드에서 제곱 계산하기”

10 * n밀리초 동안 잠든 뒤 n * n을 반환하는 async fn square_later(n: u64) -> u64를 작성하세요. main에서는 1부터 4까지 숫자마다 태스크를 하나씩 만들고, 모두 await해서 합계를 출력하세요. 힌트: tokio::spawn(...) 핸들을 하나씩 Vec에 넣고, 핸들을 돌면서 handle.await.unwrap()을 더하세요.

정답 보기
use std::time::Duration;
use tokio::time::sleep;
async fn square_later(n: u64) -> u64 {
sleep(Duration::from_millis(10 * n)).await;
n * n
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in 1..=4 {
handles.push(tokio::spawn(square_later(n)));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("합계 = {total}");
}

실행 결과:

합계 = 30
▶ 직접 실행해 보기

네 태스크가 모두 바로 시작해서 동시에 잠들기 때문에, 10 + 20 + 30 + 40 = 100ms가 아니라 40ms쯤 걸려요.

  • 비동기를 쓰면 스레드 하나가 여러 작업을 처리할 수 있어요. 작업들이 기다리는 동안 서로 번갈아 실행하거든요.
  • Rust에서 비동기 코드를 실행하려면 런타임이 필요해요. cargo add tokio --features full로 tokio를 추가하고, main에 #[tokio::main]을 붙이세요.
  • async fn은 퓨처를 반환해요. 퓨처는 게을러서 .await하기 전까지 아무것도 실행되지 않아요.
  • 여러 퓨처를 한꺼번에 기다릴 때는 tokio::join!을, 태스크를 시작하고 JoinHandle을 받으려면 tokio::spawn을 써요.
  • 비동기 코드에서는 절대 std::thread::sleep을 쓰지 마세요. tokio::time::sleep(...).await를 쓰세요.
  • 비동기는 기다리는 일을 위한 거예요. 무거운 CPU 작업에는 스레드를 쓰세요.

이해도 점검

1. async fn을 .await 없이 호출하면 어떻게 될까요?

2. #[tokio::main]은 무슨 일을 할까요?

3. 두 퓨처가 각각 30ms, 20ms 동안 잠들어요. 둘에 tokio::join!을 쓰면 대략 얼마나 걸릴까요?

4. 비동기 코드 안에서 std::thread::sleep이 문제가 되는 이유는 무엇일까요?

5. 비동기가 가장 잘 어울리는 일은 무엇일까요?

점수: 0 / 5