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 스레드로 수만 개의 동시 작업을 효율적으로 처리할 수 있습니다.
Rust의 비동기 프로그래밍은 Future 트레이트를 기반으로 합니다. Future는 "아직 완료되지 않았지만 나중에 값을 생산할 계산"을 나타냅니다.
// 표준 라이브러리의 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 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)
}Rust의 Future는 게으른(lazy) 특성을 가집니다. Future를 생성하는 것만으로는 아무것도 실행되지 않으며, .await하거나 런타임에 spawn해야 실행이 시작됩니다. JavaScript의 Promise와 다른 점입니다.
Tokio는 Rust의 사실상 표준 비동기 런타임입니다. Future를 실행하고 I/O 이벤트를 관리하는 역할을 합니다.
// 가장 일반적인 방법: 매크로로 런타임 설정
#[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) 알고리즘으로 태스크를 분배합니다.
tokio::spawn으로 독립적인 비동기 태스크를 생성합니다. 스레드보다 훨씬 가볍고(수 백 바이트), 수십만 개를 동시에 실행할 수 있습니다.
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);
}tokio::spawn으로 생성한 태스크에 전달하는 데이터는 'static 라이프타임이어야 합니다. 즉, 빌린 참조를 직접 전달할 수 없고, 소유권을 이동하거나 Arc로 감싸야 합니다. 이는 태스크가 언제 종료될지 알 수 없기 때문입니다.
여러 Future를 동시에 실행하고 모든 결과를 기다릴 때 tokio::join!을 사용합니다.
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만 걸립니다.
여러 Future 중 가장 먼저 완료되는 것을 처리할 때 tokio::select!를 사용합니다.
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 패턴에서도 핵심적으로 사용됩니다.
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) {
// 연결 처리 로직
}Tokio는 태스크 간 통신을 위해 여러 종류의 **채널(channel)**을 제공합니다. 공유 상태(shared state)보다 메시지 패싱을 선호하는 것이 Rust의 관례입니다.
**mpsc(multi-producer, single-consumer)**는 여러 송신자가 하나의 수신자에게 메시지를 보내는 채널입니다.
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 | 일대다 (최신값) | 설정 변경 알림, 상태 모니터링 |
공유 상태가 필요할 때 두 가지 접근법이 있습니다.
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;
}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); }
}
}
}일반적인 지침은 다음과 같습니다. 단순한 공유 상태(카운터, 캐시)에는 Arc + Mutex가 간편합니다. 복잡한 상태 관리나 여러 연산을 조율해야 하는 경우에는 메시지 패싱(actor 패턴)이 더 명확하고 안전합니다.
Send + Sync 바운드tokio::spawn에 전달하는 Future는 Send 트레이트를 구현해야 합니다. 즉, 스레드 간에 안전하게 이동할 수 있어야 합니다.
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을 사용하세요.
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)
}이제까지 배운 내용을 종합하면 다음과 같은 구조가 됩니다.
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 런타임을 다루었습니다.
.await하거나 spawn해야 실행됩니다다음 장에서는 Axum 웹 프레임워크 기초를 다룹니다. 지금까지 배운 소유권, 타입 시스템, 비동기 프로그래밍이 실제 웹 서버 구축에 어떻게 적용되는지 확인합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Axum의 Tower 기반 아키텍처, 라우팅, 핸들러, 추출자, 응답 타입, 미들웨어까지 Hello World에서 CRUD API까지 실습합니다.
구조체, 열거형, 트레이트부터 Result/Option, thiserror/anyhow를 활용한 에러 처리 패턴까지 Rust 타입 시스템의 핵심을 다룹니다.
중첩 라우터, 인증/인가 미들웨어, 요청 검증, 웹소켓, SSE, API 테스트까지 프로덕션 수준의 Axum 패턴을 다룹니다.