Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

퓨처와 async 문법

러스트에서 비동기 프로그래밍의 핵심 요소는 퓨처(futures)async, await 키워드입니다.

퓨처 는 지금 당장은 준비되지 않았지만, 미래의 어떤 시점에는 준비될 값을 뜻합니다. (이 개념은 다른 언어에도 있으며, taskpromise 같은 이름으로 불리기도 합니다.) 러스트는 Future 트레이트를 제공해, 서로 다른 비동기 연산이 각자 다른 자료구조로 구현되더라도 공통 인터페이스를 가지게 해 줍니다. 러스트에서 퓨처는 Future 트레이트를 구현하는 타입입니다. 각 퓨처는 자기 자신의 진행 상태와 “준비됨(ready)”이 무엇을 의미하는지에 대한 정보를 직접 가지고 있습니다.

async 키워드는 블록과 함수에 적용할 수 있으며, 그 코드가 중간에 멈췄다가 다시 이어질 수 있음을 뜻합니다. async 블록이나 async 함수 안에서는 await 키워드로 퓨처를 기다릴 수 있습니다. 즉, 준비될 때까지 기다립니다. await 를 사용하는 지점은 언제든 그 블록이나 함수가 잠시 멈췄다가 다시 이어질 수 있는 위치입니다. 퓨처의 값이 아직 준비되었는지 확인하는 과정을 polling 이라고 부릅니다.

C# 이나 JavaScript 같은 다른 언어도 asyncawait 키워드를 사용합니다. 그 언어들을 써 본 적이 있다면, 러스트가 이 문법을 다루는 방식이 꽤 다르다는 사실을 금방 눈치챌 수도 있습니다. 곧 그 이유를 보게 될 것입니다!

러스트 async 코드를 작성할 때는 대부분 asyncawait 키워드를 사용합니다. 러스트는 이를 13장에서 for 루프를 Iterator 트레이트 기반의 코드로 컴파일했던 것과 비슷한 방식으로, Future 트레이트를 사용하는 코드로 컴파일합니다. 하지만 러스트는 Future 트레이트 자체를 제공하므로, 필요하다면 여러분 자신의 타입에도 직접 이를 구현할 수 있습니다. 이 장에서 볼 많은 함수는 각자 자기만의 Future 구현을 가진 타입을 반환합니다. 장 마지막에 다시 트레이트 정의로 돌아와, 그것이 어떻게 작동하는지도 더 깊게 파헤칠 것입니다. 하지만 지금은 계속 진행하기에 충분한 정도만 알고 있으면 됩니다.

여기까지는 조금 추상적으로 느껴질 수 있으니, 첫 번째 async 프로그램을 하나 만들어 봅시다. 간단한 웹 스크레이퍼입니다. 명령줄에서 두 URL을 받아 둘 다 동시에 요청하고, 더 빨리 끝난 쪽의 <title> 요소를 출력하는 프로그램을 만들겠습니다. 새로운 문법이 조금 많이 등장하겠지만, 진행하면서 필요한 것은 전부 설명할 것입니다.

첫 번째 async 프로그램

이 장의 초점을 생태계 도구 조합이 아니라 async 개념 자체에 두기 위해, 우리는 trpl 이라는 크레이트를 미리 준비해 두었습니다(trpl 은 “The Rust Programming Language” 의 약자입니다). 이 크레이트는 주로 futurestokio 크레이트에서 필요한 타입, 트레이트, 함수를 재수출합니다. futures 크레이트는 async 러스트 실험을 위한 공식적인 공간이며, Future 트레이트가 원래 설계된 곳이기도 합니다. Tokio는 오늘날 러스트에서 가장 널리 쓰이는 async 런타임이며, 특히 웹 애플리케이션 분야에서 많이 사용됩니다. 물론 다른 훌륭한 런타임도 많고, 목적에 따라 그것들이 더 적합할 수도 있습니다. 여기서는 Tokio가 널리 쓰이고 잘 검증된 런타임이기 때문에 trpl 내부에서 그것을 사용합니다.

어떤 경우에는 trpl 이 원래 API를 그대로 재수출하지 않고 이름을 바꾸거나 얇게 감싸서, 이 장에서 중요한 세부에만 집중할 수 있게 해 줍니다. 이 크레이트가 실제로 무엇을 하는지 궁금하다면 소스 코드를 직접 보세요. 각 재수출이 어떤 크레이트에서 왔는지도 확인할 수 있고, 무엇을 위해 그렇게 감쌌는지에 대한 설명도 충분히 달아 두었습니다.

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

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

이제 trpl 이 제공하는 여러 구성 요소를 사용해 첫 번째 async 프로그램을 작성할 수 있습니다. 두 웹 페이지를 가져오고, 각 페이지의 <title> 요소를 뽑아낸 뒤, 그 전체 과정이 더 빨리 끝난 페이지의 제목을 출력하는 작은 커맨드라인 도구를 만들 것입니다.

page_title 함수 정의하기

먼저 페이지 URL 하나를 받아 요청을 보내고, 그 HTML 안의 <title> 요소 텍스트를 반환하는 함수를 작성해 봅시다. 목록 17-1을 보세요.

Filename: src/main.rs
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())
}
Listing 17-1: HTML 페이지에서 제목 요소를 가져오는 async 함수 정의하기

먼저 page_title 이라는 함수를 정의하고 async 키워드를 붙입니다. 그런 다음 trpl::get 함수를 사용해 전달된 URL을 요청하고, 응답을 await 로 기다립니다. 응답 본문 텍스트를 얻기 위해 responsetext 메서드를 호출하고, 이것도 다시 await 합니다. 이 두 단계는 모두 비동기입니다. get 은 서버가 응답의 첫 부분 (HTTP 헤더, 쿠키 등)를 보내 줄 때까지 기다려야 하고, 응답 본문과는 따로 올 수도 있습니다. 특히 본문이 크다면 전체가 도착하기까지 시간이 걸릴 수 있습니다. 그리고 본문 전체 가 도착해야만 읽을 수 있으므로, text 메서드도 비동기입니다.

이 두 퓨처를 모두 명시적으로 await 해야 하는 이유는, 러스트의 퓨처가 게으르기 때문입니다. await 로 “지금 실행해”라고 요청하기 전까지는 아무 것도 하지 않습니다. (실제로 퓨처를 사용하지 않으면 러스트는 컴파일 경고도 보여 줍니다.) 이것은 13장의 “반복자로 항목 시리즈 처리하기” 절에서 했던 반복자 이야기와 비슷합니다. 반복자도 next 를 직접 호출하거나, for 루프나 map 같은 메서드가 내부적으로 next 를 호출하기 전까지는 아무 일도 하지 않습니다. 마찬가지로 퓨처도 명시적으로 await 하기 전까지는 아무 일도 하지 않습니다. 이런 게으름은 러스트가 정말 필요할 때까지 async 코드를 실행하지 않게 해 줍니다.

Note: 이것은 16장의 spawn 으로 새 스레드 만들기” 절에서 봤던 thread::spawn 과는 다릅니다. 그때는 다른 스레드에 넘긴 클로저가 즉시 실행되기 시작했습니다. 다른 많은 언어의 async와도 다릅니다. 하지만 반복자와 마찬가지로, 러스트가 성능 보장을 제공하려면 이런 동작 방식이 중요합니다.

response_text 를 얻은 뒤에는, 이를 Html::parse 를 사용해 Html 타입 인스턴스로 파싱할 수 있습니다. 이제 우리는 단순 문자열이 아니라, HTML을 더 풍부한 자료구조로 다룰 수 있는 타입을 가지게 됩니다. 특히 select_first 메서드를 사용해 주어진 CSS 선택자와 맞는 첫 번째 요소를 찾을 수 있습니다. 문자열 "title" 을 넘기면 문서 안의 첫 <title> 요소를 얻게 됩니다. 매칭되는 요소가 없을 수도 있으므로 select_firstOption<ElementRef> 를 반환합니다. 마지막으로 Option::map 메서드를 사용해, 값이 있을 때만 그 안의 값을 다루고 없으면 아무 것도 하지 않게 할 수 있습니다. (여기서 match 식을 써도 되지만 map 이 더 관용적입니다.) map 에 넘기는 함수 본문에서는 title 에 대해 inner_html 을 호출해 내용을 String 으로 꺼냅니다. 결과적으로 우리는 Option<String> 을 얻게 됩니다.

여기서 러스트의 await 키워드는 여러분이 기다리는 식 이 아니라 에 붙는다는 점에 주목하세요. 즉, 후위(postfix) 키워드입니다. 다른 언어의 async에 익숙하다면 조금 다르게 느껴질 수 있지만, 러스트에서는 이 덕분에 메서드 체인이 훨씬 읽기 좋습니다. 그래서 목록 17-2처럼 trpl::gettext 호출을 체인으로 연결하고, 사이사이에 await 를 끼워 넣는 식으로 page_title 본문을 쓸 수도 있습니다.

Filename: src/main.rs
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())
}
Listing 17-2: await 키워드와 메서드 체이닝하기

이제 첫 번째 async 함수를 성공적으로 작성했습니다! main 에서 이 함수를 호출하는 코드를 추가하기 전에, 방금 작성한 코드가 정확히 무엇을 의미하는지 잠시 더 살펴봅시다.

러스트가 async 키워드가 붙은 블록 을 만나면, 컴파일러는 그것을 Future 트레이트를 구현하는 고유한 익명 데이터 타입으로 컴파일합니다. 그리고 async 가 붙은 함수 를 만나면, 컴파일러는 그 함수를 “본문이 async 블록인 일반 함수”로 컴파일합니다. async 함수의 반환 타입은 바로 그 async 블록을 위해 컴파일러가 만든 익명 데이터 타입이 됩니다.

따라서 async fn 을 쓴다는 것은, 결국 “어떤 반환 타입에 대한 퓨처 를 반환하는 함수”를 쓰는 것과 같습니다. 컴파일러 입장에서는 목록 17-1의 async fn page_title 정의가 대략 다음과 같은 비동기 아님 함수와 같습니다.

#![allow(unused)]
fn main() {
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 문법을 사용합니다.
  • 반환값은 Future 트레이트를 구현하며, 그 연관 타입 OutputOption<String> 입니다. 이는 원래 async fn 버전의 page_title 반환 타입과 같습니다.
  • 원래 함수 본문 안에서 호출하던 코드는 모두 async move 블록 안에 감싸져 있습니다. 앞에서 본 것처럼 블록은 하나의 식입니다. 이 블록 전체가 함수가 반환하는 식입니다.
  • 이 async 블록은 방금 설명한 것처럼 Option<String> 타입의 값을 만들어 냅니다. 이는 반환 타입의 Output 과 딱 맞습니다.
  • 새 함수 본문이 async move 블록인 이유는, 그 안에서 url 매개변수를 사용하기 때문입니다. (asyncasync move 의 차이는 이 장 뒤쪽에서 더 자세히 다룹니다.)

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

런타임으로 async 함수 실행하기

우선은 페이지 하나의 제목만 가져와 보겠습니다. 목록 17-3이 그 코드입니다. 안타깝게도 이 코드는 아직 컴파일되지 않습니다.

Filename: src/main.rs
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())
}
Listing 17-3: 사용자가 전달한 인수로 main 에서 page_title 함수를 호출하기

우리는 12장의 “명령줄 인수 받기” 절에서 했던 방식대로 명령줄 인수를 얻고, URL 인수를 page_title 에 넘긴 뒤 결과를 await 하려 합니다. 퓨처가 만들어 내는 값은 Option<String> 이므로, 페이지에 <title> 이 있는지 없는지에 따라 다른 메시지를 출력하려고 match 식도 사용하고 있습니다.

문제는 await 키워드를 async 함수나 async 블록 안에서만 쓸 수 있다는 점입니다. 그리고 러스트는 특별한 함수인 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`

mainasync 를 붙일 수 없는 이유는, async 코드를 실행하려면 런타임(runtime) 이 필요하기 때문입니다. 런타임은 비동기 코드 실행 세부를 관리하는 러스트 크레이트입니다. 프로그램의 main 함수는 런타임을 초기화 할 수는 있지만, 그 자체가 런타임은 아닙니다. (왜 그런지는 조금 뒤에 더 설명하겠습니다.) async 코드를 실행하는 모든 러스트 프로그램에는, 퓨처를 실제로 실행하는 런타임을 설정하는 지점이 반드시 하나 이상 존재합니다.

대부분의 async 지원 언어는 런타임을 언어와 함께 내장해 두지만, 러스트는 그러지 않습니다. 대신 서로 다른 트레이드오프를 가진 다양한 async 런타임이 존재합니다. 예를 들어 CPU 코어가 많고 메모리도 충분한 고성능 웹 서버는, 단일 코어와 소량의 RAM만 가진 마이크로컨트롤러와 전혀 다른 요구를 가집니다. 이런 런타임을 제공하는 크레이트들은 대개 파일 I/O 나 네트워크 I/O 같은 흔한 기능의 async 버전도 함께 제공합니다.

이 장과 나머지 부분에서는 trpl 크레이트의 block_on 함수를 사용합니다. block_on 은 퓨처 하나를 인수로 받아, 그 퓨처가 끝날 때까지 현재 스레드를 막습니다. 내부적으로 block_on 을 호출하면, 넘겨진 퓨처를 실행하기 위해 tokio 를 사용하는 런타임이 설정됩니다(trplblock_on 동작은 다른 런타임 크레이트의 block_on 과 비슷합니다). 퓨처가 완료되면 block_on 은 그 퓨처가 만든 값을 반환합니다.

기술적으로는 page_title 이 반환한 퓨처를 바로 block_on 에 넘긴 다음, 그 결과로 나온 Option<String> 에 대해 바로 match 를 써도 됩니다. 하지만 이 장의 대부분 예제(그리고 현실의 많은 async 코드)에서는 단지 async 함수 하나만 호출하는 것보다 더 많은 일을 하게 될 것이므로, 대신 목록 17-4처럼 async 블록 자체를 block_on 에 넘기고, 그 안에서 page_title 호출 결과를 명시적으로 await 하겠습니다.

Filename: src/main.rs
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())
}
Listing 17-4: trpl::block_on 에 넘긴 async 블록 안에서 await 하기

이 코드를 실행하면, 우리가 처음 기대했던 바로 그 동작을 얻게 됩니다.

$ 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 코드를 얻었습니다! 이제 두 사이트를 서로 경주시키는 코드를 추가하기 전에, 퓨처가 실제로 어떻게 동작하는지 잠시만 더 봅시다.

await 지점—즉, 코드가 await 키워드를 사용하는 모든 위치—은 런타임에 제어권을 돌려주는 지점입니다. 이 동작을 가능하게 하려면, 러스트는 async 블록이 가진 상태를 추적해야 합니다. 그래야 런타임이 다른 일을 하러 갔다가 나중에 다시 돌아와 그 작업을 이어갈 수 있기 때문입니다. 이는 보이지 않는 상태 머신과 같습니다. 마치 각 await 지점의 현재 상태를 저장하기 위해 이런 enum을 직접 작성한 것처럼요.

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

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

물론 각 상태 사이 전환 코드를 손으로 쓰는 일은 지루하고 오류를 만들기 쉽습니다. 특히 나중에 기능이 더 늘어나 상태가 많아질수록 그렇습니다. 다행히 러스트 컴파일러는 이런 async 코드에 필요한 상태 머신 자료구조를 자동으로 만들고 관리해 줍니다. 그리고 일반적인 대여 및 소유권 규칙도 그대로 적용되며, 기쁘게도 컴파일러는 그 검사까지 함께 해 주고 유용한 오류 메시지도 제공합니다. 이 장 뒤에서 그런 예도 몇 가지 보게 될 것입니다.

결국 이 상태 머신을 실제로 실행하는 무언가는 반드시 필요하고, 그것이 바로 런타임입니다. (런타임을 찾아보다 보면 executor 라는 표현을 볼 수도 있는데, 이것은 비동기 코드를 실행하는 런타임의 구성 요소를 뜻합니다.)

이제 목록 17-3에서 왜 컴파일러가 main 자체를 async 함수로 만드는 것을 막았는지도 보일 것입니다. 만약 main 이 async 함수라면, main 이 반환하는 퓨처를 실행하는 또 다른 무언가가 필요하게 됩니다. 하지만 main 은 프로그램의 시작점입니다! 그래서 대신 main 안에서 trpl::block_on 을 호출해 런타임을 만들고, async 블록이 반환한 퓨처를 끝날 때까지 실행한 것입니다.

Note: 어떤 런타임은 async fn main() 을 쓸 수 있게 해 주는 매크로를 제공합니다. 하지만 그 매크로는 내부적으로 async fn main() { ... } 를 일반 fn main 으로 바꾸고, 목록 17-4에서 우리가 손으로 했던 것처럼 퓨처를 끝까지 실행하는 함수를 호출하게 만들어 줍니다.

이제 이 조각들을 합쳐서, 어떻게 동시적인 코드를 작성할 수 있는지 보겠습니다.

두 URL을 동시에 경주시키기

목록 17-5에서는 명령줄에서 받은 서로 다른 두 URL에 대해 page_title 을 호출하고, 둘 중 어느 퓨처가 먼저 끝나는지를 경쟁시킵니다.

Filename: src/main.rs
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)
}
Listing 17-5: 두 URL에 대해 page_title 을 호출해 어느 쪽이 먼저 끝나는지 보기

우리는 먼저 사용자가 입력한 두 URL에 대해 각각 page_title 을 호출하고, 결과 퓨처를 title_fut_1, title_fut_2 에 저장합니다. 기억하세요. 아직 이 퓨처들은 실제로는 아무 것도 하지 않습니다. 퓨처는 게으르고, 아직 await 하지 않았기 때문입니다. 그 다음 우리는 이 두 퓨처를 trpl::select 에 넘기는데, 이 함수는 전달한 퓨처 중 어느 쪽이 먼저 끝났는지를 알려 주는 값을 반환합니다.

Note: 내부적으로 trpl::selectfutures 크레이트의 더 일반적인 select 함수 위에 만들어져 있습니다. futuresselecttrpl::select 보다 훨씬 더 많은 일을 할 수 있지만, 지금은 넘어가도 되는 추가 복잡성도 함께 가집니다.

어느 퓨처가 먼저 끝나든 모두 정상적인 결과이므로, Result 를 반환할 이유는 없습니다. 대신 trpl::select 는 아직 보지 못한 trpl::Either 타입을 반환합니다. Either 는 두 경우를 가진다는 점에서는 Result 와 약간 비슷하지만, 성공과 실패의 개념이 들어 있지는 않습니다. 대신 단순히 “둘 중 하나”를 Left, Right 로 표현합니다.

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

select 함수는 첫 번째 인수 퓨처가 먼저 끝나면 그 출력값을 담은 Left 를 반환하고, 두 번째 인수 퓨처가 먼저 끝나면 그 출력값을 담은 Right 를 반환합니다. 즉, 호출 시 인수 순서와 왼쪽/오른쪽 구분이 그대로 대응됩니다.

또한 page_title 이 원래 받았던 URL 도 함께 반환하도록 바꿨습니다. 그래야 먼저 끝난 페이지에 <title> 이 없어도, 어떤 URL 이 먼저 끝났는지는 여전히 의미 있게 출력할 수 있기 때문입니다. 이 정보까지 활용해, println! 출력도 “어느 URL 이 먼저 끝났는지” 와 “그 URL의 <title> 이 무엇인지(있다면)” 를 함께 보여 주도록 바꿉니다.

이제 여러분은 작지만 실제로 동작하는 웹 스크레이퍼를 하나 만들었습니다! 서로 다른 URL 몇 개를 넣고 실행해 보세요. 어떤 사이트는 항상 더 빠르고, 어떤 사이트는 실행할 때마다 먼저 끝나는 쪽이 달라질 수도 있습니다. 더 중요한 것은, 이제 여러분이 퓨처를 어떻게 다루는지의 기초를 배웠다는 점입니다. 그러니 이제 async 에 대해 더 깊이 들어갈 준비가 된 것입니다.