Rust async/await
식당을 떠올려 보세요. 동시성 페이지에서 스레드를 배웠죠. 스레드는 테이블마다 종업원을 한 명씩 두는 것과 비슷해요. 잘 돌아가긴 하지만, 종업원은 비싸고, 대부분의 시간 동안 주방만 바라보며 기다리고 서 있어요.
비동기(async)는 종업원 한 명이 여러 주문을 받는 것과 비슷해요. 종업원은 1번 테이블의 주문을 받아 주방에 넘기고, 음식이 만들어지는 동안 2번 테이블로 가요. 아무도 하는 일 없이 서 있지 않아요. 이게 Rust의 async와 .await의 핵심 아이디어예요. 한 작업이 (네트워크, 파일, 타이머를) 기다리는 동안 다른 작업이 실행될 수 있어요.
비동기는 프로그램이 기다리는 데 많은 시간을 쓸 때 빛을 발해요. 웹 서버, API 클라이언트, 채팅 앱, 봇 같은 것들이요. 이 페이지에서는 Rust에서 가장 많이 쓰는 비동기 런타임인 tokio를 사용해요.
tokio 준비하기
섹션 제목: “tokio 준비하기”Rust 표준 라이브러리는 async와 .await 키워드는 주지만, 비동기 코드를 실제로 실행하는 “엔진”은 주지 않아요. 그 엔진을 런타임(runtime)이라고 하고, 대부분의 프로젝트가 tokio를 써요. 새 프로젝트에 추가해 볼게요.
cargo new async_democd async_democargo 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쯤만 걸려요. 실제 네트워크 호출에서는 이 차이가 금방 커져요.
tokio::spawn으로 태스크 만들기
섹션 제목: “tokio::spawn으로 태스크 만들기”퓨처의 개수가 정해져 있을 때는 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
더 알아보기
섹션 제목: “더 알아보기”- Fundamentals of Asynchronous Programming — The Rust Book (영어): 퓨처,
async,.await, 스트림을 다루는 공식 장이에요. 한국어판에는 아직 이 장이 없어요. - tokio 튜토리얼 (영어): 작은 Redis 비슷한 서버를 직접 만들면서 태스크, 공유 상태, 채널을 배우는 실습 가이드예요.
- docs.rs의 tokio (영어):
tokio::spawn,tokio::time,tokio::sync등의 API 레퍼런스예요. - Asynchronous Programming in Rust (async book) (영어): 퓨처와 실행기(executor)가 내부에서 어떻게 동작하는지 더 깊이 살펴봐요.