Rust Future란 무엇인가: async fn이 바로 실행되지 않는 이유

Rust Future란 무엇인가: async fn이 바로 실행되지 않는 이유

Rust에서 비동기 코드를 처음 보면 asyncawait는 꽤 익숙해 보입니다. JavaScript나 Python을 써보셨다면 더 그렇죠. 그런데 막상 Rust에서 async fn을 호출해보면 살짝 당황스러운 지점이 나옵니다.

함수를 호출했는데 실행이 바로 시작되지 않습니다. 반환값도 우리가 기대한 값이 아니라 Future입니다. 그리고 .await를 붙이지 않으면 아무 일도 일어나지 않는 것처럼 보이죠. 🤔

Tokio 입문 글에서도 이 차이를 잠깐 다뤘는데요. 이번 글에서는 Tokio 같은 런타임을 쓰기 전에, Rust의 Future 자체가 어떤 모델인지 차근차근 살펴보겠습니다. Future 트레이트, poll, Waker, lazy 실행 모델을 이해하면 Rust 비동기 코드가 훨씬 덜 마법처럼 느껴집니다.

Future는 아직 끝나지 않은 계산

Rust의 Future는 “언젠가 값을 만들어낼 수 있는 계산”을 표현하는 트레이트입니다. 네트워크 요청이 끝나면 응답을 줄 수도 있고, 타이머가 끝나면 ()를 줄 수도 있고, 파일 읽기가 끝나면 문자열을 줄 수도 있죠.

핵심은 아직 값이 없을 수 있다는 점입니다.

use std::future::Future;

fn answer_later() -> impl Future<Output = u32> {
    async {
        42
    }
}

answer_later()를 호출하면 u32가 바로 나오지 않습니다. 대신 Future<Output = u32>를 구현한 어떤 값이 나옵니다. 이 future는 “나를 실행하면 언젠가 u32를 만들 수 있어요”라는 계산의 표현입니다.

이걸 조금 더 노골적으로 쓰면 다음과 같습니다.

async fn answer() -> u32 {
    42
}

fn answer_desugared() -> impl Future<Output = u32> {
    async {
        42
    }
}

async fn answer() -> u32는 겉으로 보기에는 u32를 반환하는 함수처럼 보이지만, 실제로는 호출 시점에 Future<Output = u32>를 반환합니다. 반환 타입에 Future가 숨어 있는 셈입니다.

그래서 다음 코드는 42를 출력하지 않습니다.

async fn answer() -> u32 {
    println!("계산 시작");
    42
}

fn main() {
    let _future = answer();
    println!("future만 만들었습니다");
}

answer()를 호출했지만 “계산 시작”은 출력되지 않습니다. future라는 값을 만들었을 뿐, 아직 실행하지 않았기 때문입니다. Rust의 future는 기본적으로 lazy합니다.

JavaScript Promise와 다른 점

이 지점이 JavaScript의 Promise와 가장 크게 다릅니다. JavaScript에서는 async function을 호출하면 함수 본문 실행이 바로 시작됩니다.

async function answer() {
  console.log("계산 시작");
  return 42;
}

const promise = answer(); // "계산 시작"이 바로 출력됨

반면 Rust에서는 async fn을 호출해도 본문이 바로 실행되지 않습니다.

async fn answer() -> u32 {
    println!("계산 시작");
    42
}

fn main() {
    let _future = answer(); // 아직 아무것도 출력되지 않음
}

Rust future는 “이미 시작된 작업의 핸들”이라기보다 “아직 시작하지 않은 작업의 설명서”에 가깝습니다. 누군가 이 future를 .await 하거나 직접 poll해야 비로소 계산이 진행됩니다.

이 설계에는 장점이 있습니다. future를 만들기만 하고 버리면 작업이 시작되지 않았으니 비용도 거의 들지 않습니다. 작업을 취소하고 싶을 때도 future를 drop하면 됩니다. 이미 백그라운드에서 달리기 시작한 작업을 따로 취소 신호로 멈추는 모델보다 훨씬 단순하죠.

물론 예외처럼 보이는 경우도 있습니다. tokio::spawn으로 태스크를 만들면 그 태스크는 런타임에 등록되어 독립적으로 실행됩니다. 이때 spawn이 돌려주는 JoinHandle도 future이지만, 안쪽 작업은 이미 스케줄된 상태입니다. 그래서 Rust future는 lazy하다는 원칙을 이해하되, 런타임이 별도 태스크로 떼어낸 작업은 독립적으로 진행될 수 있다는 점도 같이 기억하면 좋습니다.

Future 트레이트의 실제 모양

Future 트레이트는 생각보다 작습니다.

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output>;
}

중심에는 poll 메서드 하나가 있습니다. 이 메서드는 future에게 이렇게 묻습니다.

“지금 결과를 만들 수 있나요?”

대답은 둘 중 하나입니다.

  • Poll::Ready(value) — 계산이 끝났고 결과가 준비됐습니다.
  • Poll::Pending — 아직 준비되지 않았습니다. 나중에 다시 물어봐 주세요.

이게 Rust 비동기의 가장 낮은 층입니다. .await도 결국은 어떤 future를 반복해서 poll하는 흐름 위에 올라가 있습니다. 우리는 보통 직접 poll을 호출하지 않고 .await를 쓰지만, 내부 모델은 이 두 가지 응답으로 돌아갑니다.

간단한 future를 직접 구현해볼까요?

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct AlwaysReady;

impl Future for AlwaysReady {
    type Output = &'static str;

    fn poll(
        self: Pin<&mut Self>,
        _cx: &mut Context<'_>,
    ) -> Poll<Self::Output> {
        Poll::Ready("완료")
    }
}

AlwaysReady는 이름 그대로 물어보자마자 완료되는 future입니다. 실제 비동기 작업은 보통 한 번에 끝나지 않기 때문에 Pending을 돌려주는 순간이 필요합니다.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct YieldOnce {
    yielded: bool,
}

impl Future for YieldOnce {
    type Output = ();

    fn poll(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output> {
        if self.yielded {
            Poll::Ready(())
        } else {
            self.yielded = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

이 future는 처음 poll될 때는 Pending을 반환합니다. 대신 cx.waker().wake_by_ref()를 호출해서 “나중에 다시 폴링해 주세요”라고 알려줍니다. 두 번째로 poll되면 Ready(())를 반환하고 끝납니다.

실무에서 이런 future를 직접 구현하는 일은 많지 않습니다. 대부분은 async fn, async 블록, Tokio나 futures 크레이트의 조합자를 사용합니다. 그래도 이 작은 예제를 보면 Future가 특별한 마법이 아니라 poll에 응답하는 상태 객체라는 감이 옵니다.

Waker는 다시 깨워달라는 약속

Poll::Pending만 있으면 한 가지 문제가 생깁니다. 런타임은 언제 다시 poll해야 할까요?

무작정 반복해서 poll하면 CPU를 낭비합니다. 아직 소켓에 데이터가 오지 않았는데 계속 물어보는 식이 되니까요. 그래서 poll에는 Context가 함께 넘어오고, 이 Context 안에는 Waker가 들어 있습니다.

future가 아직 준비되지 않았다면 보통 현재 작업의 Waker를 저장해둡니다. 그리고 나중에 진행할 수 있는 조건이 생기면 그 Waker를 깨웁니다.

예를 들어 소켓 읽기 future라면 흐름은 대략 이렇습니다.

  1. 런타임이 future를 poll합니다.
  2. 아직 읽을 데이터가 없으면 future는 Waker를 저장하고 Pending을 반환합니다.
  3. 운영체제가 “소켓에 데이터가 왔다”고 알려줍니다.
  4. 런타임이나 I/O 드라이버가 저장해둔 Waker를 깨웁니다.
  5. 런타임이 해당 future를 다시 poll합니다.
  6. 이번에는 데이터를 읽고 Ready(value)를 반환합니다.

중요한 점은 Pending을 반환하는 future가 “언젠가 다시 깨울 방법”을 마련해야 한다는 것입니다. 그렇지 않으면 런타임은 그 future를 다시 폴링할 이유를 알 수 없습니다. 그래서 직접 future를 구현할 때는 PendingWaker를 항상 한 쌍으로 생각해야 합니다.

await는 무엇을 해주는 걸까

그럼 .await는 정확히 무엇을 해줄까요?

개념적으로는 현재 future 안에서 다른 future가 끝날 때까지 기다리는 지점을 만듭니다. 기다린다고 해서 OS 스레드를 멈추는 것은 아닙니다. 안쪽 future가 Pending을 반환하면 현재 future도 Pending을 반환하고, 런타임은 그동안 다른 태스크를 실행할 수 있습니다.

async fn load_user() -> String {
    "사용자".to_string()
}

async fn handler() -> String {
    let user = load_user().await;
    format!("안녕하세요, {user}님")
}

handler()도 future입니다. 이 future는 load_user().await 지점에서 잠시 멈출 수 있습니다. load_user()가 끝나면 그 결과를 user 변수에 넣고 다음 줄부터 이어서 실행합니다.

컴파일러는 이런 async 코드를 상태 머신으로 바꿉니다. .await가 있는 지점마다 “여기까지 실행했다”, “이 값을 보관해야 한다”, “다시 깨면 여기서 이어간다” 같은 상태가 생깁니다.

대략 이런 느낌입니다.

처음 poll
  -> load_user future 시작
  -> 아직 준비 안 됨
  -> Pending 반환

다시 poll
  -> load_user 완료
  -> user 값 저장
  -> format! 실행
  -> Ready(String) 반환

우리가 직접 이런 상태 머신을 작성하면 너무 번거롭습니다. async.await는 이 반복적인 상태 관리 코드를 컴파일러에게 맡기는 문법이라고 볼 수 있습니다.

Pin은 왜 등장할까

Future::poll의 시그니처를 다시 보면 조금 낯선 부분이 있습니다.

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>)
    -> Poll<Self::Output>;

왜 그냥 &mut self가 아니라 Pin<&mut Self>일까요?

이유는 async 블록이 상태 머신으로 변환되면서 자기 자신 안의 값을 참조하는 모양이 생길 수 있기 때문입니다. 그런 값은 메모리 위치가 바뀌면 내부 참조가 깨질 수 있습니다. Pin은 “이 값은 더 이상 마음대로 이동시키지 않겠다”는 약속을 타입으로 표현합니다.

처음 Rust 비동기를 배울 때 Pin까지 완벽히 이해할 필요는 없습니다. 대부분의 앱 코드에서는 async fn.await만 쓰면 컴파일러와 런타임이 알아서 처리해줍니다. 다만 future를 직접 구현하거나, Tower Layer 직접 만들기처럼 응답 future를 감싸는 미들웨어를 작성할 때는 Pin을 마주치게 됩니다. 그때는 pin-project 같은 도구를 사용해 안전하게 필드를 꺼내는 패턴을 따르는 편이 좋습니다.

실제 코드에서 Pin<Box<dyn Future<...>>>Box::pin(async { ... }) 형태를 언제 쓰는지 궁금하다면 Box::pin은 언제 사용할까에서 별도로 정리해두었습니다.

Future를 실행하는 것은 런타임의 일

Future는 계산을 표현하지만, 혼자서는 앞으로 나아가지 않습니다. 누군가 계속 poll해줘야 합니다. 이 역할을 하는 대표적인 도구가 비동기 런타임입니다.

Tokio는 Rust 생태계에서 가장 널리 쓰이는 런타임인데요. #[tokio::main]을 붙이면 Tokio가 런타임을 만들고, async fn main()이 반환하는 최상위 future를 완료될 때까지 실행합니다.

#[tokio::main]
async fn main() {
    let value = answer().await;
    println!("{value}");
}

async fn answer() -> u32 {
    42
}

#[tokio::main] 없이 일반 fn main() 안에서 .await를 바로 쓸 수 없는 이유도 여기에 있습니다. .await는 async 컨텍스트 안에서만 쓸 수 있고, 그 최상위 async 컨텍스트를 실제로 굴려줄 런타임이 필요합니다.

런타임은 단순히 poll만 반복하는 것이 아닙니다. 타이머, 네트워크 I/O, 태스크 스케줄링, 스레드 풀 관리까지 함께 맡습니다. future가 Pending을 반환하면 다른 태스크를 실행하고, Waker가 깨우면 다시 해당 future를 폴링합니다. 이런 협력 덕분에 적은 수의 스레드로도 많은 I/O 작업을 동시에 처리할 수 있습니다.

흔히 헷갈리는 지점

첫 번째로 async fn을 호출했다는 사실만으로 작업이 실행된다고 생각하기 쉽습니다.

async fn send_log() {
    println!("로그 전송");
}

fn main() {
    send_log(); // future를 만들고 바로 버림
}

이 코드는 로그를 전송하지 않습니다. 컴파일러가 unused future 경고를 띄워주지만, 처음에는 놓치기 쉽죠. 실행하려면 async 컨텍스트 안에서 .await해야 합니다.

async fn run() {
    send_log().await;
}

두 번째로 Pending을 반환하면서 Waker를 준비하지 않는 future는 멈춘 것처럼 보일 수 있습니다. 직접 Future를 구현할 때 Poll::Pending은 “나중에 다시 불러줘”라는 말입니다. 그러려면 나중에 누군가 wake를 호출할 수 있어야 합니다.

세 번째로 poll 안에서 오래 걸리는 일을 하면 안 됩니다. poll은 가능한 한 빨리 반환해야 합니다. 네트워크 대기나 긴 계산을 그 자리에서 블로킹하면 런타임 스레드가 묶여 다른 태스크가 실행되지 못합니다. 오래 걸리는 CPU 작업은 별도 스레드 풀로 보내고, I/O 작업은 비동기 API를 사용하는 편이 좋습니다.

마지막으로, .await는 “현재 스레드를 멈춘다”가 아닙니다. 현재 태스크의 실행을 잠시 양보하는 것에 가깝습니다. 이 차이를 이해하면 tokio::time::sleepstd::thread::sleep의 차이도 자연스럽게 보입니다. 전자는 태스크를 쉬게 하고, 후자는 OS 스레드를 멈춥니다.

마치며

Rust의 Future는 비동기 값을 표현하는 작은 트레이트입니다. async fn은 future를 만들고, .await는 future가 끝날 때까지 현재 async 흐름을 이어 붙여주며, 런타임은 Waker 신호에 맞춰 future를 다시 poll합니다. 겉으로는 간단한 async/await 문법이지만, 안쪽에서는 Poll::Ready, Poll::Pending, Waker, 상태 머신이 차분히 맞물려 돌아가는 셈입니다.

이 모델을 알고 나면 Rust 비동기 코드의 여러 특징이 덜 이상하게 느껴집니다. .await를 빼먹으면 실행되지 않는 이유, Tokio 같은 런타임이 필요한 이유, Tower에서 type Future가 자주 등장하는 이유가 같은 그림 안에 들어오죠.

다음 단계로는 Tokio를 활용한 비동기 프로그래밍에서 실제 런타임 위에 future를 올려 실행하는 방법을 살펴보시면 좋습니다. 미들웨어나 서버 프레임워크 쪽으로 더 들어가고 싶다면 Tower 입문도 자연스러운 다음 길입니다.

더 자세한 내용은 Rust 표준 라이브러리의 Future 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord