본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 4장: async/await와 Tokio 런타임
2026년 2월 17일·프로그래밍·

4장: async/await와 Tokio 런타임

Rust의 비동기 프로그래밍 기초부터 Future 트레이트, Tokio 런타임, spawn/select/join, 채널 기반 동시성 패턴까지 체계적으로 다룹니다.

15분789자12개 섹션
rust
공유
rust-backend4 / 11
1234567891011
이전3장: Rust 타입 시스템과 에러 처리다음5장: Axum 웹 프레임워크 기초

학습 목표

  • Rust의 비동기 프로그래밍 모델과 Future 트레이트를 이해합니다
  • Tokio 런타임의 구조와 동작 방식을 파악합니다
  • spawn, select, join 등 핵심 동시성 도구를 익힙니다
  • 채널을 활용한 태스크 간 통신 패턴을 학습합니다

왜 비동기 프로그래밍인가

백엔드 서버는 본질적으로 I/O 바운드(I/O bound) 작업이 대부분입니다. 데이터베이스 쿼리, HTTP 요청, 파일 읽기 등 외부 자원을 기다리는 시간이 실제 CPU 연산 시간보다 압도적으로 깁니다.

동기 방식에서는 I/O를 기다리는 동안 스레드가 차단(block)됩니다. 1,000개의 동시 요청을 처리하려면 1,000개의 스레드가 필요하고, 각 스레드는 약 2-8MB의 스택 메모리를 차지합니다.

비동기 방식에서는 I/O를 기다리는 동안 다른 작업을 처리합니다. 소수의 OS 스레드로 수만 개의 동시 작업을 효율적으로 처리할 수 있습니다.

Future 트레이트

Rust의 비동기 프로그래밍은 Future 트레이트를 기반으로 합니다. Future는 "아직 완료되지 않았지만 나중에 값을 생산할 계산"을 나타냅니다.

Future 트레이트 (간소화)
rust
// 표준 라이브러리의 Future 트레이트 (간소화)
trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
 
enum Poll<T> {
    Ready(T),   // 값이 준비됨
    Pending,    // 아직 준비되지 않음
}

직접 Future를 구현할 일은 거의 없습니다. async fn과 await 키워드가 컴파일러 수준에서 Future를 자동 생성합니다.

async/await 기초
rust
// async fn은 Future를 반환하는 함수
async fn fetch_user(id: u64) -> Result<User, Error> {
    // .await로 Future의 완료를 기다림
    let row = sqlx::query_as("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(&pool)
        .await?;
 
    Ok(row)
}
Info

Rust의 Future는 게으른(lazy) 특성을 가집니다. Future를 생성하는 것만으로는 아무것도 실행되지 않으며, .await하거나 런타임에 spawn해야 실행이 시작됩니다. JavaScript의 Promise와 다른 점입니다.

Tokio 런타임

Tokio는 Rust의 사실상 표준 비동기 런타임입니다. Future를 실행하고 I/O 이벤트를 관리하는 역할을 합니다.

Tokio 런타임 설정
rust
// 가장 일반적인 방법: 매크로로 런타임 설정
#[tokio::main]
async fn main() {
    println!("Tokio 런타임이 실행 중입니다");
 
    let result = fetch_data().await;
    println!("결과: {:?}", result);
}
 
// 또는 수동으로 런타임 생성
fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .enable_all()
        .build()
        .unwrap();
 
    rt.block_on(async {
        println!("수동 런타임에서 실행 중");
    });
}

런타임 종류

런타임매크로용도
멀티스레드#[tokio::main]웹 서버, 일반 애플리케이션
싱글스레드#[tokio::main(flavor = "current_thread")]경량 CLI, 테스트

멀티스레드 런타임은 기본적으로 CPU 코어 수만큼 워커 스레드를 생성하고, 작업 탈취(work stealing) 알고리즘으로 태스크를 분배합니다.

spawn: 태스크 생성

tokio::spawn으로 독립적인 비동기 태스크를 생성합니다. 스레드보다 훨씬 가볍고(수 백 바이트), 수십만 개를 동시에 실행할 수 있습니다.

tokio::spawn
rust
use tokio::time::{sleep, Duration};
 
#[tokio::main]
async fn main() {
    // 독립적인 태스크 생성
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        "태스크 완료"
    });
 
    // 다른 작업 수행 가능
    println!("태스크 대기 중...");
 
    // 태스크 결과 대기
    let result = handle.await.unwrap();
    println!("결과: {}", result);
}
Warning

tokio::spawn으로 생성한 태스크에 전달하는 데이터는 'static 라이프타임이어야 합니다. 즉, 빌린 참조를 직접 전달할 수 없고, 소유권을 이동하거나 Arc로 감싸야 합니다. 이는 태스크가 언제 종료될지 알 수 없기 때문입니다.

join: 병렬 실행

여러 Future를 동시에 실행하고 모든 결과를 기다릴 때 tokio::join!을 사용합니다.

tokio::join! 매크로
rust
use tokio::time::{sleep, Duration};
 
async fn fetch_user(id: u64) -> String {
    sleep(Duration::from_millis(100)).await;
    format!("User {}", id)
}
 
async fn fetch_posts(user_id: u64) -> Vec<String> {
    sleep(Duration::from_millis(150)).await;
    vec![format!("Post by {}", user_id)]
}
 
async fn fetch_notifications(user_id: u64) -> u32 {
    sleep(Duration::from_millis(80)).await;
    42
}
 
#[tokio::main]
async fn main() {
    // 세 요청을 동시에 실행 — 총 소요 시간은 가장 긴 것(150ms)
    let (user, posts, notifications) = tokio::join!(
        fetch_user(1),
        fetch_posts(1),
        fetch_notifications(1),
    );
 
    println!("사용자: {}", user);
    println!("게시물: {:?}", posts);
    println!("알림: {}", notifications);
}

순차적으로 실행하면 100 + 150 + 80 = 330ms가 걸리지만, join!으로 병렬 실행하면 약 150ms만 걸립니다.

select: 경쟁 실행

여러 Future 중 가장 먼저 완료되는 것을 처리할 때 tokio::select!를 사용합니다.

tokio::select! 매크로
rust
use tokio::time::{sleep, Duration};
 
async fn api_call() -> String {
    sleep(Duration::from_secs(2)).await;
    "API 응답".to_string()
}
 
#[tokio::main]
async fn main() {
    // 타임아웃 패턴
    tokio::select! {
        result = api_call() => {
            println!("응답 받음: {}", result);
        }
        _ = sleep(Duration::from_secs(1)) => {
            println!("타임아웃! 1초 초과");
        }
    }
}

select!는 graceful shutdown 패턴에서도 핵심적으로 사용됩니다.

Graceful Shutdown 패턴
rust
use tokio::signal;
 
async fn run_server() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
 
    println!("서버 시작: 0.0.0.0:3000");
 
    loop {
        tokio::select! {
            Ok((socket, addr)) = listener.accept() => {
                println!("새 연결: {}", addr);
                tokio::spawn(handle_connection(socket));
            }
            _ = signal::ctrl_c() => {
                println!("종료 신호 수신, 서버를 종료합니다...");
                break;
            }
        }
    }
}
 
async fn handle_connection(socket: tokio::net::TcpStream) {
    // 연결 처리 로직
}

채널 (Channels)

Tokio는 태스크 간 통신을 위해 여러 종류의 **채널(channel)**을 제공합니다. 공유 상태(shared state)보다 메시지 패싱을 선호하는 것이 Rust의 관례입니다.

mpsc: 다대일 채널

**mpsc(multi-producer, single-consumer)**는 여러 송신자가 하나의 수신자에게 메시지를 보내는 채널입니다.

mpsc 채널
rust
use tokio::sync::mpsc;
 
#[derive(Debug)]
enum Command {
    Get { key: String, resp: tokio::sync::oneshot::Sender<Option<String>> },
    Set { key: String, value: String },
}
 
#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<Command>(32); // 버퍼 크기 32
 
    // 수신 태스크: 명령을 처리하는 단일 태스크
    let manager = tokio::spawn(async move {
        let mut store = std::collections::HashMap::new();
 
        while let Some(cmd) = rx.recv().await {
            match cmd {
                Command::Set { key, value } => {
                    store.insert(key, value);
                }
                Command::Get { key, resp } => {
                    let value = store.get(&key).cloned();
                    let _ = resp.send(value);
                }
            }
        }
    });
 
    // 송신 태스크: 여러 곳에서 명령을 보냄
    let tx2 = tx.clone();
 
    tx.send(Command::Set {
        key: "name".to_string(),
        value: "Rust".to_string(),
    }).await.unwrap();
 
    let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
    tx2.send(Command::Get {
        key: "name".to_string(),
        resp: resp_tx,
    }).await.unwrap();
 
    let value = resp_rx.await.unwrap();
    println!("값: {:?}", value); // Some("Rust")
}

채널 종류 비교

채널패턴용도
mpsc다대일작업 큐, 명령 패턴
oneshot일대일요청-응답, 단발성 결과 전달
broadcast일대다이벤트 알림, 구독 시스템
watch일대다 (최신값)설정 변경 알림, 상태 모니터링

Arc + Mutex vs 메시지 패싱

공유 상태가 필요할 때 두 가지 접근법이 있습니다.

Arc + Mutex 방식
rust
use std::sync::Arc;
use tokio::sync::Mutex;
 
#[derive(Clone)]
struct SharedState {
    counter: Arc<Mutex<u64>>,
}
 
async fn increment(state: SharedState) {
    let mut counter = state.counter.lock().await;
    *counter += 1;
}
메시지 패싱 방식
rust
use tokio::sync::mpsc;
 
enum Message {
    Increment,
    GetCount(tokio::sync::oneshot::Sender<u64>),
}
 
async fn counter_actor(mut rx: mpsc::Receiver<Message>) {
    let mut count: u64 = 0;
 
    while let Some(msg) = rx.recv().await {
        match msg {
            Message::Increment => count += 1,
            Message::GetCount(resp) => { let _ = resp.send(count); }
        }
    }
}
Tip

일반적인 지침은 다음과 같습니다. 단순한 공유 상태(카운터, 캐시)에는 Arc + Mutex가 간편합니다. 복잡한 상태 관리나 여러 연산을 조율해야 하는 경우에는 메시지 패싱(actor 패턴)이 더 명확하고 안전합니다.

비동기 코드에서 주의할 점

Send + Sync 바운드

tokio::spawn에 전달하는 Future는 Send 트레이트를 구현해야 합니다. 즉, 스레드 간에 안전하게 이동할 수 있어야 합니다.

Send가 아닌 타입 문제
rust
use std::rc::Rc; // Rc는 Send가 아님
 
async fn problematic() {
    let data = Rc::new(42);
    // .await 지점을 넘어서 Rc를 사용하면 컴파일 에러
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("{}", data); // 에러! Rc는 Send가 아님
}
 
async fn fixed() {
    let data = std::sync::Arc::new(42); // Arc는 Send + Sync
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("{}", data); // OK
}

블로킹 작업 주의

비동기 태스크 안에서 CPU 집약적이거나 동기 블로킹 작업을 수행하면 전체 런타임이 느려집니다. tokio::task::spawn_blocking을 사용하세요.

블로킹 작업 분리
rust
async fn hash_password(password: String) -> String {
    // CPU 집약적 작업은 별도 스레드풀에서 실행
    tokio::task::spawn_blocking(move || {
        // argon2 해싱은 의도적으로 느린 연산
        argon2_hash(&password)
    })
    .await
    .unwrap()
}
 
fn argon2_hash(password: &str) -> String {
    // 실제 해싱 로직
    format!("hashed_{}", password)
}

실전 패턴: 비동기 웹 서버 구조

이제까지 배운 내용을 종합하면 다음과 같은 구조가 됩니다.

비동기 웹 서버 기본 구조
rust
use std::sync::Arc;
use tokio::sync::RwLock;
 
struct AppState {
    db: String, // 실제로는 sqlx::PgPool
    cache: Arc<RwLock<std::collections::HashMap<String, String>>>,
}
 
#[tokio::main]
async fn main() {
    // 1. 상태 초기화
    let state = Arc::new(AppState {
        db: "postgresql://...".to_string(),
        cache: Arc::new(RwLock::new(std::collections::HashMap::new())),
    });
 
    // 2. 백그라운드 태스크 (캐시 정리 등)
    let cache = state.cache.clone();
    tokio::spawn(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
            let mut cache = cache.write().await;
            cache.clear();
            println!("캐시 정리 완료");
        }
    });
 
    // 3. 서버 시작 (다음 장에서 Axum으로 구현)
    println!("서버가 시작되었습니다");
 
    // 4. Graceful shutdown 대기
    tokio::signal::ctrl_c().await.unwrap();
    println!("서버를 종료합니다");
}

정리

이 장에서는 Rust의 비동기 프로그래밍과 Tokio 런타임을 다루었습니다.

  • Rust의 Future는 게으르게 동작하며, .await하거나 spawn해야 실행됩니다
  • Tokio는 사실상 표준 비동기 런타임으로, 멀티스레드/싱글스레드 모드를 지원합니다
  • **join!**은 병렬 실행, **select!**는 경쟁 실행을 처리합니다
  • **채널(mpsc, oneshot, broadcast)**을 통한 메시지 패싱이 공유 상태보다 선호됩니다
  • 블로킹 작업은 spawn_blocking으로 분리해야 합니다

다음 장에서는 Axum 웹 프레임워크 기초를 다룹니다. 지금까지 배운 소유권, 타입 시스템, 비동기 프로그래밍이 실제 웹 서버 구축에 어떻게 적용되는지 확인합니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#rust

관련 글

프로그래밍

5장: Axum 웹 프레임워크 기초

Axum의 Tower 기반 아키텍처, 라우팅, 핸들러, 추출자, 응답 타입, 미들웨어까지 Hello World에서 CRUD API까지 실습합니다.

2026년 2월 19일·10분
프로그래밍

3장: Rust 타입 시스템과 에러 처리

구조체, 열거형, 트레이트부터 Result/Option, thiserror/anyhow를 활용한 에러 처리 패턴까지 Rust 타입 시스템의 핵심을 다룹니다.

2026년 2월 15일·14분
프로그래밍

6장: Axum 고급 패턴

중첩 라우터, 인증/인가 미들웨어, 요청 검증, 웹소켓, SSE, API 테스트까지 프로덕션 수준의 Axum 패턴을 다룹니다.

2026년 2월 21일·10분
이전 글3장: Rust 타입 시스템과 에러 처리
다음 글5장: Axum 웹 프레임워크 기초

댓글

목차

약 15분 남음
  • 학습 목표
  • 왜 비동기 프로그래밍인가
  • Future 트레이트
  • Tokio 런타임
    • 런타임 종류
  • spawn: 태스크 생성
  • join: 병렬 실행
  • select: 경쟁 실행
  • 채널 (Channels)
    • mpsc: 다대일 채널
    • 채널 종류 비교
  • Arc + Mutex vs 메시지 패싱
  • 비동기 코드에서 주의할 점
    • Send + Sync 바운드
    • 블로킹 작업 주의
  • 실전 패턴: 비동기 웹 서버 구조
  • 정리