Rust Box::pin은 언제 사용할까?

Rust Box::pin은 언제 사용할까?

Rust 비동기 코드를 읽다 보면 이런 타입을 마주칠 때가 있습니다.

Pin<Box<dyn Future<Output = String> + Send>>

처음 보면 부담스럽습니다. Box도 알고, Future도 대충 알겠는데 중간에 Pin이 끼어들면서 갑자기 어려워 보이죠. 게다가 예제 코드에서는 Box::new()가 아니라 Box::pin()을 쓰기도 합니다.

let future = Box::pin(async {
    "done".to_string()
});

이건 단순히 future를 박스에 넣는 코드일까요? 아니면 뭔가 더 특별한 일을 하는 걸까요?

이번 글에서는 Box::pin이 언제 필요한지 차근차근 살펴보겠습니다. Box 자체가 아직 익숙하지 않다면 Box로 힙에 데이터 저장하기를 먼저 읽어보시면 좋습니다. Rust의 Future가 왜 바로 실행되지 않는지 궁금하다면 Rust Future란 무엇인가도 함께 참고해 주세요.

Box::pin이 하는 일

Box::pin(value)는 값을 힙에 올리고, 그 값을 Pin<Box<T>>로 감쌉니다. 즉 Box::new()와 비슷하게 힙 할당을 하지만, 반환 타입이 다릅니다.

let a = Box::new(42);
let b = Box::pin(42);

위 코드에서 ab의 타입은 다음과 같습니다.

// a: Box<i32>
// b: Pin<Box<i32>>

Box::new()는 값을 힙에 저장하는 역할만 합니다. 반면 Box::pin()은 값을 힙에 저장한 뒤, 그 값이 더 이상 마음대로 이동하지 않는다고 약속하는 포인터를 만듭니다.

여기서 중요한 단어는 “힙”보다 “이동되지 않는다”입니다. Rust에서 값은 소유권이 이동될 때 메모리 위치도 바뀔 수 있습니다. 대부분의 타입은 위치가 바뀌어도 아무 문제가 없지만, 어떤 타입은 자기 자신의 주소에 의존합니다. 그런 타입은 한 번 특정 위치에 놓인 뒤에는 움직이면 안 됩니다.

Pin은 바로 이 제약을 타입으로 표현합니다.

Pin은 왜 필요할까

일반적인 Rust 값은 이동돼도 안전합니다. 예를 들어 String을 다른 변수로 옮긴다고 해서 문자열 데이터가 깨지지는 않습니다.

let name = String::from("Rust");
let moved = name;

println!("{moved}");

하지만 자기 자신 안의 값을 참조하는 구조라면 이야기가 달라집니다. 개념적으로 이런 모양을 떠올려볼 수 있습니다.

struct SelfReferential {
    text: String,
    pointer_to_text: *const String,
}

pointer_to_texttext의 주소를 가리키고 있다면, 이 구조체가 다른 메모리 위치로 이동될 때 포인터가 낡은 주소를 가리킬 수 있습니다. 그 주소에 더 이상 유효한 text가 없다면 문제가 생기겠죠.

실무에서 이런 자기 참조 구조체를 직접 작성하는 일은 많지 않습니다. 하지만 async 블록과 async fn이 컴파일러에 의해 상태 머신으로 바뀔 때 비슷한 상황이 생길 수 있습니다. .await 지점 사이에서 어떤 값을 보관하고, 그 값에 대한 참조도 함께 들고 있어야 할 수 있기 때문이죠.

그래서 Future 트레이트의 poll 메서드는 그냥 &mut self를 받지 않습니다.

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

trait Future {
    type Output;

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

pollPin<&mut Self>를 받는다는 것은 future를 폴링하는 동안 그 future가 안정적인 위치에 있어야 한다는 뜻입니다. 우리는 보통 직접 poll을 호출하지 않고 .await를 쓰지만, 비동기 런타임과 future 조합자 내부에서는 이 제약이 중요합니다.

Box::new와 Box::pin의 차이

Box::new(value)는 값을 힙에 올립니다. 하지만 Box<T> 자체는 여전히 이동될 수 있습니다. 정확히 말하면 스택에 있는 박스 포인터는 이동될 수 있고, 힙에 있는 값의 주소는 그대로입니다.

그렇다면 Box::new()만으로 충분한 것 아닐까요? 힙에 있는 값의 주소는 바뀌지 않으니까요.

문제는 타입 수준의 보장입니다. 어떤 API가 “이 값은 이제 고정된 위치에 있다”고 요구한다면, 단순한 Box<T>로는 그 약속을 표현할 수 없습니다. 그 약속을 담은 타입이 Pin<Box<T>>입니다.

use std::pin::Pin;

fn takes_pinned(_: Pin<Box<String>>) {}

fn main() {
    let boxed = Box::new(String::from("hello"));
    // takes_pinned(boxed); // 타입이 맞지 않음

    let pinned = Box::pin(String::from("hello"));
    takes_pinned(pinned);
}

일반 값에는 이 차이가 크게 와닿지 않을 수 있습니다. String은 이동돼도 괜찮은 타입이기 때문입니다. 하지만 Future처럼 !Unpin일 수 있는 타입을 다룰 때는 Pin<Box<T>>가 필요해집니다.

Unpin은 “이 타입은 고정된 뒤에도 이동해도 괜찮다”는 자동 트레이트입니다. 대부분의 평범한 타입은 Unpin입니다. 반대로 어떤 future는 Unpin이 아닐 수 있고, 이런 값을 안전하게 폴링하려면 Pin이 필요합니다.

async 블록을 dyn Future로 반환할 때

Box::pin을 가장 자주 보는 곳은 dyn Future를 반환하는 코드입니다.

async 블록은 익명 타입의 future를 만듭니다. 이 타입 이름은 우리가 직접 쓸 수 없습니다. 그래서 구체 타입을 숨기고 싶을 때는 impl Future를 반환합니다.

use std::future::Future;

fn load_message() -> impl Future<Output = String> {
    async {
        "hello".to_string()
    }
}

이 방식은 반환 타입이 하나로 고정될 때 좋습니다. 그런데 트레이트 객체로 future를 다루거나, 여러 구현체에서 같은 형태의 future 반환 타입을 맞추려면 dyn Future가 필요할 수 있습니다.

use std::future::Future;
use std::pin::Pin;

type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

trait UserRepository {
    fn find_user<'a>(&'a self, id: u64) -> BoxFuture<'a, Option<String>>;
}

struct MemoryRepository;

impl UserRepository for MemoryRepository {
    fn find_user<'a>(&'a self, id: u64) -> BoxFuture<'a, Option<String>> {
        Box::pin(async move {
            Some(format!("user-{id}"))
        })
    }
}

여기서 Box가 필요한 이유는 dyn Future의 크기를 컴파일 시점에 알 수 없기 때문입니다. dyn Trait는 직접 값으로 들 수 없고 포인터 뒤에 둬야 합니다. 이 부분은 dyn 키워드와 동적 디스패치에서 다룬 트레이트 객체와 같은 원리입니다.

그리고 Pin이 필요한 이유는 future를 폴링하려면 고정된 위치가 필요하기 때문입니다. 결국 Pin<Box<dyn Future<Output = T>>>는 “힙에 저장된, 고정된, 동적으로 디스패치되는 future”라고 볼 수 있습니다.

재귀 async 함수에서 사용할 때

Box::pin이 필요한 또 다른 대표 사례는 재귀 async 함수입니다.

동기 재귀 함수는 자연스럽게 작성할 수 있습니다.

fn count_down(n: u32) -> u32 {
    if n == 0 {
        0
    } else {
        count_down(n - 1) + 1
    }
}

하지만 async fn으로 그대로 바꾸면 문제가 생깁니다.

async fn count_down(n: u32) -> u32 {
    if n == 0 {
        0
    } else {
        count_down(n - 1).await + 1
    }
}

async fn은 컴파일러가 특정 future 타입으로 바꿉니다. 그런데 그 future 안에 다시 같은 future가 들어가고, 그 안에 또 같은 future가 들어가는 모양이 됩니다. 컴파일러 입장에서는 이 타입의 크기를 계산할 수 없습니다.

이때 Box::pin으로 한 단계 포인터를 넣어 크기를 끊을 수 있습니다.

use std::future::Future;
use std::pin::Pin;

fn count_down(n: u32) -> Pin<Box<dyn Future<Output = u32>>> {
    Box::pin(async move {
        if n == 0 {
            0
        } else {
            count_down(n - 1).await + 1
        }
    })
}

재귀 자료구조에서 Box로 무한 크기 문제를 해결했던 것과 비슷합니다. 차이가 있다면 여기서는 future를 실행하려면 pinning도 필요하므로 Box<T>가 아니라 Pin<Box<T>>를 쓴다는 점입니다.

다만 모든 재귀 비동기 로직을 이런 식으로 작성해야 한다는 뜻은 아닙니다. 깊이가 깊어질 수 있는 재귀라면 반복문으로 바꾸는 편이 더 단순하고 안전할 때가 많습니다. Box::pin은 타입 크기 문제를 해결해주지만, 로직 자체의 복잡도까지 없애주지는 않습니다.

직접 poll해야 할 때

앱 코드에서는 future를 직접 poll하는 일이 거의 없습니다. 대부분은 Tokio 같은 런타임 위에서 .await를 사용합니다.

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

async fn load_message() -> String {
    "hello".to_string()
}

이런 코드에서는 Box::pin이 필요 없습니다. 컴파일러와 런타임이 필요한 pinning을 내부적으로 처리해주기 때문입니다.

반대로 직접 Future를 구현하거나, future를 구조체 필드에 저장하거나, 여러 future를 하나의 타입으로 감싸서 반환해야 한다면 Pin을 직접 만나게 됩니다. Tower 미들웨어처럼 요청을 받아 응답 future를 감싸는 코드도 여기에 속합니다.

이때는 손으로 unsafe 코드를 작성하기보다 pin-project 같은 크레이트를 사용하는 편이 일반적입니다. Pin 자체는 안전한 추상화이지만, 고정된 값의 내부 필드를 다루는 일은 꽤 섬세하기 때문입니다.

Box::pin이 실행을 시작하는 것은 아니다

Box::pin(async { ... })를 보면 future가 뭔가 실행되기 시작한 것처럼 느껴질 수 있습니다. 하지만 그렇지 않습니다.

fn main() {
    let _future = Box::pin(async {
        println!("실행됨");
    });

    println!("future만 만들었습니다");
}

이 코드는 "실행됨"을 출력하지 않습니다. Box::pin은 future를 힙에 올리고 고정할 뿐입니다. future를 진행시키려면 .await 하거나 런타임이 poll해야 합니다.

#[tokio::main]
async fn main() {
    let future = Box::pin(async {
        println!("실행됨");
    });

    future.await;
}

Box::pin은 실행 도구가 아니라 저장 형태를 바꾸는 도구입니다. 이 차이를 기억해두면 Rust 비동기 코드를 읽을 때 헷갈림이 많이 줄어듭니다.

언제 쓰고 언제 피할까

실무에서는 Box::pin을 먼저 떠올리기보다 async fnimpl Future로 해결할 수 있는지 보는 편이 좋습니다.

async fn load_user(id: u64) -> String {
    format!("user-{id}")
}

이렇게 쓸 수 있다면 이게 가장 단순합니다. 힙 할당도 없고 동적 디스패치도 없습니다.

반환 타입을 숨기기만 하면 된다면 impl Future가 좋습니다.

use std::future::Future;

fn load_user(id: u64) -> impl Future<Output = String> {
    async move {
        format!("user-{id}")
    }
}

반면 API 경계에서 구체 future 타입을 지우고 싶거나, 트레이트 객체로 future를 다뤄야 하거나, 재귀 async처럼 타입 크기 문제가 생긴다면 Box::pin이 자연스러운 선택입니다.

정리하면 이렇습니다.

  • 평범한 async 코드는 async fn.await를 사용합니다.
  • 구체 future 타입을 숨기기만 하면 impl Future를 사용합니다.
  • dyn Future가 필요하면 Pin<Box<dyn Future<...>>>를 사용합니다.
  • 재귀 async 함수처럼 무한 크기 문제가 생기면 Box::pin으로 한 단계를 끊을 수 있습니다.
  • 직접 poll하거나 future를 감싸는 라이브러리 코드를 작성할 때는 Pin을 더 신중하게 다룹니다.

Box::pin은 자주 쓰는 기본 문법이라기보다, Rust 비동기 타입 시스템의 경계에서 필요한 도구에 가깝습니다.

마치며

Box::pin은 값을 힙에 올리고, 그 값을 고정된 위치에 있는 것으로 다루게 해줍니다. 그래서 결과 타입은 Box<T>가 아니라 Pin<Box<T>>입니다.

일반적인 앱 코드에서는 직접 쓸 일이 많지 않습니다. async fn을 호출하고 .await하는 정도라면 컴파일러와 런타임이 필요한 부분을 처리해줍니다. 하지만 dyn Future를 반환하거나, 재귀 async 함수를 작성하거나, future를 직접 구현하고 감싸는 코드를 쓰다 보면 Box::pin이 필요해집니다.

핵심은 Box::pin이 future를 실행하는 함수가 아니라는 점입니다. 실행은 .await나 런타임의 poll이 맡고, Box::pin은 그 future를 안정적인 위치에 놓는 역할을 합니다.

더 자세한 내용은 Rust 공식 문서의 std::pin 모듈Box::pin 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord