Async/await와 Tokio를 활용한 비동기 Rust 탐구
Min-jun Kim
Dev Intern · Leapcell

동시 Rust 프로그래밍 소개
고성능 및 반응형 애플리케이션의 현대 세계에서는 동시성이 단순한 사치가 아니라 필수가 되었습니다. 수천 개의 동시 연결을 처리하는 웹 서버부터 복잡한 데이터 처리 파이프라인에 이르기까지, 여러 작업을 동시에 실행하는 능력은 사용자 경험과 리소스 활용에 직접적인 영향을 미칩니다. 완료될 때까지 전체 프로그램을 차단하는 기존의 동기 프로그래밍은 이러한 시나리오에서 빠르게 병목 현상을 일으킵니다. 이때 비동기 프로그래밍이 등장하여, 프로그램이 장기 실행 작업(예: 네트워크 요청 또는 파일 I/O)이 완료되기를 기다리는 동안 유용한 작업을 수행할 수 있도록 하는 패러다임 전환을 제공합니다.
Rust는 성능, 안전성 및 동시성에 대한 강력한 강조를 통해 비동기 프로그래밍을 일급 시민으로 받아들였습니다. 초기 비동기 Rust는 복잡한 수동 미래 조합기로 특징지어졌지만, async/await
구문의 도입은 비동기 코드를 동기적이고 직관적으로 느끼게 함으로써 환경을 변화시켰습니다. 그러나 async/await
자체만으로는 동시성을 마법처럼 활성화하지 못하며, 이러한 논블로킹 작업을 예약하고 실행하려면 비동기 런타임이 필요합니다. 사용 가능한 다양한 런타임 중에서 Tokio는 Rust 생태계에서 사실상의 표준으로 부상했으며, 강력하고 확장 가능한 비동기 애플리케이션 구축을 위한 포괄적인 도구킷을 제공합니다. 이 글은 Rust의 비동기 프로그래밍을 명확히 설명하고, async/await
의 핵심 개념을 탐구하며, 효율적이고 동시적인 Rust 프로그램을 구축하기 위해 Tokio 런타임을 활용하는 방법을 실질적으로 보여주는 것을 목표로 합니다.
비동기 Rust 명확히 이해하기
그 본질에서 Rust의 비동기 프로그래밍은 Futures 개념을 중심으로 진행됩니다. Future
는 미래의 어느 시점에 사용 가능할 수 있는 값을 나타내는 트레잇입니다. 이는 본질적으로 폴링될 때 값과 함께 준비되었음을 나타내거나 아직 준비되지 않아 나중에 다시 폴링해야 함을 나타내는 상태 머신입니다. 이 논블로킹 특성은 단일 스레드가 많은 동시 작업을 관리할 수 있도록 하는 핵심입니다.
주요 용어 설명
예시로 들어가기 전에 몇 가지 중요한 용어를 명확히 해 봅시다:
Future
: 언급했듯이 이 트레잇은 완료될 때 값을 반환하는 비동기 계산을 나타냅니다. 핵심 메서드는poll
이며, 실행자는 이 메서드를 반복적으로 호출하여 계산을 진행합니다.async fn
: Rust의 이 특별한 구문은 비동기 함수를 선언합니다.async fn
을 호출하면 내부 코드가 즉시 실행되는 것이 아니라Future
가 반환됩니다. 실제 실행은 실행자가 이Future
를 폴링할 때 시작됩니다.await
: 이 키워드는async fn
또는async
블록 내부에서만 사용할 수 있습니다.Future
를await
할 때 현재async
함수의 실행은 대기 중인Future
가 완료될 때까지 일시 중지됩니다. 이 일시 중지 동안 실행자는 다른Future
를 실행하도록 전환할 수 있어 스레드가 차단되는 것을 방지합니다.- 실행자/런타임:
async fn
에서 반환된Future
를 가져와 폴링하고 실행을 위해 예약하는 엔진입니다. 폴링 루프를 관리하고, 종속성이 준비되면(예: 네트워크 소켓에 데이터 도착)Future
를 깨우고, 효율적인 리소스 활용을 보장하는 역할을 합니다. Tokio는 이러한 실행자/런타임의 대표적인 예입니다. Pin
:Pin
은 더 고급 개념이지만,Future
가 시작된 후 메모리에서 이동할 필요 없이async/await
가 어떻게 작동하는지 이해하는 데 필수적입니다.Pin
은 값이 현재 메모리 위치에서 이동되지 않음을 보장하며, 이는 종종Future
내에서 발견되는 자체 참조 구조에 중요합니다.
async/await
메커니즘
async/await
구문 설탕은 Future
를 훨씬 쉽게 작업할 수 있게 합니다. 파일에서 읽는 동기 함수를 생각해 봅시다:
// 동기 파일 읽기 fn read_file_sync(path: &str) -> std::io::Result<String> { std::fs::read_to_string(path) }
이 함수는 전체 파일이 읽힐 때까지 현재 스레드를 차단합니다. 이제 async/await
를 사용한 비동기 버전을 살펴봅시다:
// async_std 또는 tokio::fs를 사용한 비동기 파일 읽기 async fn read_file_async(path: &str) -> std::io::Result<String> { tokio::fs::read_to_string(path).await // read_to_string이 반환한 Future 대기 }
read_file_async
를 호출하면 즉시 Future
를 반환합니다. tokio::fs::read_to_string(path)
호출도 Future
를 반환합니다. 이 내부 Future
를 await
할 때, read_file_async
Future
는 실행자에게 제어권을 양보합니다. 실행자는 다른 Future
를 실행하도록 전환할 수 있습니다. tokio::fs::read_to_string
이 완료되면(예: 파일이 읽히면) 실행자는 read_file_async
Future
를 깨우고 await
지점 바로 뒤에서 실행을 재개합니다. 이 협력적 멀티태스킹은 협력적 비동기 프로그래밍의 본질입니다.
Tokio 런타임 소개
Tokio는 단순한 실행자 이상이며, Rust를 위한 포괄적인 비동기 런타임입니다.
- 스케줄러:
Future
를 관리하고 실행합니다.Future
의 실행을 실제로 병렬화하기 위해 여러 스레드(워커 스레드)를 활용할 수 있지만, 각Future
자체는 단일 스레드에서 실행됩니다. - 비동기 I/O: 표준 라이브러리 I/O 작업(예:
TcpStream
,UdpSocket
,File
)의 논블로킹 버전입니다. 이는 고성능 네트워크 서비스를 구축하는 데 중요합니다. - 타이머: 특정 시간 또는 특정 지연으로 작업을 예약합니다(예:
tokio::time::sleep
). - 동기화 기본 요소: 표준 라이브러리 뮤텍스, 세마포어, 채널 등의 비동기 버전(예:
tokio::sync::Mutex
,tokio::sync::mpsc
). - 유틸리티: 작업 결합(
tokio::join!
), 여러 Future 간 선택(tokio::select!
), 백그라운드 작업 스폰(tokio::spawn
)과 같은 일반적인 비동기 패턴을 위한 풍부한 도우미 세트.
실용적인 예시: Tokio와 함께하는 간단한 Echo 서버
Tokio와 async/await
가 어떻게 함께 작동하는지 설명하기 위해 간단한 TCP Echo 서버를 구축해 보겠습니다.
// Cargo.toml // [dependencies] // tokio = { version = "1", features = ["full"] } use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, }; async fn handle_connection(mut stream: TcpStream) -> Result<(), Box<dyn std::error::Error>> { println!("Handling connection from {:?}", stream.peer_addr()?); let mut buf = vec![0; 1024]; // 에코를 위한 작은 버퍼 loop { // 클라이언트로부터 비동기적으로 데이터 읽기 let n = stream.read(&mut buf).await?; if n == 0 { // 클라이언트 연결 종료 println!("Client disconnected from {:?}", stream.peer_addr()?); return Ok(()); } // 수신된 데이터를 클라이언트에게 비동기적으로 다시 에코 stream.write_all(&buf[0..n]).await?; } } #[tokio::main] // Tokio 런타임 진입점 async fn main() -> Result<(), Box<dyn std::error::Error>> { let listener = TcpListener::bind("127.0.0.1:8080").await?; println!("Echo server listening on 127.0.0.1:8080"); loop { // 새로운 클라이언트 연결을 비동기적으로 수락 let (stream, _addr) = listener.accept().await?; // 이 연결을 처리하기 위해 새로운 비동기 작업을 스폰합니다. // `tokio::spawn`은 handle_connection이 반환한 Future가 // Tokio 런타임에 의해 동시적으로 실행되도록 보장합니다. tokio::spawn(async move { if let Err(e) = handle_connection(stream).await { eprintln!("Error handling connection: {}", e); } }); } }
설명:
#[tokio::main]
: 이 매크로는 Tokio 런타임을 설정하고 실행하는 편리한 방법입니다.async fn main
을 가져와 Tokio 런타임 인스턴스 내에서 자동으로 실행합니다. 이것이 없으면 수동으로 Tokio 런타임을 생성하고 차단해야 합니다.TcpListener::bind("127.0.0.1:8080").await?
: 이렇게 하면 논블로킹 TCP 리스너가 생성됩니다.await
는 바인딩이 시간이 걸리는 경우(bind 자체는 거의 없지만 예시용),main
함수가 완료될 때까지 제어권을 양보함을 의미합니다.listener.accept().await?
: 이것은 논블로킹 서버 로직의 핵심입니다.accept()
는 새로운 클라이언트 연결이 수립될 때 완료되는Future
를 반환합니다. 연결을 기다리는 동안 Tokio는 다른Future
(예: 이미 연결된 클라이언트의 Futrure)를 실행할 수 있습니다.tokio::spawn(async move { ... })
: 이것은 여러Future
를 동시에 실행하는 방법입니다.tokio::spawn
은Future
(이 경우async move
블록)를 가져와 Tokio 런타임에서 실행하도록 예약합니다. 각 스폰된 작업은 독립적으로 실행됩니다.spawn
하지 않으면accept
가handle_connection
이 완료될 때까지 차단하여 서버를 동기적으로 만들고 여러 클라이언트를 동시에 처리하지 못하게 합니다.stream.read(&mut buf).await?
및stream.write_all(&buf[0..n]).await?
:handle_connection
내부에서 이들은 Tokio의 비동기 I/O 메서드입니다. 복사되지 않고 스레드를 차단하지 않고 TCP 스트림에서 읽고 씁니다. 읽을 데이터가 없는 경우read
는 제어권을 양보합니다. 쓰기 버퍼가 가득 찬 경우write_all
은 제어권을 양보합니다.
이 예시는 async/await
를 통해 순차적으로 보이는 코드를 작성하면서 Tokio 런타임과 페어링될 때 동시 동작을 제공하는 방법을 명확하게 보여줍니다. 각 handle_connection
작업은 Tokio의 스케줄러에 의해 동시적으로 관리되는 별도의 Future
이며, 서버가 상대적으로 적은 수의 스레드에서 많은 클라이언트를 동시에 처리할 수 있도록 합니다.
고급 Tokio 기능: Select와 Join
Tokio는 Future를 결합하고 관리하는 강력한 매크로를 제공합니다:
-
tokio::join!
: 여러Future
가 동시에 완료될 때까지 기다리고 결과를 수집합니다. 모든Future
는 동시에 폴링됩니다.async fn fetch_data_from_api_a() -> String { /* ... */ "Data A".to_string() } async fn fetch_data_from_api_b() -> String { /* ... */ "Data B".to_string() } async fn get_all_data() { let (data_a, data_b) = tokio::join!( fetch_data_from_api_a(), fetch_data_from_api_b() ); println!("Received: {} and {}", data_a, data_b); }
-
tokio::select!
: 여러Future
를 서로 경주시키고 가장 먼저 완료되는Future
에 해당하는 분기를 실행합니다.use tokio::time::{sleep, Duration}; async fn timeout_op() { // 긴 작업을 시뮬레이션 sleep(Duration::from_secs(5)).await; println!("Long operation finished!"); } async fn early_exit() { sleep(Duration::from_secs(2)).await; println!("Early exit condition met!"); } async fn race_example() { tokio::select! { _ = timeout_op() => { println!("Timeout operation won the race!"); }, _ = early_exit() => { println!("Early exit won the race!"); }, // 어떤 분기도 즉시 준비되지 않은 경우 기본 동작을 위해 `else`를 추가할 수도 있습니다. } }
이러한 매크로는 복잡한 비동기 워크플로를 조율하는 데 매우 유용하며, 개발자가 정교한 동시성 패턴을 간결하게 표현할 수 있도록 합니다.
결론
async/await
와 Tokio 런타임을 사용한 비동기 프로그래밍은 Rust에서 동시 애플리케이션 개발에 혁명을 일으켰습니다. Future
트레잇과 논블로킹 철학을 수용하고 Tokio의 강력한 실행자, 비동기 I/O 및 풍부한 유틸리티 세트를 활용함으로써 개발자는 메모리 안전하고 성능이 뛰어난 언어로 매우 효율적이고 확장 가능하며 반응성이 뛰어난 애플리케이션을 구축할 수 있습니다. async/await
구문은 이러한 동시 코드를 접하기 쉽게 만들어 Rust 프로그램이 I/O 바운드 시나리오에서 진가를 발휘하도록 하며, 현대 네트워크 서비스, 데이터 파이프라인 및 고성능 컴퓨팅에 탁월한 선택이 됩니다. Rust의 비동기 생태계는 개발자가 속도와 안전성을 모두 자신감 있게 달성하면서 놀라운 시스템을 구축할 수 있도록 힘을 실어줍니다.