- Published on
Rust 라이프타임 static 오해로 터지는 버그 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/CLI/비동기 작업을 Rust로 짜다 보면 어느 순간 컴파일러가 Send + Sync + 'static을 요구하는 장면을 마주합니다. 이때 많은 사람이 'static을 "아무 참조나 붙이면 되는 만능 라이프타임" 정도로 오해하거나, 반대로 "무조건 전역 변수"라고만 이해해서 설계를 꼬이게 만듭니다.
'static은 크게 두 가지 상황을 뜻합니다.
&'static T: 참조가 프로그램 종료까지 유효함(예: 문자열 리터럴)T: 'static: 타입T가 비-'static참조를 내부에 포함하지 않음(즉, 소유 타입이면 대부분 성립)
문제는 이 두 의미가 문맥에 따라 섞여 보이고, 특히 스레드/태스크 경계에서 T: 'static 제약을 만났을 때 "그럼 그냥 'static으로 캐스팅하거나 누수시키자" 같은 지름길을 택하기 쉽다는 점입니다.
아래는 'static 오해로 실제로 터지기 쉬운 버그 7가지와 대안입니다.
1) &str을 억지로 &'static str로 만들기
가장 흔한 오해는 "어차피 문자열이니까 'static으로 바꾸면 되겠지"입니다. 하지만 런타임에 만들어진 String의 슬라이스 &str은 해당 String이 drop 되면 무효가 됩니다.
잘못된 시도
fn make_static(s: String) -> &'static str {
// 절대 이렇게 하면 안 됩니다.
// 컴파일러가 막아주지만, 우회하려고 unsafe를 쓰면 진짜 버그가 됩니다.
unsafe { std::mem::transmute::<&str, &'static str>(s.as_str()) }
}
이 패턴을 unsafe로 우회하면, 호출이 끝난 뒤 s가 해제되면서 &'static str이 댕글링 포인터가 됩니다. 운이 나쁘면 즉시 크래시, 운이 좋으면 더 위험한 데이터 오염으로 이어집니다.
안전한 대안
- 정말로 전역 상수라면 문자열 리터럴을 사용
- 런타임 문자열을 장기 보관해야 하면 소유 타입으로 바꾸기
fn keep_long(s: String) -> String {
s
}
fn keep_long_arc(s: String) -> std::sync::Arc<str> {
std::sync::Arc::<str>::from(s)
}
2) Box::leak로 'static을 만들고 잊어버리기(메모리 누수)
컴파일러가 &'static T를 요구할 때, 검색하면 Box::leak가 자주 보입니다. 이 함수는 힙에 할당한 값을 의도적으로 해제하지 않고 &'static mut T 또는 &'static T를 얻습니다.
흔한 누수 패턴
fn leak_config(contents: String) -> &'static str {
let boxed: Box<str> = contents.into_boxed_str();
Box::leak(boxed)
}
이 코드는 요청마다 호출되면 요청마다 메모리를 누수합니다. "프로세스가 오래 살지 않으니 괜찮다"는 가정은 서버에서 특히 위험합니다.
안전한 대안
- 전역으로 1회 초기화라면
OnceLock같은 원샷 초기화 사용 - 다회 생성이라면
Arc로 공유하고 참조는 빌려 쓰기
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
fn init_config(s: String) {
let _ = CONFIG.set(s);
}
fn config() -> &'static str {
CONFIG.get().map(|s| s.as_str()).unwrap_or("")
}
OnceLock을 쓰면 "정말로 전역 1개"라는 의도를 코드로 고정할 수 있습니다.
3) T: 'static을 &'static T로 착각하기
스레드나 비동기 태스크에 넘길 때 자주 보는 제약이 T: Send + 'static입니다. 여기서 'static은 대개 T가 비-'static 참조를 들고 있지 않다는 뜻이지, T의 메모리가 전역에 있다는 뜻이 아닙니다.
오해가 만드는 설계 문제
use std::thread;
fn spawn_worker(name: String) {
thread::spawn(move || {
// name은 소유(String)라서 T: 'static을 만족합니다.
println!("{name}");
});
}
위에서 String은 클로저로 이동되고, 스레드가 끝날 때 drop 됩니다. 즉, String은 'static 참조가 아니지만 String: 'static은 성립합니다.
핵심 정리
String: 'static은 거의 항상 참&'static str은 오직 리터럴/전역/누수/원샷 전역 같은 경우에만 가능
이 차이를 놓치면, 불필요하게 전역화를 하거나 누수를 도입하는 방향으로 설계를 밀어버립니다.
4) tokio::spawn 때문에 모든 것을 'static으로 만들려다 공유 지옥에 빠지기
tokio::spawn은 기본적으로 태스크가 현재 스코프보다 오래 살 수 있으므로 캡처하는 값이 Send + 'static이어야 합니다. 여기서 초보자가 흔히 하는 실수는 "그러면 참조를 다 'static으로 바꾸자"입니다.
흔한 실패 코드(참조 캡처)
#[tokio::main]
async fn main() {
let s = String::from("hello");
let r = s.as_str();
tokio::spawn(async move {
// r은 s에 빌린 참조라서 'static이 될 수 없습니다.
println!("{r}");
});
}
올바른 방향: 소유권 이동 또는 Arc
#[tokio::main]
async fn main() {
let s = String::from("hello");
tokio::spawn(async move {
// s 자체를 move로 넘기면 String: 'static이라 OK
println!("{s}");
})
.await
.unwrap();
}
공유가 필요하면 Arc를 사용합니다.
use std::sync::Arc;
#[tokio::main]
async fn main() {
let s = Arc::new(String::from("hello"));
let s2 = Arc::clone(&s);
tokio::spawn(async move {
println!("{s2}");
})
.await
.unwrap();
}
비동기 런타임 관련해서는 스폰/블로킹 경계에서 런타임 패닉이 터지는 경우도 흔합니다. 런타임 사용 패턴은 Rust tokio 런타임 패닉 - block_on 중첩 해결도 함께 참고하면 전체 그림이 잡힙니다.
5) 트레잇 객체에 + 'static을 붙여서 플러그인/콜백이 망가지는 경우
콜백 저장소(리스너, 훅, 미들웨어)를 만들 때 Box<dyn Fn(...)>를 쓰다가, 컴파일러 요구에 따라 + 'static을 붙이는 일이 많습니다.
문제 상황
struct Bus {
handlers: Vec<Box<dyn Fn(String) + Send + Sync + 'static>>,
}
impl Bus {
fn on<F>(&mut self, f: F)
where
F: Fn(String) + Send + Sync + 'static,
{
self.handlers.push(Box::new(f));
}
}
이 설계는 장점도 있지만, 호출자가 지역 참조를 캡처하는 순간 등록이 불가능해집니다.
fn demo(bus: &mut Bus) {
let prefix = String::from("[p]");
let p = prefix.as_str();
bus.on(move |msg| {
// p는 prefix에 빌린 참조라서 'static 불가
println!("{p} {msg}");
});
}
대안 1: 캡처 데이터를 소유하게 만들기
fn demo(bus: &mut Bus) {
let prefix = String::from("[p]");
bus.on(move |msg| {
// prefix를 move로 소유하면 OK
println!("{prefix} {msg}");
});
}
대안 2: 버스 자체에 라이프타임을 도입하기
'static을 강제하지 않고, 버스가 살아있는 동안만 핸들러가 유효하다고 모델링할 수도 있습니다.
struct Bus<'a> {
handlers: Vec<Box<dyn Fn(String) + Send + Sync + 'a>>,
}
다만 이 경우 Bus를 장기 보관하거나 스레드로 넘기는 설계가 어려워질 수 있으니(라이프타임이 따라다님), API 의도에 맞춰 선택해야 합니다.
6) FFI에서 'static을 믿고 Rust 참조를 C에 넘겼다가 Use-After-Free
FFI에서는 특히 'static 오해가 치명적입니다. C 쪽이 포인터를 얼마나 오래 들고 있을지 Rust는 알 수 없는데, "C가 알아서 잘 쓰겠지" 하고 지역 데이터의 주소를 넘기면 바로 UAF가 됩니다.
위험한 예시
#[repr(C)]
pub struct Ctx {
ptr: *const u8,
len: usize,
}
pub fn make_ctx() -> Ctx {
let s = String::from("hello");
Ctx {
ptr: s.as_ptr(),
len: s.len(),
}
// s drop 이후 ptr은 무효
}
안전한 대안
- C가 소유해야 한다면
CString::into_raw로 소유권을 넘기고, 해제 함수도 제공 - Rust가 소유해야 한다면
Arc나Box로 힙에 올리고 핸들을 넘기기
use std::ffi::CString;
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn make_c_string() -> *mut c_char {
CString::new("hello").unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_c_string(p: *mut c_char) {
if p.is_null() { return; }
unsafe {
let _ = CString::from_raw(p);
}
}
여기서 중요한 것은 'static이라는 단어가 FFI 안전성을 보장해주지 않는다는 점입니다. 수명은 "어디까지 유효한가"의 약속이고, FFI는 그 약속을 타입 시스템 밖으로 밀어냅니다.
7) lazy_static/전역 캐시로 문제를 덮다가 락 경합과 종료 순서 버그가 생기기
'static 요구를 만나면 "전역 캐시에 넣으면 되겠네"로 귀결되는 경우가 많습니다. 하지만 전역화는 다른 종류의 버그를 부릅니다.
- 락 경합으로 성능 급락
- 테스트 간 상태 공유로 비결정적 실패
- 종료 시점(드롭 순서) 의존 버그
- 설정 핫리로드/멀티테넌시 요구에 대응 불가
전역 Mutex 캐시 예시
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, String>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn put(k: String, v: String) {
let mut g = cache().lock().unwrap();
g.insert(k, v);
}
이 코드는 "일단 된다"는 장점이 있지만, 높은 QPS에서 Mutex가 병목이 될 수 있습니다. 또한 테스트에서 캐시가 초기화된 뒤 값이 남아, 케이스 간 간섭이 생기기 쉽습니다.
더 나은 대안들
- 캐시를 전역이 아니라 애플리케이션 컨텍스트(구조체)로 소유
- 비동기라면
tokio::sync::RwLock또는 샤딩된 락, lock-free 캐시 고려 - 캐시 초기화/수명은 DI로 명시
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
struct AppState {
cache: Arc<RwLock<HashMap<String, String>>>,
}
impl AppState {
fn new() -> Self {
Self { cache: Arc::new(RwLock::new(HashMap::new())) }
}
}
이렇게 하면 'static을 "전역"으로 해결하지 않고도, 스폰되는 태스크에 AppState: 'static(소유/Arc) 형태로 안전하게 전달할 수 있습니다.
'static을 만났을 때 체크리스트
'static 관련 컴파일 에러를 보면 아래 순서로 판단하면 버그를 줄일 수 있습니다.
- 지금 필요한 게
&'static T인가,T: 'static인가를 분리해서 읽기 - 스레드/태스크 경계라면 "참조 캡처"를 "소유 이동"으로 바꿀 수 있는지 검토
- 공유가 필요하면
Arc로 소유권을 공유하고, 참조는 스코프 안에서만 빌리기 Box::leak는 정말로 의도된 영구 객체(원샷 전역)일 때만 사용- FFI에서는 포인터 수명 계약을 명시하고,
into_raw/from_raw같은 소유권 프로토콜을 제공
마무리
'static은 "러스트가 평생 보장해주는 마법"이 아니라, "참조가 프로그램 끝까지 유효" 또는 "비-'static 참조를 포함하지 않음"이라는 매우 구체적인 의미를 갖습니다. 이를 오해하면 컴파일 에러를 억지로 잠재우는 과정에서 누수, UAF, 전역 상태 오염 같은 더 큰 버그를 만들기 쉽습니다.
소유권/빌림이 얽혀 더 복잡한 컴파일 에러를 만난다면, 'static 문제와 함께 자주 동반되는 소유권 충돌 케이스를 Rust E0502 소유권 충돌 6패턴 해결법에서 같이 정리해두었습니다. 런타임/태스크 경계 이슈는 앞서 언급한 Rust tokio 런타임 패닉 - block_on 중첩 해결도 함께 보면, "왜 여기서 Send + 'static이 필요한가"까지 연결해서 이해하는 데 도움이 됩니다.