
Rust의 `async`/`await`를 완전히 이해하기: 7가지 함정과 해결 패턴
요약
Rust의 비동기 프로그래밍(async/await)에서 발생하기 쉬운 7가지 주요 함정과 그 해결책을 다룹니다. Future의 지연 평가 특성, CPU 블로킹 문제, MutexGuard 스코프 관리 등 실무적인 개발 가이드를 제공합니다.
핵심 포인트
- Future는 지연 평가되므로 반드시 .await를 호출해야 실행됨
- 무거운 동기 작업은 spawn_blocking을 사용하여 런타임 블로킹 방지
- std::sync::MutexGuard가 await를 가로지르지 않도록 스코프 제한
- Send 트레이트 준수를 위해 Rc 대신 Arc 사용 권장
- Rust의 async는 "제로 코스트 추상화 (Zero-cost Abstraction)"이지만,
사용법을 잘못하면 의도하지 않은 블로킹 (Blocking)이 발생한다 -
Future는 폴링 모델 (Polling Model)이며,await를 하지 않는 한 실행되지 않는다 -
Send경계,MutexGuard의 스코프, 태스크 분할의 3가지 포인트가 빈번하게 발생하는 걸림돌이다 - 본 기사에서는 7가지 흔한 실수와 tokio 기반의 해결 코드를 제시한다
Rust의 비동기 프로그래밍은 "빠르고 안전하다"고 알려져 있지만, 처음 접할 때는 동작을 파악하기 어려운 함정이 다수 존재합니다.
2024~2026년에 걸쳐 tokio는 1.x 계열이 안정 운영 단계에 진입하였고, async-std와의 역할 분담도 정리되어 왔습니다. 지금이야말로 "왜 그렇게 동작하는가"를 한 번 체계적으로 정리할 타이밍입니다.
- Rust 2021 Edition 이후
tokio = { version = "1", features = ["full"] }사용cargo빌드가 통과되는 환경
가장 기초적이면서도 가장 알아차리기 어려운 함정입니다.
// ❌ NG: Future를 생성하고 있을 뿐 실행되지 않음
async fn fetch_data() -> String {
reqwest::get("https://example.com/api") // ← await가 없음
...
Rust는 Future를 지연 평가 (Lazy Evaluation) 합니다. .await를 호출하지 않으면 일절 실행되지 않습니다. 컴파일러가 경고를 해주기도 하지만, 체인 중간에서 await를 잊어버리면 경고가 나오지 않는 케이스가 있습니다.
// ✅ OK
async fn fetch_data() -> String {
reqwest::get("https://example.com/api")
...
교훈: async fn을 호출한 직후에는 반드시 .await가 있는지 육안으로 확인한다.
tokio의 기본 런타임 (Runtime)은 스레드 풀 (Thread Pool)을 가지지만, 1개의 태스크가 CPU를 블록 (Block)하면 스레드 풀 전체가 막힙니다.
// ❌ NG: 무거운 동기 처리를 async 태스크 내에서 직접 호출
#[tokio::main]
async fn main() {
...
// ✅ OK: spawn_blocking으로 스레드 풀을 분리한다
#[tokio::main]
async fn main() {
...
spawn_blocking은 전용 블로킹 스레드 풀에서 실행되므로, async 런타임을 막히게 하지 않습니다.
이는 컴파일 에러가 발생하는 경우와, 조용히 동작하지만 실제로는 데드락 (Deadlock)을 유발하는 경우 두 가지가 있습니다.
use std::sync::Mutex;
// ❌ NG: MutexGuard가 await를 가로지름
async fn update(data: Arc<Mutex<Vec<u32>>>) {
...
std::sync::MutexGuard는 Send를 구현하지 않았기 때문에, tokio::spawn에 전달하면 컴파일 에러가 발생합니다. 하지만 싱글 태스크 문맥에서는 통과되어 버려 데드락의 원인이 됩니다.
// ✅ OK 안① 스코프를 한정한다
async fn update(data: Arc<Mutex<Vec<u32>>>) {
{
...
tokio::sync::Mutex는 await를 가로질러도 안전합니다. 다만, 락 (Lock) 시간이 짧다면 안①이 더 효율적입니다.
// ❌ NG: Rc는 Send가 아님
use std::rc::Rc;
#[tokio::main]
...
에러 메시지:
error[E0277]: `Rc<i32>` cannot be sent between threads safely
tokio::spawn은 태스크를 스레드 간에 이동할 수 있도록 Future: Send를 요구합니다.
// ✅ OK: Arc를 사용한다
use std::sync::Arc;
#[tokio::main]
...
싱글 스레드 런타임을 사용하는 경우에는 tokio::task::LocalSet + spawn_local을 통해 !Send 타입도 다룰 수 있습니다.
// ✅ OK: 싱글 스레드 전용
#[tokio::main(flavor = "current_thread")]
async fn main() {
...
Rust 1.75 이전에는 trait 내에서 async fn을 직접 선언할 수 없었습니다.
// ❌ NG (Rust 1.74 이전)
trait Fetcher {
async fn fetch(&self) -> String;
...
}
**Rust 1.75 (2023-12-28 stable)**부터 trait 내 async fn이 안정화되었습니다. 다만, 객체 안전성 (Object Safety) (dyn Trait)와의 조합에는 아직 제한이 있습니다.
// ✅ OK: Rust 1.75+ 에서의 직접 기술
trait Fetcher {
async fn fetch(&self) -> String;
...
}
async-trait 크레이트는 내부적으로 Pin<Box<dyn Future>>로 디슈가링 (desugaring)하기 때문에 dyn Fetcher를 사용할 수 있습니다. Rust 1.75+에서도 객체 안전한 trait이 필요한 경우에는 async-trait가 현실적인 해결책입니다.
// ❌ NG: yield 포인트가 없어 다른 태스크가 기아 상태 (starvation)에 빠짐
async fn polling_loop(flag: Arc<AtomicBool>) {
loop {
...
}
}
tokio의 협력적 스케줄링 (Cooperative Scheduling)에서는 await가 yield 포인트가 됩니다.
await가 없는 루프는 런타임을 독점합니다.
// ✅ OK: tokio::task::yield_now()로 명시적으로 yield
async fn polling_loop(flag: Arc<AtomicBool>) {
loop {
...
}
}
// ❌ NG: tokio::spawn의 클로저 내에서 ?를 사용하면 반환 타입이 맞지 않음
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
...
}
tokio::spawn이 반환하는 JoinHandle<T>의 T는 Result<(), E>여도 괜찮지만, ?를 사용하려면 클로저의 반환 타입(return type)을 명시해야 합니다.
// ✅ OK: 반환 타입을 명시하고, JoinHandle을 await 함
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
...
}
handle.await는 Result<Result<(), E>, JoinError>를 반환하기 때문에, ??로 이중 unwrap이 필요합니다. anyhow 크레이트를 사용하면 훨씬 단순해집니다.
// ✅ anyhow를 사용하는 경우
use anyhow::Result;
#[tokio::main]
...
| # | 함정 | 해결책 |
|---|---|---|
| ① | await 누락 | 체인 전체를 육안으로 확인 |
| ② | 무거운 동기 처리의 블로킹 (Blocking) | spawn_blocking으로 분리 |
| ③ | MutexGuard를 await 사이에 유지 | 스코프 분리 또는 tokio::sync::Mutex |
| ④ | !Send 타입을 spawn에 전달 | Arc로 변환 또는 spawn_local |
| ⑤ | trait 내 async fn | Rust 1.75+ 직접 기술 또는 async-trait |
| ⑥ | await 없는 무한 루프 | yield_now 또는 슬립 (sleep) 삽입 |
| ⑦ | ? 타입 불일치 | 반환 타입 명시 또는 anyhow |
Rust의 async는 "마법"이 아니라, **폴링 기반의 협력적 스케줄링 (Polling-based Cooperative Scheduling)**이라는 명확한 모델로 동작합니다. 모델을 이해하고 있다면, 에러 메시지도 "당연한 결과"라고 읽을 수 있게 됩니다.
공식 문서인 Asynchronous Programming in Rust와 tokio의 튜토리얼(tutorial)은 특히 내용이 알차므로, 더 깊이 있게 파고들고 싶은 분들은 꼭 참조하시기 바랍니다.
- The Rust Programming Language — async/await
- Asynchronous Programming in Rust (async-book)
- tokio 공식 문서
- async-trait 크레이트 (crate)
- anyhow 크레이트 (crate)
- Rust 1.75 릴리스 노트 (async fn in traits)
✍️ 본 기사 저자: 合同会社ジモラボ (Jimolabo LLC)
Jimolabo는 하치오지를 거점으로 AI를 활용한 SaaS를 다수 개발하고 있습니다. 본 기사의 기술 검증 또한 그러한 개발 과정의 부산물입니다.
- 🌐 공식 사이트: https://locallab.jp
- 🔍 AI SEO 최적화 SaaS: lookupai.jp
- 📺 YouTube: @locallab_llc
- ✉️ 문의하기: info@locallab.jp
관심이 생기셨다면, 각 SNS 팔로우도 꼭 부탁드립니다!
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기