- Published on
Rust async의 static 수명 요구, 소유권·빌림으로 푸는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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 단독으로는 불가능하므로 Mutex 나 RwLock 같은 동기화가 필요합니다.
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로 유지
});
}
}
포인트는 두 가지입니다.
self도Arc<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 요구가 보이면 아래 순서로 확인하면 빠릅니다.
- 에러가 난
async블록이나 함수가spawn되는가 - 캡처된 값 중
&T가 있는가 - 그 참조가 지역 변수/지역 구조체를 가리키는가
- 해결 방향 선택
- 참조를 없애고 소유권 이동(
move,clone,to_owned)하기 - 공유면
Arc로 바꾸기 - 스폰을 제거하고
join!같은 구조적 동시성으로 스코프 내에 묶기 - API 시그니처를
Arc중심으로 재설계하기
- 참조를 없애고 소유권 이동(
이 과정은 “캐시 때문에 데이터가 안 바뀐다” 같은 비동기/동시성 이슈를 추적할 때도 비슷한 사고 흐름이 유용합니다. 런타임은 다르지만 원인 규명에서의 접근은 Next.js 14 RSC 캐시로 데이터가 안 바뀔 때 같은 글에서 말하는 관찰 포인트와도 닮아 있습니다.
결론: 'static 은 제약이 아니라 설계 신호다
Rust async에서 'static 수명 요구는 불편해 보이지만, 사실은 “이 작업이 호출 스코프 밖으로 나갈 수 있다”는 매우 중요한 신호입니다. 이를 무시하고 억지로 맞추기보다, 아래 원칙으로 정리하면 설계가 깔끔해집니다.
- 백그라운드 태스크로 나가면: 소유권을 넘겨라
- 공유가 필요하면:
Arc로 공유하고, 변경은 락으로 최소 범위만 - 스코프 내 동시성이면:
spawn을 피하고 구조적으로 묶어라 - API가 내부에서
spawn한다면: 처음부터Arc/소유 타입을 받도록 설계하라
이렇게 접근하면 'static 에러는 “삽질 포인트”가 아니라 “올바른 수명 경계와 소유 구조를 찾는 가이드”가 됩니다.