Published on

Rust 라이프타임 static 오해로 터지는 버그 7가지

Authors

서버/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가 소유해야 한다면 ArcBox로 힙에 올리고 핸들을 넘기기
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 관련 컴파일 에러를 보면 아래 순서로 판단하면 버그를 줄일 수 있습니다.

  1. 지금 필요한 게 &'static T인가, T: 'static인가를 분리해서 읽기
  2. 스레드/태스크 경계라면 "참조 캡처"를 "소유 이동"으로 바꿀 수 있는지 검토
  3. 공유가 필요하면 Arc로 소유권을 공유하고, 참조는 스코프 안에서만 빌리기
  4. Box::leak는 정말로 의도된 영구 객체(원샷 전역)일 때만 사용
  5. FFI에서는 포인터 수명 계약을 명시하고, into_raw/from_raw 같은 소유권 프로토콜을 제공

마무리

'static은 "러스트가 평생 보장해주는 마법"이 아니라, "참조가 프로그램 끝까지 유효" 또는 "비-'static 참조를 포함하지 않음"이라는 매우 구체적인 의미를 갖습니다. 이를 오해하면 컴파일 에러를 억지로 잠재우는 과정에서 누수, UAF, 전역 상태 오염 같은 더 큰 버그를 만들기 쉽습니다.

소유권/빌림이 얽혀 더 복잡한 컴파일 에러를 만난다면, 'static 문제와 함께 자주 동반되는 소유권 충돌 케이스를 Rust E0502 소유권 충돌 6패턴 해결법에서 같이 정리해두었습니다. 런타임/태스크 경계 이슈는 앞서 언급한 Rust tokio 런타임 패닉 - block_on 중첩 해결도 함께 보면, "왜 여기서 Send + 'static이 필요한가"까지 연결해서 이해하는 데 도움이 됩니다.