learn

Rust에서 struct를 static으로 관리하는 법 — static, const, OnceLock

문제 설정

let rect = Rectangle { ... }로 만든 instance는 stack(또는 Box라면 heap)에 살고, 스코프가 끝나면 사라진다. Java/C++의 static field처럼 "프로그램 전체에서 하나만 존재하고 인스턴스에 묶이지 않은 값"을 두려면 어떻게 해야 하는가.

답: Rust에는 static 키워드와 const 키워드가 있다. 의미가 비슷해 보이지만 메모리 모델이 다르다.

static — data segment에 박히는 단 하나의 인스턴스

static RECT: Rectangle = Rectangle { width: 30, height: 50 };
  • 컴파일된 바이너리의 data segment(RODATA) 에 그 값의 비트 패턴이 미리 들어간다.
  • 프로그램 시작 시 OS가 페이지를 매핑하면 즉시 사용 가능. 별도 초기화 코드가 돌지 않는다.
  • lifetime은 'static — 프로그램 시작부터 종료까지.
  • 모든 참조가 같은 메모리 주소를 가리킨다.
  • 기본 immutable.

Java의 static final Rectangle SHARED = ..., C의 전역 static const Rectangle r = {...}와 의미적으로 매칭된다.

const — 메모리 자리가 없는 inline 상수

const RECT_C: Rectangle = Rectangle { width: 30, height: 50 };
  • 사용 시점마다 컴파일러가 값을 그 자리에 inline 한다. 별도의 메모리 자리가 없다.
  • &RECT_C로 주소를 가져오면 컴파일러가 임시 위치를 만들어주는 식 — 매번 다른 주소일 수 있다.
  • 따라서 lifetime이라는 개념이 의미가 없다.

큰 데이터는 static이 적절하다 (inline되면 바이너리가 부풀고 캐시 효율이 떨어진다). 작고 자주 쓰이는 상수는 const가 적합하다.

제약 — compile-time에 결정 가능한 값만

staticconst 모두 초기값이 const fn으로 표현 가능해야 한다.

static NOW: Instant = Instant::now();  // ❌ 런타임 호출 필요

이유는 단순하다. 컴파일러가 바이너리에 비트 패턴을 미리 박아넣어야 하는데, 런타임에야 알 수 있는 값은 박을 수 없다.

런타임 초기화 — OnceLock / LazyLock

전역에서 단 하나, 하지만 첫 접근 시 초기화하는 lazy singleton 패턴은 표준 라이브러리에 안정화되어 있다.

use std::sync::OnceLock;

static CONFIG: OnceLock<Config> = OnceLock::new();

fn config() -> &'static Config {
    CONFIG.get_or_init(|| Config::load_from_disk())
}

OnceLock::new()const fn이라 빈 상태로 직접 static 초기화에 쓸 수 있다. 첫 호출 시 클로저로 진짜 값을 채우고, 이후 호출은 그 값을 빌려준다. thread-safe.

LazyLock은 첫 접근에서 자동으로 초기화하는 syntactic sugar. 외부 crate lazy_static!, once_cell::sync::Lazy도 같은 목적이며 표준화 이전부터 쓰이던 사실상 표준이었다.

이는 Java의 class Holder { static Config CONFIG = Config.load(); }가 클래스 로딩 시점에 초기화되는 패턴의 Rust 안전 구현이다.

가변 전역 상태 — static mut을 피하라

static mut COUNTER: u32 = 0;
unsafe { COUNTER += 1; }   // 모든 접근이 unsafe

static mut은 데이터 레이스를 컴파일러가 막을 수 없어 unsafe 블록 안에서만 접근 가능. Rust ownership 보장의 핵심이 깨지는 영역이라 실무에서 거의 쓰지 않는다.

권장 패턴은 Mutex/RwLockstatic에 박는 것:

use std::sync::Mutex;

static COUNTER: Mutex<u32> = Mutex::new(0);

fn increment() {
    *COUNTER.lock().unwrap() += 1;
}

Mutex::newconst fn이라 직접 static 초기화에 사용 가능하다 (최근 안정화). 더 복잡한 초기화가 필요하면 OnceLock<Mutex<T>> 조합으로 간다.

타입에 묶인 상수 — associated constant

impl Rectangle {
    const UNIT: Rectangle = Rectangle { width: 1, height: 1 };
    const ZERO: Rectangle = Rectangle { width: 0, height: 0 };
}

let r = Rectangle::UNIT;

impl 블록 안에 const를 두면 그 타입 namespace에 상수가 묶인다. f64::EPSILON, i32::MAX가 이 메커니즘이다. 메모리 모델은 일반 const와 동일 — inline. namespacing만 타입에 붙는다.

Java의 static final 필드와 가장 가까운 매칭. 단, *associated static*은 Rust 문법이 받지 않는다. "타입에 묶인 메모리 한 자리"가 필요하면 모듈 스코프에 static을 두고 모듈로 namespacing한다 — Rust에서 module과 type은 namespacing 측면에서 동등하다.

메모리 위치 한눈에

선언메모리 위치lifetime가변
let x = T { ... }stack스코프mut 키워드
Box::new(T { ... })heapBox drop까지Box<T> mut
static X: T = ...data segment (RODATA)'static불가
static mut X: T = ...data segment (BSS)'staticunsafe로 가능
const X: T = ...자리 없음, 사용처 inline해당 없음불가
impl T { const X: ... }동일 (inline, 타입 namespace)해당 없음불가

다른 언어와의 매핑

Java/C++/CRust
static final 필드static 또는 impl T { const }
lazy static (Holder pattern)static X: OnceLock<T> = OnceLock::new();
static 가변 필드static X: Mutex<T> = Mutex::new(...);
static 메서드self 없는 associated function (what-are-methods-and-associated-functions)

결론

static 키워드는 인스턴스를 stack/heap이 아닌 data segment에 박아 프로그램 전체에서 하나의 자리를 차지하게 한다. 컴파일 타임에 결정 가능한 값만 직접 가능하며, 런타임 초기화는 OnceLock/LazyLock으로, 가변 전역 상태는 Mutexstatic에 넣는 패턴으로 해결한다. const는 inline 상수라서 메모리 자리가 없다는 점에서 static과 본질이 다르다 — 큰 데이터엔 static, 작은 상수엔 const가 적절하다.