Rust 비동기 런타임: Tokio 크레이트 사용법
Rust로 네트워크 서버를 만들거나 여러 작업을 동시에 처리해야 할 때, 어디서부터 시작해야 할지 막막한 적이 있으신가요? Rust는 async/await 문법을 언어 차원에서 지원하지만, 이걸 실제로 실행하려면 비동기 런타임이 필요합니다. 그리고 Rust 생태계에서 가장 널리 쓰이는 비동기 런타임이 바로 Tokio입니다.
이번 글에서는 Tokio가 무엇이고, 왜 필요한지부터 시작해서 실제로 비동기 코드를 작성하고 실행하는 방법까지 차근차근 다뤄보겠습니다.
비동기 프로그래밍이 필요한 이유
웹 서버를 하나 만든다고 생각해볼까요? 클라이언트가 요청을 보내면 서버는 데이터베이스를 조회하고, 외부 API를 호출하고, 파일을 읽는 등의 작업을 수행합니다. 이런 I/O 작업은 CPU 연산에 비해 훨씬 오래 걸리는데, 동기 방식으로 처리하면 하나의 요청이 끝날 때까지 다른 요청을 처리하지 못하고 멍하니 기다려야 합니다.
OS 스레드를 사용해서 각 요청을 별도 스레드에서 처리하는 방법도 있지만, 스레드 하나당 수 메가바이트의 스택 메모리를 차지하고 스레드 간 전환 비용도 무시할 수 없습니다. 동시 접속자가 수천, 수만 명이 되면 스레드만으로는 한계가 금방 드러나죠.
비동기 프로그래밍은 이 문제를 해결합니다. I/O 작업이 완료될 때까지 기다리는 동안 다른 작업을 처리할 수 있어서, 적은 수의 스레드로도 수많은 동시 작업을 무리 없이 소화할 수 있습니다.
Rust의 async/await과 런타임
Rust는 async/await 키워드를 제공하지만, 다른 언어와 좀 다른 점이 있습니다. Python이나 JavaScript는 언어 자체에 비동기 런타임이 내장되어 있는 반면, Rust는 런타임을 별도로 선택해야 합니다. 이건 Rust의 “사용하지 않는 것에 비용을 지불하지 않는다”는 철학 때문인데요, 모든 프로그램에 비동기 런타임을 강제하지 않겠다는 거죠.
async fn으로 비동기 함수를 선언하면, 이 함수는 호출했을 때 바로 실행되지 않고 Future라는 것을 반환합니다. Future는 “나중에 값을 줄게”라는 약속 같은 건데, 누군가가 이 Future를 .await 하거나 폴링(polling)해야 실제로 실행됩니다.
async fn hello() -> String {
"안녕하세요!".to_string()
}
// hello()를 호출하면 바로 실행되지 않고 Future를 반환
// .await를 해야 실제로 실행됨
let message = hello().await;
이 동작은 JavaScript의 Promise와 근본적으로 다릅니다. JavaScript에서 async function foo()를 호출하면 즉시 실행이 시작되고 Promise가 반환되지만, Rust의 async fn은 호출만으로는 아무 일도 일어나지 않습니다. Future라는 “실행 설명서”만 하나 반환될 뿐이죠. 다음 예제로 확인해볼 수 있습니다.
async fn do_work() {
println!("실제로 실행됨!");
}
let fut = do_work(); // 아무것도 출력되지 않음 (Future만 생성)
println!("Future를 만들었지만 아직 실행 전");
fut.await; // 이때 비로소 "실제로 실행됨!" 출력
Rust가 이렇게 게으른(lazy) 설계를 선택한 데는 이유가 있습니다. Future를 만들기만 하고 쓰지 않으면 할당이나 스케줄링이 일어나지 않아서 비용이 거의 제로에 가깝습니다. 또 Future를 drop하는 것만으로 진행 중인 작업을 취소할 수 있어서 자원 관리도 깔끔하고요. 뒤에서 살펴볼 tokio::join!이나 tokio::select! 같은 조합자들이 가능한 것도 Future가 “아직 시작되지 않은 작업의 설명”이라는 특성 덕분입니다.
그래서 async fn은 “즉시 실행되는 함수”라기보다 “일시정지 가능한 함수의 설계도”를 만드는 키워드에 가깝습니다. 컴파일러는 이걸 .await 지점마다 일시정지와 재개가 가능한 상태 머신(state machine)으로 변환하고, 런타임이 이런 상태 머신들을 효율적으로 섞어가며 실행합니다.
그런데 이 .await는 async 함수 안에서만 쓸 수 있습니다. 그러면 맨 처음 async 함수는 누가 실행하느냐는 의문이 생기죠. 바로 여기서 비동기 런타임이 필요합니다. 런타임이 최상위 Future를 받아서 완료될 때까지 실행하는 역할을 합니다.
Tokio란?
Tokio는 Rust 생태계에서 사실상의 표준 비동기 런타임입니다. Future를 실행해주는 것뿐만 아니라 서버 개발에 필요한 도구들을 두루 갖추고 있습니다.
우선 멀티스레드 태스크 스케줄러가 비동기 태스크를 여러 OS 스레드에 골고루 분배해줍니다. TCP/UDP 소켓이나 파일 시스템 같은 I/O 작업도 비동기로 처리할 수 있고, 타이머, 채널, 동기화 프리미티브(Mutex, RwLock 등)까지 갖추고 있습니다.
axum, reqwest, sqlx 같은 Rust의 인기 있는 웹 프레임워크와 라이브러리가 대부분 Tokio 위에서 동작하기 때문에, Rust로 서버 프로그래밍을 한다면 Tokio는 거의 필수입니다.
프로젝트 설정
새로운 Rust 프로젝트를 만들고 Tokio를 추가해보겠습니다.
cargo new tokio-demo
cd tokio-demo
Cargo.toml에 Tokio 의존성을 추가합니다.
[dependencies]
tokio = { version = "1", features = ["full"] }
features = ["full"]은 Tokio의 모든 기능을 활성화합니다. Tokio는 기능별로 피처 플래그(feature flag)가 나뉘어 있어서, 필요한 것만 골라 쓸 수도 있습니다. 예를 들어 rt는 런타임, net은 네트워크, time은 타이머 기능인데, 처음 배울 때는 "full"로 시작하는 게 편합니다.
첫 번째 비동기 프로그램
한번 코드를 작성해볼까요?
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("작업 시작!");
sleep(Duration::from_secs(1)).await;
println!("1초 후 완료!");
}
작업 시작!
1초 후 완료!
#[tokio::main]이 눈에 들어오시죠? 이 매크로가 하는 일은 생각보다 단순합니다. Tokio 런타임을 만들고 async fn main()을 그 위에서 실행해주는 겁니다. 매크로를 쓰지 않고 직접 작성하면 다음과 같습니다.
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
println!("작업 시작!");
tokio::time::sleep(Duration::from_secs(1)).await;
println!("1초 후 완료!");
});
}
코드에서 tokio::time::{sleep, Duration}으로 함께 가져오고 있는데 여기서 Duration은 Tokio만의 타입이 아니라 표준 라이브러리의 std::time::Duration을 re-export한 거예요.
sleep과 함께 쓰는 일이 많으니까 편의상 tokio::time에서 같이 가져올 수 있게 해둔 것이죠.
주의할 점은 tokio::time::sleep과 std::thread::sleep의 차이입니다. std::thread::sleep은 OS 스레드 자체를 멈추지만 tokio::time::sleep은 현재 태스크만 양보하고 스레드는 다른 태스크를 처리할 수 있어요.
태스크 스폰
Tokio의 핵심 기능 중 하나는 비동기 태스크를 생성(spawn)하여 동시에 실행할 수 있다는 점입니다. tokio::spawn은 OS 스레드를 새로 만드는 게 아니라, 런타임의 스레드 풀 위에서 경량 태스크를 생성합니다.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task1 = tokio::spawn(async {
sleep(Duration::from_secs(2)).await;
println!("태스크 1 완료 (2초)");
"결과 1"
});
let task2 = tokio::spawn(async {
sleep(Duration::from_secs(1)).await;
println!("태스크 2 완료 (1초)");
"결과 2"
});
// 두 태스크의 결과를 기다림
let result1 = task1.await.unwrap();
let result2 = task2.await.unwrap();
println!("{result1}, {result2}");
}
태스크 2 완료 (1초)
태스크 1 완료 (2초)
결과 1, 결과 2
태스크 2가 1초짜리이므로 먼저 완료되고, 태스크 1이 2초 뒤에 완료됩니다. 두 태스크가 동시에 실행되기 때문에 전체 소요 시간은 약 2초입니다. 순차적으로 실행했다면 3초가 걸렸을 거예요.
tokio::spawn이 반환하는 JoinHandle을 .await하면 태스크의 결과를 받을 수 있습니다. 태스크 안에서 패닉이 발생하면 JoinError를 반환하므로 unwrap() 대신 적절한 에러 처리를 해주는 것이 좋습니다.
join!으로 동시 실행
여러 Future를 동시에 실행하고 모두 완료될 때까지 기다리고 싶다면, tokio::join! 매크로를 사용할 수 있습니다.
use tokio::time::{sleep, Duration};
async fn fetch_user() -> String {
sleep(Duration::from_secs(2)).await;
"김토키".to_string()
}
async fn fetch_orders() -> Vec<String> {
sleep(Duration::from_secs(1)).await;
vec!["주문-001".to_string(), "주문-002".to_string()]
}
#[tokio::main]
async fn main() {
// 두 비동기 함수를 동시에 실행
let (user, orders) = tokio::join!(fetch_user(), fetch_orders());
println!("사용자: {user}");
println!("주문 목록: {orders:?}");
}
사용자: 김토키
주문 목록: ["주문-001", "주문-002"]
fetch_user가 2초, fetch_orders가 1초 걸리지만 동시에 실행되므로 총 2초 만에 결과를 받을 수 있습니다. API 서버에서 여러 데이터소스를 조합해야 할 때 흔히 쓰는 패턴이죠.
tokio::spawn과 tokio::join!의 차이가 궁금하실 텐데요. spawn은 새 태스크를 백그라운드에서 독립적으로 실행하고, join!은 현재 태스크 안에서 여러 Future를 동시에 실행합니다. spawn은 'static 수명이 필요하지만 join!은 그렇지 않아서 로컬 변수를 참조할 수 있다는 장점도 있습니다.
select!로 경쟁 실행
tokio::select!는 여러 Future 중 가장 먼저 완료되는 것 하나만 처리하고 나머지는 취소합니다. 타임아웃을 구현하거나, 여러 소스 중 먼저 도착하는 데이터를 처리할 때 유용합니다.
use tokio::time::{sleep, Duration};
async fn slow_api() -> String {
sleep(Duration::from_secs(5)).await;
"API 응답".to_string()
}
#[tokio::main]
async fn main() {
tokio::select! {
result = slow_api() => {
println!("API 응답: {result}");
}
_ = sleep(Duration::from_secs(3)) => {
println!("타임아웃! 3초 초과");
}
}
}
타임아웃! 3초 초과
slow_api가 5초 걸리지만, 3초 타임아웃이 먼저 완료되므로 “타임아웃!” 메시지가 출력되고 slow_api는 자동으로 취소됩니다. 이런 식으로 네트워크 요청에 타임아웃을 거는 패턴은 실무에서 정말 자주 사용됩니다.
비동기 채널
Tokio에는 태스크 간 메시지를 주고받을 수 있는 채널도 있습니다. 표준 라이브러리의 std::sync::mpsc와 비슷하지만, 비동기 환경에 맞게 설계되어 있어서 .await로 메시지를 기다릴 수 있습니다.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// 버퍼 크기 32인 채널 생성
let (tx, mut rx) = mpsc::channel(32);
// 송신 태스크
let tx_clone = tx.clone();
tokio::spawn(async move {
for i in 1..=3 {
tx_clone.send(format!("메시지 {i}")).await.unwrap();
}
});
// 원본 송신자도 메시지를 보냄
tokio::spawn(async move {
tx.send("추가 메시지".to_string()).await.unwrap();
});
// 수신 태스크: 모든 송신자가 drop될 때까지 수신
while let Some(msg) = rx.recv().await {
println!("수신: {msg}");
}
println!("모든 메시지 처리 완료");
}
수신: 메시지 1
수신: 메시지 2
수신: 메시지 3
수신: 추가 메시지
모든 메시지 처리 완료
mpsc는 여러 송신자(Multiple Producer)와 하나의 수신자(Single Consumer) 패턴입니다. 송신자를 clone()으로 복제하면 여러 태스크에서 같은 채널로 메시지를 보낼 수 있고, 모든 송신자가 드롭되면 rx.recv()가 None을 반환하면서 루프가 종료됩니다.
Tokio는 mpsc 외에도 oneshot, broadcast, watch 같은 여러 종류의 채널을 제공하는데요, 그중에서 oneshot이 실무에서 특히 자주 쓰입니다.
oneshot 채널
oneshot 채널은 이름 그대로 딱 한 번만 메시지를 보낼 수 있는 채널입니다. 송신자(Sender)와 수신자(Receiver)가 각각 하나씩이고, 메시지도 정확히 하나만 전달됩니다. “이 작업 끝나면 결과 좀 알려줘”라는 상황에 딱 맞는 도구죠.
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = 6 * 7;
tx.send(result).unwrap();
});
let value = rx.await.unwrap();
println!("결과: {value}");
}
결과: 42
oneshot::channel()로 채널을 만들면 송신자 tx와 수신자 rx가 반환됩니다. mpsc와 달리 버퍼 크기를 지정하지 않는데, 어차피 메시지가 하나뿐이니까요. 송신자의 send()는 비동기 함수가 아니라 일반 함수라서 .await 없이 바로 호출합니다. 반면 수신자는 rx.await로 메시지가 도착할 때까지 기다립니다.
oneshot이 진가를 발휘하는 건 mpsc와 조합해서 요청-응답 패턴을 만들 때입니다. 여러 클라이언트가 하나의 워커 태스크에 요청을 보내고, 각자 자신의 응답을 받아야 하는 상황을 생각해보세요.
use tokio::sync::{mpsc, oneshot};
struct Request {
query: String,
respond_to: oneshot::Sender<String>,
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<Request>(32);
// 워커 태스크: 요청을 받아 처리하고 응답을 돌려줌
tokio::spawn(async move {
while let Some(req) = rx.recv().await {
let response = format!("'{}' 검색 결과: 42건", req.query);
let _ = req.respond_to.send(response);
}
});
// 요청을 보내고 응답을 기다림
let (resp_tx, resp_rx) = oneshot::channel();
tx.send(Request {
query: "Rust Tokio".to_string(),
respond_to: resp_tx,
})
.await
.unwrap();
let response = resp_rx.await.unwrap();
println!("{response}");
}
'Rust Tokio' 검색 결과: 42건
요청마다 oneshot 채널을 하나씩 만들어서 Request 구조체에 송신자를 함께 넘기는 게 포인트입니다. 워커는 요청을 처리한 뒤 그 송신자로 응답을 보내고, 클라이언트는 자신이 가진 수신자로 응답을 받습니다. 이 패턴은 Tokio 공식 튜토리얼에서도 소개하는 대표적인 패턴으로, 액터 모델이나 서비스 핸들러를 구현할 때 뼈대가 됩니다.
비동기 Mutex
여러 태스크가 같은 데이터를 수정해야 할 때는 동기화가 필요합니다. Tokio에는 비동기 환경에 맞는 Mutex가 있는데, .await 지점을 넘겨 잠금을 유지해야 할 때 사용합니다.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = tokio::spawn(async move {
let mut lock = counter.lock().await;
*lock += 1;
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
println!("최종 값: {}", *counter.lock().await);
}
최종 값: 5
Arc로 여러 태스크에서 Mutex를 공유하고, .lock().await로 잠금을 획득합니다. 잠금을 기다리는 동안에도 스레드가 블로킹되지 않고 다른 태스크를 처리할 수 있다는 게 std::sync::Mutex와의 핵심 차이입니다.
다만 잠금 구간이 짧고 .await를 포함하지 않는다면 std::sync::Mutex가 오히려 성능이 좋습니다. tokio::sync::Mutex는 잠금을 유지한 채로 .await를 해야 하는 경우에만 사용하는 것이 좋습니다.
실전 예제: 간단한 TCP 에코 서버
지금까지 배운 내용을 종합해서, 여러 클라이언트의 요청을 동시에 처리하는 TCP 에코 서버를 만들어보겠습니다.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("서버 시작: 127.0.0.1:8080");
loop {
// 새 연결을 기다림
let (mut socket, addr) = listener.accept().await?;
println!("새 연결: {addr}");
// 각 연결을 별도 태스크에서 처리
tokio::spawn(async move {
let mut buf = [0; 1024];
loop {
// 데이터 읽기
let n = match socket.read(&mut buf).await {
Ok(0) => {
println!("연결 종료: {addr}");
return;
}
Ok(n) => n,
Err(e) => {
eprintln!("읽기 오류: {e}");
return;
}
};
// 받은 데이터를 그대로 돌려보냄
if let Err(e) = socket.write_all(&buf[..n]).await {
eprintln!("쓰기 오류: {e}");
return;
}
}
});
}
}
이 서버의 핵심은 tokio::spawn으로 각 클라이언트 연결을 독립적인 태스크로 처리한다는 점입니다. OS 스레드를 하나씩 생성하는 것이 아니라 Tokio의 스레드 풀 위에서 경량 태스크로 동작하기 때문에, 수천 개의 동시 연결도 거뜬히 감당할 수 있습니다.
다른 터미널에서 nc(netcat) 명령어로 테스트해볼 수 있습니다.
nc 127.0.0.1 8080
입력한 텍스트가 그대로 돌아오면 에코 서버가 정상적으로 동작하는 겁니다.
흔히 하는 실수들
Tokio를 처음 쓸 때 자주 마주치는 실수 몇 가지를 짚어보겠습니다.
첫 번째로 비동기 함수 안에서 std::thread::sleep을 호출하는 실수가 있습니다.
// ❌ 스레드 전체를 블로킹함
async fn bad() {
std::thread::sleep(Duration::from_secs(1));
}
// ✅ 현재 태스크만 양보
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
std::thread::sleep은 OS 스레드 자체를 멈추기 때문에 해당 스레드에서 실행 중인 다른 태스크까지 전부 멈춰버립니다. 비동기 코드 안에서는 반드시 tokio::time::sleep을 사용해야 합니다.
두 번째로 tokio::spawn에 넘기는 클로저에서 로컬 변수의 참조를 사용하려는 실수입니다.
// ❌ 컴파일 오류: 참조가 태스크보다 먼저 해제될 수 있음
let data = vec![1, 2, 3];
tokio::spawn(async {
println!("{:?}", data);
});
// ✅ move로 소유권을 이동
let data = vec![1, 2, 3];
tokio::spawn(async move {
println!("{:?}", data);
});
tokio::spawn으로 생성된 태스크는 'static 수명을 가져야 합니다. 태스크가 언제 실행될지, 얼마나 오래 살아있을지 알 수 없기 때문이죠. async move로 필요한 데이터의 소유권을 태스크 안으로 옮겨야 합니다. 여러 태스크에서 같은 데이터를 공유해야 한다면 Arc로 감싸서 사용하면 됩니다.
세 번째로 .await를 빼먹는 실수가 있습니다.
// ❌ Future를 생성만 하고 실행하지 않음
async fn main() {
some_async_function(); // 아무 일도 일어나지 않음
}
// ✅ .await로 실행
async fn main() {
some_async_function().await;
}
Rust의 Future는 게으릅니다(lazy). .await를 하지 않으면 아예 실행되지 않습니다. 다행히 컴파일러가 “unused future” 경고를 띄워주기는 하는데, 처음에는 놓치기 쉽습니다.
런타임 설정
기본 #[tokio::main] 매크로는 멀티스레드 런타임을 생성하지만, 용도에 따라 다르게 설정할 수 있습니다.
// 싱글스레드 런타임 (가벼운 작업에 적합)
#[tokio::main(flavor = "current_thread")]
async fn main() {
// 하나의 스레드에서만 실행
}
// 워커 스레드 수 지정
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
// 4개의 워커 스레드로 실행
}
싱글스레드 런타임은 스레드 간 동기화가 필요 없어서 오버헤드가 적습니다. CLI 도구나 간단한 스크립트처럼 높은 동시성이 필요하지 않은 경우에 적합합니다. 웹 서버처럼 많은 동시 요청을 처리해야 한다면 기본 멀티스레드 런타임을 사용하면 됩니다.
마치며
이번 글에서는 Tokio의 핵심 개념들을 살펴봤습니다. 비동기 런타임이 왜 필요한지부터 시작해서, 태스크 스폰, join!과 select!, mpsc와 oneshot 채널, 비동기 Mutex까지 Tokio로 비동기 프로그래밍을 하는 데 필요한 기본기를 다뤘습니다.
Tokio의 세계는 이보다 훨씬 넓습니다. 비동기 스트림, 타임아웃 조합, graceful shutdown, 트레이싱 같은 고급 주제들도 있으니 공식 문서를 참고해보시기 바랍니다.
Rust의 소유권 시스템이나 Arc 같은 개념에 익숙하다면, Tokio를 배우는 과정이 한결 수월할 겁니다. 반대로 Tokio를 쓰다 보면 소유권과 수명에 대한 이해가 자연스럽게 깊어지기도 합니다.
비동기 프로그래밍이 처음에는 낯설 수 있지만, 몇 가지 패턴만 익히면 적은 리소스로 빠른 프로그램을 만들 수 있습니다. 에코 서버 예제를 출발점 삼아 직접 만들어보시길 추천합니다 🚀
This work is licensed under
CC BY 4.0