비동기 함수 (Async Functions)
지금까지 작성한 모든 함수와 메서드는 호출 시 즉시 실행되었습니다. 호출 전에는 아무 일도 일어나지 않지만, 일단 호출되면 작업이 완료될 때까지 실행을 멈추지 않았죠. 즉, 모든 작업을 수행한 뒤 결과를 반환했습니다.
하지만 때로는 이런 방식이 효율적이지 않을 수 있습니다. 예를 들어 HTTP 서버를 만든다고 할 때, 많은 대기(Waiting) 시간이 발생할 수 있습니다. 요청 본문이 도착하길 기다리거나, 데이터베이스의 응답을 기다리고, 외부 서비스가 대답해 주기를 기다리는 것처럼 말이죠.
만약 기다리는 동안 다른 유용한 작업을 할 수 있다면 어떨까요? 작업 도중에 잠시 멈추고 나중에 다시 시작할 수 있다면? 혹은 현재 작업보다 더 급한 다른 일에 우선순위를 둘 수 있다면?
이런 요구를 충족시켜 주는 것이 바로 **비동기 함수(Async functions)**입니다.
async fn
async 키워드를 사용하면 비동기 함수를 정의할 수 있습니다:
use tokio::net::TcpListener;
// 이 함수는 비동기로 동작합니다 async fn bind_random() -> TcpListener {
// [...]
}
만약 bind_random을 일반 함수처럼 그냥 호출하면 어떻게 될까요?
fn run() {
// `bind_random` 호출
let listener = bind_random();
// 이제 무엇을 해야 할까요?
}
답은 “아무 일도 일어나지 않는다“입니다!
Rust는 bind_random을 호출하는 즉시 실행을 시작하지 않습니다. 다른 언어들의 경험에 비추어 백그라운드 작업이 시작될 거라 기대할 수도 있지만, 그렇지 않습니다.
Rust의 비동기 함수는 게으릅니다(Lazy). 명시적으로 요청하기 전까지는 아무런 일도 하지 않죠.
Rust 용어로는 bind_random이 **퓨처(Future)**를 반환한다고 말합니다. 퓨처는 나중에 완료될 수 있는 계산 과정을 나타내는 타입입니다. 이들은 Future 트레이트를 구현하고 있기 때문에 퓨처라고 불리며, 이번 챕터에서 이 인터페이스에 대해 자세히 알아볼 것입니다.
.await
비동기 함수에게 실제 작업을 시키는 가장 일반적인 방법은 .await 키워드를 사용하는 것입니다:
use tokio::net::TcpListener;
async fn bind_random() -> TcpListener {
// [...]
}
async fn run() {
// `bind_random`을 호출하고 완료될 때까지 기다립니다
let listener = bind_random().await;
// 이제 `listener`가 준비되었습니다
}
.await는 비동기 함수가 완료되어 결과(예제에서는 TcpListener)를 얻을 때까지 호출자에게 제어권을 넘겨주지 않습니다.
런타임 (Runtime)
여기서 조금 혼란스러울 수 있습니다! 방금 전까지 비동기 함수의 장점은 모든 작업을 한꺼번에 하지 않는 것이라고 해놓고, 바로 다음에 작업이 끝날 때까지 멈춰 있는 .await를 소개했으니까요. “결국 똑같은 거 아닌가?” 싶으실 겁니다.
하지만 사실 그렇지 않습니다! .await를 호출할 때 내부적으로는 훨씬 많은 일이 일어납니다. 바로 비동기 런타임(Asynchronous Runtime), 다른 말로 **비동기 실행기(Asynchronous Executor)**에게 제어권을 양보(Yield)하는 것이죠. 실행기는 마법 같은 일을 수행합니다. 현재 진행 중인 모든 비동기 **태스크(Task)**를 관리하며, 다음 두 가지 목표 사이의 균형을 맞춥니다:
- 진행(Progress): 태스크가 수행 가능한 상태라면 최대한 빨리 진행되도록 합니다.
- 효율성(Efficiency): 어떤 태스크가 무언가를 기다리고 있다면, 그동안 다른 태스크를 실행하여 리소스를 낭비하지 않도록 합니다.
기본 런타임의 부재
Rust는 비동기 프로그래밍 방식에서 꽤 독특한 점이 있습니다. 바로 기본 런타임이 없다는 것입니다. 표준 라이브러리에도 런타임이 포함되어 있지 않아, 사용자가 직접 가져와야 합니다!
대부분의 경우 생태계에 이미 구현된 라이브러리 중 하나를 선택하게 됩니다. 범용적으로 가장 널리 쓰이는 탄탄한 옵션으로는 tokio와 async-std가 있고, 임베디드 시스템처럼 특정 목적에 최적화된 embassy 같은 런타임도 있습니다.
우리는 이번 과정에서 Rust에서 가장 인기 있는 범용 비동기 런타임인 **tokio**를 사용할 것입니다.
#[tokio::main]
실행 파일의 진입점인 main 함수는 기본적으로 동기 함수여야 합니다. 따라서 거기서 여러분이 선택한 비동기 런타임을 설정하고 가동해 주어야 하죠.
많은 런타임이 이 과정을 쉽게 해주는 매크로를 제공합니다. tokio의 경우 #[tokio::main]을 사용합니다:
#[tokio::main]
async fn main() {
// 이제 여기서 비동기 코드를 작성할 수 있습니다
}
이 코드는 내부적으로 다음과 같이 확장됩니다:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(
// 비동기 함수가 여기서 실행됩니다
// [...]
);
}
#[tokio::test]
테스트도 마찬가지입니다. 기본적으로는 동기 함수로 작동하죠. 각 테스트 함수는 독립된 스레드에서 실행되므로, 비동기 코드를 테스트하려면 직접 런타임을 구성해야 합니다.
tokio는 이를 간편하게 도와주는 #[tokio::test] 매크로를 제공합니다:
#[tokio::test]
async fn my_test() {
// 비동기 테스트 코드를 여기서 작성하세요
}
Exercise
The exercise for this section is located in 08_futures/01_async_fn