Published on

Rust async의 static 수명 요구, 소유권·빌림으로 푸는 법

Authors

Rust async를 쓰다 보면 컴파일러가 갑자기 'static 수명을 요구하는 순간이 있습니다. 특히 tokio::spawn 같은 태스크 스폰, 콜백 기반 API, 장수하는 작업 큐에 Future 를 넣는 순간에 흔합니다. 문제는 대개 “내가 빌린 참조가 비동기 경계(.await)를 넘어 살아남을 수 있냐”로 귀결됩니다.

이 글은 'static 요구가 왜 생기는지, 그리고 이를 소유권 이동빌림 패턴으로 어떻게 풀어야 하는지 실전 위주로 정리합니다. (필요할 때만 leak 같은 극약처방도 다룹니다.)


'static 이 정말 의미하는 것

Rust에서 'static 은 흔히 “프로그램이 끝날 때까지 살아있는 값”으로 설명되지만, async 문맥에서 더 중요한 의미는 다음입니다.

  • 해당 Future 가 스택 프레임(현재 함수)보다 오래 살아도 안전해야 한다
  • 즉, Future 내부가 지역 변수에 대한 참조를 들고 있지 않아야 한다

예를 들어 tokio::spawn 은 대략 이런 제약을 둡니다.

  • 스폰된 태스크는 호출자 스코프를 벗어나 독립적으로 실행될 수 있음
  • 그래서 스폰되는 Future 는 보통 Send + 'static 을 요구

이때 'static 은 “진짜로 영원히 살아야 한다”기보다는, 스폰된 태스크가 어디로 이동하든 안전한 소유 구조를 가져야 한다는 뜻에 가깝습니다.


대표 에러 패턴: 빌린 참조를 스폰하려고 할 때

아래 코드는 매우 흔한 실패 예시입니다.

use tokio::task;

#[tokio::main]
async fn main() {
    let s = String::from("hello");
    let r = &s;

    task::spawn(async move {
        // r은 s를 빌림
        println!("{}", r);
    })
    .await
    .unwrap();
}

왜 실패할까요?

  • r 은 지역 변수 s 를 빌린 참조
  • spawn 된 태스크는 main 스코프 밖에서도 실행될 수 있음
  • 그러면 s 가 drop 된 뒤 r 을 쓰는 상황이 가능

컴파일러는 이를 막기 위해 Future: 'static 을 요구하고, 결과적으로 “빌린 참조를 들고 있지 말라”는 메시지를 줍니다.


해결 전략 1: 참조 대신 소유권을 태스크로 이동시키기

가장 정석적인 해법은 참조를 넘기지 말고 소유권을 넘기는 것입니다.

use tokio::task;

#[tokio::main]
async fn main() {
    let s = String::from("hello");

    task::spawn(async move {
        // 소유권이 태스크로 이동
        println!("{}", s);
    })
    .await
    .unwrap();
}

핵심은 async move 입니다.

  • move 로 캡처하면 클로저가 참조 대신 값을 소유
  • String 같은 소유 타입은 태스크 내부로 안전하게 이동

하지만 현실에서는 값이 여러 태스크에서 공유되어야 하거나, 소유권을 이동시키면 호출자에서 더 이상 쓸 수 없는 문제가 생깁니다.


해결 전략 2: 여러 태스크에서 공유해야 하면 Arc 로 소유권 공유

공유가 필요하면 Arc 가 가장 흔한 해법입니다.

use std::sync::Arc;
use tokio::task;

#[tokio::main]
async fn main() {
    let s = Arc::new(String::from("shared"));

    let s1 = Arc::clone(&s);
    let s2 = Arc::clone(&s);

    let t1 = task::spawn(async move {
        println!("t1: {}", s1);
    });

    let t2 = task::spawn(async move {
        println!("t2: {}", s2);
    });

    let _ = tokio::join!(t1, t2);
}

Arc 를 쓰면

  • 각 태스크는 자기 Arc 를 소유하므로 'static 요구를 만족
  • 실제 데이터는 참조 카운팅으로 공유

다만 내부를 수정해야 한다면 Arc 단독으로는 불가능하므로 MutexRwLock 같은 동기화가 필요합니다.

use std::sync::Arc;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0u64));

    let c1 = Arc::clone(&counter);
    let t1 = tokio::spawn(async move {
        let mut g = c1.lock().await;
        *g += 1;
    });

    let c2 = Arc::clone(&counter);
    let t2 = tokio::spawn(async move {
        let mut g = c2.lock().await;
        *g += 1;
    });

    let _ = tokio::join!(t1, t2);

    println!("{}", *counter.lock().await);
}

주의할 점은 락을 잡은 채로 오래 await 하지 않는 것입니다. 락 스코프를 최대한 좁게 유지해야 교착이나 지연을 줄일 수 있습니다.


해결 전략 3: 스폰이 아니라 “스코프 내 동시성”으로 빌림을 유지하기

모든 동시성에 spawn 이 필요한 건 아닙니다. “현재 스코프 안에서만” 병렬로 돌리면 'static 이 필요 없을 수 있습니다.

Tokio의 JoinSet 은 기본적으로 태스크가 스코프 밖으로 도망가므로 여전히 'static 요구가 걸리지만, spawn 자체를 피하고 join! 같은 구조적 동시성으로 풀면 빌림을 유지할 수 있습니다.

async fn two_ops(s: &str) {
    let a = async { format!("A={}", s) };
    let b = async { format!("B={}", s) };

    let (ra, rb) = tokio::join!(a, b);
    println!("{} {}", ra, rb);
}

이 패턴은

  • s: &str 같은 빌림을 유지하면서
  • .await 경계를 넘는 동안에도 s 의 수명이 안전하게 보장

즉, “태스크를 백그라운드로 날려버리는” 모델이 아니라 “현재 호출 스택 안에서 함께 완료되는” 모델입니다.

이 관점은 동시성에서 함정이 생기기 쉬운 지점을 줄여줍니다. 병렬 처리에서의 사고방식 차이는 Kotlin Flow vs Java Stream - 병렬 처리 함정 7가지 글과도 연결됩니다. 런타임이 다르더라도 “구조적 동시성으로 수명과 범위를 고정한다”는 감각은 비슷합니다.


해결 전략 4: 함수 인자를 &T 대신 Arc 로 받는 API 설계

비동기 API를 설계할 때, 내부에서 spawn 을 한다면 호출자가 빌린 참조를 넘기는 순간 'static 문제가 터집니다. 이때는 애초에 API를 “스폰 친화적”으로 설계하는 편이 낫습니다.

나쁜 예: 내부에서 스폰하면서 &T 를 받음

use tokio::task;

struct Service;

impl Service {
    async fn fire_and_forget(&self, msg: &str) {
        task::spawn(async move {
            // msg는 빌림이라 'static 요구에서 터질 수 있음
            println!("{}", msg);
        });
    }
}

좋은 예: Arc 또는 소유 타입을 받음

use std::sync::Arc;
use tokio::task;

struct Service;

impl Service {
    async fn fire_and_forget(self: Arc<Self>, msg: Arc<str>) {
        task::spawn(async move {
            println!("{}", msg);
            let _svc = self; // 서비스도 Arc로 유지
        });
    }
}

포인트는 두 가지입니다.

  • selfArc<Self> 로 받아서 태스크가 서비스 수명을 소유
  • 문자열도 Arc<str> 또는 String 으로 소유

이렇게 하면 호출자는 “이 API는 백그라운드로 날아갈 수 있다”는 사실을 타입에서 바로 알 수 있고, 'static 요구를 자연스럽게 만족합니다.


해결 전략 5: Cow 로 복사 비용과 수명 문제를 동시에 다루기

입력은 보통 &str 로 받고 싶지만, 내부에서 스폰할 수도 있는 경우가 있습니다. 이때 Cow 를 쓰면 상황에 따라 빌리거나 소유할 수 있습니다.

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<'_, str> {
    if input.chars().all(|c| c.is_ascii_lowercase()) {
        Cow::Borrowed(input)
    } else {
        Cow::Owned(input.to_ascii_lowercase())
    }
}

다만 spawn 으로 넘길 때는 결국 'static 이 필요하므로, Cow 결과를 스폰 전에 Owned 로 만들어야 합니다.

use std::borrow::Cow;

fn to_owned_str(c: Cow<'_, str>) -> String {
    c.into_owned()
}

Cow 는 “가능하면 빌리고, 필요하면 소유”라는 비용 최적화 도구지만, 'static 문제를 마법처럼 없애지는 않습니다. 대신 소유로 전환하는 지점을 명확히 만들 수 있다는 점이 장점입니다.


해결 전략 6: 정말 필요할 때만 Box::leak 또는 전역 저장소

가끔은 설정 문자열, 정적 테이블처럼 프로세스 전체에서 재사용되며 사실상 해제가 필요 없는 값이 있습니다. 이때는 누수를 감수하고 'static 참조를 만드는 방법이 있습니다.

fn leak_str(s: String) -> &'static str {
    Box::leak(s.into_boxed_str())
}

이 방식은

  • 메모리가 영구적으로 해제되지 않음
  • 테스트나 단발성 CLI에서는 쓸 수 있으나, 서버 장기 실행에서는 신중해야 함

대안으로는 once_cell 같은 지연 초기화 전역을 쓰는 방법도 있지만, 결국 전역 상태를 도입하므로 설계 비용이 큽니다.


자주 헷갈리는 포인트: 'static 이 “참조 금지”는 아니다

'static 은 “참조를 쓰지 말라”가 아니라, “참조를 쓰더라도 그 참조가 'static 이어야 한다”입니다.

예를 들어 문자열 리터럴은 원래 'static 입니다.

#[tokio::main]
async fn main() {
    let s: &'static str = "literal";

    tokio::spawn(async move {
        println!("{}", s);
    })
    .await
    .unwrap();
}

이게 되는 이유는 리터럴이 프로그램 전체 수명 동안 유효하기 때문입니다.


실전 디버깅 체크리스트

'static 요구가 보이면 아래 순서로 확인하면 빠릅니다.

  1. 에러가 난 async 블록이나 함수가 spawn 되는가
  2. 캡처된 값 중 &T 가 있는가
  3. 그 참조가 지역 변수/지역 구조체를 가리키는가
  4. 해결 방향 선택
    • 참조를 없애고 소유권 이동(move, clone, to_owned)하기
    • 공유면 Arc 로 바꾸기
    • 스폰을 제거하고 join! 같은 구조적 동시성으로 스코프 내에 묶기
    • API 시그니처를 Arc 중심으로 재설계하기

이 과정은 “캐시 때문에 데이터가 안 바뀐다” 같은 비동기/동시성 이슈를 추적할 때도 비슷한 사고 흐름이 유용합니다. 런타임은 다르지만 원인 규명에서의 접근은 Next.js 14 RSC 캐시로 데이터가 안 바뀔 때 같은 글에서 말하는 관찰 포인트와도 닮아 있습니다.


결론: 'static 은 제약이 아니라 설계 신호다

Rust async에서 'static 수명 요구는 불편해 보이지만, 사실은 “이 작업이 호출 스코프 밖으로 나갈 수 있다”는 매우 중요한 신호입니다. 이를 무시하고 억지로 맞추기보다, 아래 원칙으로 정리하면 설계가 깔끔해집니다.

  • 백그라운드 태스크로 나가면: 소유권을 넘겨라
  • 공유가 필요하면: Arc 로 공유하고, 변경은 락으로 최소 범위만
  • 스코프 내 동시성이면: spawn 을 피하고 구조적으로 묶어라
  • API가 내부에서 spawn 한다면: 처음부터 Arc/소유 타입을 받도록 설계하라

이렇게 접근하면 'static 에러는 “삽질 포인트”가 아니라 “올바른 수명 경계와 소유 구조를 찾는 가이드”가 됩니다.