Futures와 Async 문법

Futures와 Async 문법 (Futures and the Async Syntax)

Rust에서 비동기 프로그래밍의 핵심 요소는 future와 Rust의 async·await 키워드입니다.

future는 지금은 준비되지 않았지만 어떤 시점에 준비될 값이에요. (이 개념은 많은 언어에 존재하는데, taskpromise 같은 다른 이름으로 불리기도 하죠.) Rust는 서로 다른 비동기 연산이 서로 다른 데이터 구조로 구현되면서도 공통 인터페이스를 가지도록, 빌딩 블록으로서 Future 트레이트를 제공합니다. Rust에서 future란 Future 트레이트를 구현하는 타입이에요. 각 future는 지금까지 진척된 정도와 "준비됨(ready)"이 무엇을 의미하는지에 대한 자신만의 정보를 갖고 있어요.

출처: The Rust Book - Futures and the Async Syntax

async 키워드는 블록과 함수에 적용해서 그것들이 중단되고 재개될 수 있음을 지정할 수 있어요. async 블록이나 async 함수 안에서는 await 키워드를 써서 future를 await할 수 있습니다(즉, 그것이 준비될 때까지 기다리죠). async 블록이나 함수 안에서 future를 await하는 모든 지점은 그 블록이나 함수가 일시정지했다가 재개될 수 있는 잠재적인 자리예요. future에 그 값이 아직 준비됐는지 확인하는 과정을 폴링(polling) 이라고 부릅니다.

C#이나 JavaScript 같은 일부 다른 언어도 비동기 프로그래밍에 async·await 키워드를 사용해요. 그 언어들을 잘 안다면 Rust가 이 문법을 다루는 방식에 꽤 큰 차이가 있다는 걸 눈치챌 거예요. 그럴 만한 이유가 있습니다. 곧 알게 될 거예요!

async Rust를 작성할 때 우리는 대부분 async·await 키워드를 사용해요. Rust는 그것들을 Future 트레이트를 사용하는 동등한 코드로 컴파일합니다. 마치 for 루프를 Iterator 트레이트를 사용하는 동등한 코드로 컴파일하는 것과 비슷하죠. 다만 Rust가 Future 트레이트를 제공하니, 필요할 때는 자신의 데이터 타입에 대해 직접 구현할 수도 있어요. 이번 장 전체에서 볼 많은 함수들이 자신만의 Future 구현을 가진 타입을 반환합니다. 이 장의 끝에서 트레이트 정의로 돌아와 그 동작 방식을 더 파고들 거예요. 지금은 앞으로 나아갈 만큼의 세부 사항이면 충분합니다.

이 모든 게 다소 추상적으로 느껴질 수 있으니, 첫 async 프로그램을 작성해 봅시다. 작은 웹 스크레이퍼(web scraper)예요. 커맨드 라인으로 URL 두 개를 받아 둘 다 동시에 가져온 뒤, 먼저 끝나는 쪽의 결과를 반환할 거예요. 이 예시에는 꽤 많은 새 문법이 나오지만 걱정 마세요. 진행하면서 필요한 것은 전부 설명할게요.

첫 Async 프로그램 (Our First Async Program)

이번 장의 초점을 생태계의 여러 부분을 좌지우지하는 데 쓰지 않고 async를 배우는 데 두기 위해, 우리는 trpl 크레이트(trpl은 "The Rust Programming Language"의 줄임말)를 만들었어요. 이 크레이트는 주로 futurestokio 크레이트에서 필요로 하는 타입·트레이트·함수를 모두 다시 내보냅니다. futures 크레이트는 async 코드를 위한 Rust의 공식 실험장이고, 실제로 Future 트레이트가 처음 설계된 곳이기도 해요. tokio는 오늘날 Rust에서 가장 널리 쓰이는 async 런타임인데, 특히 웹 애플리케이션에서 그래요. 그 외에도 훌륭한 런타임이 많고, 당신의 목적에 더 적합할 수도 있어요. 우리는 trpl 안에서 tokio 크레이트를 사용하는데, 잘 테스트되었고 널리 쓰이기 때문입니다.

어떤 경우에는 trpl이 원래 API를 이름을 바꾸거나 감싸서 이번 장과 관련된 세부 사항에 집중하게 해 줍니다. 크레이트가 무엇을 하는지 이해하고 싶다면 소스 코드를 살펴보시길 권해요. 각 다시 내보냄(reexport)이 어떤 크레이트에서 왔는지 볼 수 있고, 크레이트가 무엇을 하는지 설명하는 방대한 주석도 남겨 두었어요.

hello-async라는 새 바이너리 프로젝트를 만들고 trpl 크레이트를 의존성으로 추가해 봅시다:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

이제 trpl이 제공하는 여러 조각을 써서 첫 async 프로그램을 작성할 수 있어요. 웹 페이지 두 개를 가져와 각각 <title> 요소를 뽑아내고, 두 페이지 중 그 전체 과정을 먼저 끝내는 쪽의 제목을 출력하는 작은 커맨드 라인 도구를 만들 거예요.

page_title 함수 정의하기

페이지 URL 하나를 매개변수로 받아 그 페이지에 요청을 보내고 <title> 요소의 텍스트를 반환하는 함수를 쓰는 것부터 시작할게요(Listing 17-1).

extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

먼저 page_title이라는 함수를 정의하고 async 키워드로 표시했어요. 그런 다음 trpl::get 함수로 전달받은 URL을 가져오고, 응답을 await하기 위해 await 키워드를 붙였습니다. response의 텍스트를 얻기 위해 text 메서드를 호출하고 다시 한 번 await 키워드로 await해요. 이 두 단계 모두 비동기입니다. get 함수의 경우 서버가 응답의 첫 부분을 보내오기를 기다려야 하는데, 여기에는 HTTP 헤더·쿠키 등이 포함되고 응답 본문(body)과는 따로 전달될 수 있어요. 특히 본문이 크다면 전부 도착하는 데 시간이 걸릴 수 있습니다. 응답 전체가 도착할 때까지 기다려야 하므로 text 메서드도 async예요.

이 두 future를 명시적으로 await해야 하는데, Rust의 future는 lazy(게으름) 하기 때문이에요. await 키워드로 요청하기 전에는 아무것도 하지 않죠. (실제로 Rust는 future를 사용하지 않으면 컴파일러 경고를 보여줄 거예요.) 13장의 "Iterator로 일련의 항목 처리하기" 절에서다룬 iterator에 대한 논의가 떠오를 거예요. Iterator는 next 메서드를 호출하기 전까지 아무것도 하지 않죠. 직접 호출하든, for 루프나 내부적으로 next를 쓰는 map 같은 메서드를 쓰든 말이에요. 마찬가지로 future도 명시적으로 요청하기 전에는 아무것도 하지 않습니다. 이런 게으름 덕분에 Rust는 async 코드를 실제로 필요할 때까지 실행하지 않을 수 있어요.

참고: 이는 16장의 "spawn으로 새 스레드 만들기" 절에서 thread::spawn을 쓸 때 본 동작과 다릅니다. 그때는 다른 스레드로 넘겨준 클로저가 즉시 실행되기 시작했죠. 또한 async에 접근하는 다른 여러 언어와도 다릅니다. 하지만 iterator와 마찬가지로, Rust가 성능 보장을 제공하기 위해 이 점이 중요해요.

response_text를 얻으면 Html::parseHtml 타입의 인스턴스로 파싱할 수 있어요. 이제는 원시 문자열 대신, HTML을 더 풍부한 데이터 구조로 다루게 해 주는 타입이 생긴 거예요. 특히 select_first 메서드로 주어진 CSS 셀렉터의 첫 번째 인스턴스를 찾을 수 있죠. 문자열 "title"을 넘기면, 있다면 문서의 첫 번째 <title> 요소를 얻습니다. 일치하는 요소가 없을 수 있으므로 select_firstOption<ElementRef>를 반환해요. 마지막으로 Option::map 메서드를 사용합니다. 이 메서드는 Option에 항목이 있으면 그 항목으로 작업하게 하고, 없으면 아무것도 하지 않게 해 주죠. (여기서 match 표현식을 쓸 수도 있지만, map이 더 관용적이에요.) map에 제공하는 함수 본문에서 titleinner_html을 호출해 그 내용(문자열)을 얻습니다. 모든 걸 종합하면 Option<String>을 얻게 돼요.

Rust의 await 키워드는 await하는 표현식 뒤에 온다는 점, 즉 접미사(postfix) 키워드라는 점에 주목하세요. 다른 언어에서 async를 써 봤다면 익숙하지 않을 수 있는데, Rust에서는 메서드 체인을 훨씬 다루기 좋게 만들어 줍니다. 그 덕분에 Listing 17-2에서처럼 page_title의 본문을 바꿔 trpl::gettext 함수 호출을 await로 연결할 수도 있어요.

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

이로써 첫 async 함수를 성공적으로 작성했어요! main에 코드를 추가해 호출하기 전에, 우리가 쓴 것이 무엇을 뜻하는지 조금 더 이야기해 볼게요.

Rust는 async 키워드로 표시된 블록을 볼 때, 그것을 Future 트레이트를 구현하는 고유한 익명 데이터 타입으로 컴파일합니다. Rust는 async로 표시된 함수를 볼 때, 그 함수를 본문이 async 블록인 비-async 함수로 컴파일해요. async 함수의 반환 타입은 컴파일러가 그 async 블록을 위해 만드는 익명 데이터 타입입니다.

따라서 async fn을 작성하는 것은 반환 타입의 future를 반환하는 함수를 작성하는 것과 동등해요. 컴파일러에게, Listing 17-1의 async fn page_title 같은 함수 정의는 대략 이렇게 정의된 비-async 함수와 동등합니다:

#![allow(unused)]

extern crate trpl; // required for mdbook test

use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}

변환된 버전의 각 부분을 차례로 살펴볼게요:

  • 10장의 "트레이트를 매개변수로" 절에서 다뤘던 impl Trait 문법을 사용합니다.
  • 반환되는 값은 Output이라는 연관 타입을 가진 Future 트레이트를 구현해요. Output 타입이 Option<String>이라는 점을 보세요. 이는 async fn 버전의 page_title 원래 반환 타입과 같습니다.
  • 원래 함수 본문에서 호출된 모든 코드가 async move 블록 안에 감싸져 있어요. 블록은 표현식이라는 점을 기억하세요. 이 전체 블록이 함수에서 반환되는 표현식입니다.
  • 이 async 블록은 방금 설명한 대로 Option<String> 타입의 값을 만들어요. 그 값은 반환 타입의 Output 타입과 일치합니다. 이는 지금까지 본 다른 블록과 똑같아요.
  • 새 함수 본문은 url 매개변수를 사용하는 방식 때문에 async move 블록이에요. (asyncasync move에 대해서는 이번 장에서 훨씬 더 자세히 이야기할 거예요.)

이제 main에서 page_title을 호출할 수 있습니다.

런타임으로 async 함수 실행하기 (Executing an Async Function with a Runtime)

먼저 단일 페이지의 제목을 가져올 거예요. Listing 17-3에 나와 있습니다. 안타깝게도 이 코드는 아직 컴파일되지 않아요.

extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

12장의 "커맨드 라인 인자 받기" 절에서 커맨드 라인 인자를 얻는 데 썼던 것과 같은 패턴을 따랐어요. 그런 다음 URL 인자를 page_title에 넘기고 결과를 await합니다. future가 만드는 값이 Option<String>이므로 match 표현식을 써서 페이지에 <title>이 있는지에 따라 다른 메시지를 출력해요.

await 키워드를 쓸 수 있는 곳은 async 함수나 블록 안뿐이고, Rust는 특별한 main 함수를 async로 표시하는 것을 허용하지 않아요.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

main을 async로 표시할 수 없는 이유는 async 코드에는 런타임(runtime) 이 필요하기 때문이에요. 런타임이란 비동기 코드 실행의 세부 사항을 관리하는 Rust 크레이트를 말합니다. 프로그램의 main 함수는 런타임을 초기화할 수 있지만, 그 자체가 런타임은 아니에요. (왜 그런지에 대해서는 잠시 후에 더 자세히 볼 거예요.) async 코드를 실행하는 모든 Rust 프로그램에는 future를 실행하는 런타임을 설정하는 곳이 최소한 한 군데 있습니다.

대부분의 async를 지원하는 언어는 런타임을 번들로 제공하지만, Rust는 그렇지 않아요. 대신 사용 가능한 async 런타임이 아주 많고, 각 런타임은 자신이 겨냥한 사용 사례에 맞는 서로 다른 트레이드오프를 지닙니다. 예를 들어 CPU 코어가 많고 RAM이 많은 고처리량 웹 서버는, 코어가 하나이고 RAM이 적으며 힙 할당도 불가능한 마이크로컨트롤러와 매우 다른 요구를 가져요. 그런 런타임을 제공하는 크레이트들은 파일·네트워크 I/O 같은 공통 기능의 async 버전도 자주 제공합니다.

여기서는 이번 장 전체에서처럼 trpl 크레이트의 block_on 함수를 사용할 거예요. block_on은 future를 인자로 받아 그 future가 끝까지 실행될 때까지 현재 스레드를 차단합니다. 내부적으로 block_on을 호출하면 tokio 크레이트를 사용해 런타임을 설정해서 전달받은 future를 실행해요(trpl 크레이트의 block_on 동작은 다른 런타임 크레이트들의 block_on 함수와 비슷합니다). future가 완료되면 block_on은 future가 만들어낸 값을 반환해요.

page_title이 반환한 future를 직접 block_on에 넘기고, 완료되면 Listing 17-3에서 시도했던 것처럼 결과 Option<String>에 대해 match할 수도 있어요. 하지만 이번 장의 예시 대부분(그리고 실제 세계의 async 코드 대부분)은 async 함수 호출 한 번 이상을 하게 되므로, 대신 async 블록을 넘기고 page_title 호출의 결과를 명시적으로 await할 거예요. Listing 17-4처럼요.

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

이 코드를 실행하면 처음에 기대했던 동작을 얻을 수 있어요:

$ cargo run -- "https://www.rust-lang.org"
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

휴 — 드디어 제대로 동작하는 async 코드가 생겼어요! 하지만 두 사이트를 서로 경쟁시키는 코드를 추가하기 전에, 잠깐 future가 어떻게 동작하는지로 시선을 돌려볼게요.

await 지점(await point) —— 즉 코드가 await 키워드를 사용하는 모든 곳 —— 은 제어가 런타임으로 넘어가는 자리를 나타냅니다. 그렇게 하기 위해 Rust는 async 블록에 관련된 상태를 추적해야 해서, 런타임이 다른 작업을 시작하고 나서 첫 번째 작업을 다시 진행할 준비가 되면 돌아올 수 있어요. 이것은 마치 각 await 지점에서 현재 상태를 저장하는 이런 enum을 직접 작성한 것 같은, 보이지 않는 상태 기계(state machine)입니다:

#![allow(unused)]

extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}

하지만 각 상태 사이를 전환하는 코드를 손으로 쓰는 것은 지루하고 오류가 나기 쉬워요. 특히 나중에 코드에 기능과 상태를 더 추가해야 할 때는 더욱 그렇죠. 다행히 Rust 컴파일러가 async 코드를 위한 상태 기계 데이터 구조를 자동으로 만들고 관리해 줍니다. 데이터 구조 주변의 일반적인 빌림·소유권 규칙은 여전히 모두 적용되고, 다행히 컴파일러가 그것들도 검사해 주며 유용한 오류 메시지를 제공해요. 이번 장에서 그런 것 몇 가지를 나중에 같이 다뤄 볼 거예요.

결국 누군가는 이 상태 기계를 실행해야 하고, 바로 그 주체가 런타임입니다. (이것이 런타임을 조사하다 보면 executor라는 용어를 만나게 되는 이유예요. executor는 async 코드를 실행하는 런타임의 한 부분입니다.)

이제 Listing 17-3에서 컴파일러가 왜 main 자체를 async 함수로 만들지 못하게 했는지 알 수 있겠죠. 만약 main이 async 함수였다면, main이 반환하는 어떤 future의 상태 기계를 다른 무언가가 관리해야 하는데, main은 프로그램의 시작점이잖아요! 그래서 우리는 대신 main에서 trpl::block_on 함수를 호출해 런타임을 설정하고 async 블록이 반환하는 future를 끝날 때까지 실행했어요.

참고: 일부 런타임은 async main 함수를 쓸 수 있게 해 주는 매크로를 제공해요. 그런 매크로는 async fn main() { ... }를 일반 fn main으로 다시 써서, Listing 17-4에서 우리가 손으로 한 것과 같은 일을 하게 합니다. 즉 trpl::block_on이 하는 것처럼 future를 끝까지 실행하는 함수를 호출하죠.

이제 이 조각들을 조합해 동시성 코드를 어떻게 작성하는지 봅시다.

두 URL을 동시에 경쟁시키기 (Racing Two URLs Against Each Other Concurrently)

Listing 17-5에서 커맨드 라인으로 전달받은 두 개의 서로 다른 URL로 page_title을 호출하고, 먼저 끝나는 future 쪽을 선택해서 경쟁시킵니다.

extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::select(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}

사용자가 제공한 각 URL에 대해 page_title을 호출하는 것부터 시작해요. 결과로 나온 future를 title_fut_1title_fut_2로 저장합니다. 기억하세요. future는 lazy라서 아직 이 둘은 아무것도 하지 않고, 우리는 아직 await하지 않았어요. 그런 다음 future들을 trpl::select에 넘기는데, 이 함수는 전달받은 future들 중 어느 것이 먼저 끝나는지 나타내는 값을 반환합니다.

참고: 내부적으로 trpl::selectfutures 크레이트에 정의된 더 일반적인 select 함수를 바탕으로 만들어졌어요. futures 크레이트의 select 함수는 trpl::select가 못 하는 많은 일을 할 수 있지만, 지금은 건너뛸 수 있는 추가 복잡성도 있어요.

두 future 모두 정당하게 "이길" 수 있으므로 Result를 반환하는 건 말이 안 돼요. 대신 trpl::select는 우리가 아직 보지 못한 타입인 trpl::Either를 반환합니다. Either 타입은 두 경우가 있다는 점에서 Result와 다소 비슷해요. 하지만 Result와 달리 Either에는 성공이나 실패라는 개념이 내장되어 있지 않아요. 대신 LeftRight로 "둘 중 하나"를 나타냅니다:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

select 함수는 첫 번째 인자가 이기면 그 future의 출력을 담은 Left를, 두 번째 future 인자가 이기면 그 출력을 담은 Right를 반환해요. 이는 함수를 호출할 때 인자가 나타나는 순서와 일치합니다. 첫 번째 인자는 두 번째 인자의 왼쪽(left)에 있죠.

또한 page_title이 전달받은 URL을 그대로 반환하도록 갱신했어요. 그렇게 하면 먼저 반환된 페이지에 우리가 해석할 수 있는 <title>이 없어도 의미 있는 메시지를 출력할 수 있습니다. 이 정보가 있으니, 마지막으로 println! 출력을 갱신해서 어떤 URL이 먼저 끝났는지와 그 URL의 웹 페이지에 <title>이 있다면 그것까지 표시하게 했어요.

이제 작동하는 작은 웹 스크레이퍼를 만들었습니다! URL을 몇 개 골라 커맨드 라인 도구를 실행해 보세요. 어떤 사이트는 꾸준히 다른 사이트보다 빠르고, 어떤 경우에는 더 빠른 사이트가 실행할 때마다 달라진다는 걸 발견할 거예요. 더 중요한 것은 future를 다루는 기초를 배웠다는 점이에요. 이제 async로 무엇을 할 수 있는지 더 깊이 파고들 수 있습니다.

더 알아보기 (Learn more)