Rust 기초: LazyLock과 OnceLock으로 전역 값 지연 초기화하기

전역에 룩업 테이블(lookup table) 하나 두고 싶을 뿐인데, Rust에서는 이게 생각보다 까다롭습니다. 해시맵을 static으로 선언하려고 하면 컴파일러가 바로 막아서거든요.
use std::collections::HashMap;
static COUNTRIES: HashMap<&str, &str> =
HashMap::from([("KR", "대한민국"), ("JP", "일본")]);
error[E0015]: cannot call non-const associated function `<HashMap<&str, &str> as From<[(&str, &str); 2]>>::from` in statics
--> src/main.rs:4:5
|
4 | HashMap::from([("KR", "대한민국"), ("JP", "일본")]);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: calls in statics are limited to constant functions, tuple structs and tuple variants
= note: consider wrapping this expression in `std::sync::LazyLock::new(|| ...)`
static에 넣는 값은 컴파일 시점에 계산이 끝나 있어야 하는데, HashMap::from은 실행 중에 힙을 할당하니 안 된다는 뜻입니다. 예전에는 lazy_static! 매크로나 once_cell 크레이트(crate)를 받아 와서 풀던 문제인데요. 이제는 외부 의존성 없이 해결됩니다. 에러 메시지 마지막 줄에서 컴파일러가 이미 답을 알려주고 있네요. 😄
그 답이 LazyLock입니다. 짝을 이루는 OnceLock과 함께 표준 라이브러리에 들어온 지 얼마 안 된 타입인데, OnceLock이 Rust 1.70에, LazyLock이 1.80에 안정화됐습니다.
LazyLock으로 선언과 초기화를 한자리에
LazyLock::new에 초기화 클로저(closure)를 넘기면 됩니다. 클로저 안에서는 힙 할당이든 파일 읽기든 마음대로 할 수 있어요.
use std::collections::HashMap;
use std::sync::LazyLock;
static COUNTRIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("국가 테이블을 초기화합니다");
HashMap::from([("KR", "대한민국"), ("JP", "일본"), ("US", "미국")])
});
fn main() {
println!("프로그램 시작");
println!("KR -> {}", COUNTRIES["KR"]);
println!("JP -> {}", COUNTRIES["JP"]);
println!("총 {}개 국가", COUNTRIES.len());
}
프로그램 시작
국가 테이블을 초기화합니다
KR -> 대한민국
JP -> 일본
총 3개 국가
출력 순서를 눈여겨보세요. “프로그램 시작”이 먼저 찍히고 나서 초기화 메시지가 나옵니다. 클로저는 선언할 때가 아니라 값에 처음 손을 댈 때 실행된다는 뜻이죠. 이게 지연 초기화(lazy initialization)입니다. 프로그램이 끝날 때까지 한 번도 안 쓰는 값이라면 초기화 비용을 아예 안 치릅니다.
그리고 COUNTRIES["KR"]처럼 그냥 써도 된다는 점이 편합니다. LazyLock이 Deref 트레이트를 구현하고 있어서, 감싸고 있는 HashMap의 메서드를 껍데기 없이 바로 부를 수 있거든요.
정규식처럼 컴파일 비용이 비싼 값에 특히 잘 맞습니다.
use regex::Regex;
use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\w.+-]+@[\w-]+\.[\w.]+$").unwrap());
fn is_email(text: &str) -> bool {
EMAIL.is_match(text)
}
fn main() {
for text in ["dale.seo@gmail.com", "not-an-email", "a@b.co"] {
println!("{text} -> {}", is_email(text));
}
}
dale.seo@gmail.com -> true
not-an-email -> false
a@b.co -> true
is_email을 백만 번 부르더라도 정규식 컴파일은 딱 한 번입니다. 함수 안에서 매번 Regex::new를 호출하는 코드와 비교하면 차이가 큽니다.
초기화는 정확히 한 번만
이름에 Lock이 붙은 이유가 있습니다. 여러 스레드가 동시에 첫 접근을 시도해도 초기화 클로저는 한 번만 실행되고, 나머지는 그 결과를 기다렸다가 받아 갑니다.
use std::sync::LazyLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
static INIT_COUNT: AtomicUsize = AtomicUsize::new(0);
static TABLE: LazyLock<Vec<u64>> = LazyLock::new(|| {
INIT_COUNT.fetch_add(1, Ordering::SeqCst);
thread::sleep(std::time::Duration::from_millis(50));
(0..10).map(|n: u64| n * n).collect()
});
fn main() {
thread::scope(|s| {
for id in 0..5 {
s.spawn(move || {
println!("스레드 {id}: TABLE[3] = {}", TABLE[3]);
});
}
});
println!("초기화 클로저 실행 횟수: {}", INIT_COUNT.load(Ordering::SeqCst));
}
스레드 3: TABLE[3] = 9
스레드 1: TABLE[3] = 9
스레드 2: TABLE[3] = 9
스레드 4: TABLE[3] = 9
스레드 0: TABLE[3] = 9
초기화 클로저 실행 횟수: 1
스레드가 찍히는 순서는 실행할 때마다 달라지지만, 마지막 줄은 항상 1입니다. 다섯 스레드가 50밀리초짜리 초기화에 한꺼번에 몰려들어도 중복 실행이 없어요. 이 보장을 직접 구현하려면 원자적 타입의 compare_exchange로 초기화 플래그를 다뤄야 하는데, LazyLock이 그 일을 대신 해주는 셈입니다.
OnceLock은 나중에 값을 채웁니다
그렇다면 OnceLock은 언제 쓸까요? LazyLock은 선언하는 자리에서 초기화 방법을 이미 알고 있어야 합니다. 그런데 초기화에 필요한 값을 그 시점에 모르는 경우가 있어요. 명령줄 인자로 받은 경로라든가, main에서 읽어 들인 환경 변수 같은 것들이죠.
이럴 때 쓰라고 있는 게 빈 상자로 시작하는 OnceLock입니다.
use std::sync::OnceLock;
#[derive(Debug)]
struct Config {
data_dir: String,
verbose: bool,
}
static CONFIG: OnceLock<Config> = OnceLock::new();
fn config() -> &'static Config {
CONFIG.get().expect("설정이 아직 초기화되지 않았습니다")
}
fn main() {
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "/tmp/data".to_string());
CONFIG
.set(Config { data_dir, verbose: true })
.expect("설정은 한 번만 초기화할 수 있습니다");
println!("데이터 경로: {}", config().data_dir);
println!("상세 로그: {}", config().verbose);
let second = CONFIG.set(Config { data_dir: "/other".to_string(), verbose: false });
println!("두 번째 set 결과 오류 여부: {:?}", second.is_err());
}
데이터 경로: /var/app/data
상세 로그: true
두 번째 set 결과 오류 여부: true
set은 성공하면 Ok를, 이미 값이 들어 있으면 넘긴 값을 그대로 담은 Err를 돌려줍니다. 값을 되돌려주니 실패해도 버려지지 않아요. 덮어쓰기가 조용히 무시되는 대신 반환값으로 드러나는 설계라, 초기화가 두 번 일어나는 버그를 잡기 쉽습니다.
대신 대가가 있습니다. LazyLock과 달리 OnceLock은 Deref를 구현하지 않아서 CONFIG.data_dir처럼 바로 못 씁니다. 위 예제처럼 get()을 감싸는 접근자 함수를 따로 두는 편이 깔끔해요.
계산 결과를 캐싱하는 용도로도
OnceLock이 static 전용은 아닙니다. 구조체 필드에 넣으면 값비싼 계산을 처음 요청받을 때까지 미뤘다가 결과를 재사용할 수 있어요.
use std::sync::OnceLock;
struct Report {
rows: Vec<u32>,
total: OnceLock<u32>,
}
impl Report {
fn new(rows: Vec<u32>) -> Self {
Report { rows, total: OnceLock::new() }
}
fn total(&self) -> u32 {
*self.total.get_or_init(|| {
println!("합계를 계산합니다");
self.rows.iter().sum()
})
}
}
fn main() {
let report = Report::new(vec![10, 20, 30]);
println!("합계: {}", report.total());
println!("합계: {}", report.total());
println!("합계: {}", report.total());
}
합계를 계산합니다
합계: 60
합계: 60
합계: 60
여기서 눈여겨볼 곳은 total의 시그니처가 &mut self가 아니라 &self라는 점입니다. 불변 참조만 들고도 내부 값을 채울 수 있다는 뜻인데, 이걸 내부 가변성(interior mutability)이라고 부릅니다. RefCell도 같은 일을 하지만 Sync가 아니라서 스레드 사이에서 공유할 수 없는 반면, OnceLock은 여러 스레드가 Arc로 함께 들고 있어도 안전합니다.
get_or_init은 값이 없으면 클로저를 실행해 채우고, 있으면 그냥 참조를 돌려줍니다. set 대신 이걸 쓰면 “초기화됐나 확인하고 아니면 채우기”를 한 줄로 끝낼 수 있어요.
둘 중에 무엇을 고를까요
기준은 하나입니다. 초기화 방법을 선언하는 자리에서 이미 아는지 여부예요.
LazyLock | OnceLock | |
|---|---|---|
| 안정화 버전 | 1.80 | 1.70 |
| 초기화 시점 | 첫 접근 때 자동 | 직접 set이나 get_or_init 호출 |
| 초기화 클로저 | 선언할 때 함께 지정 | 나중에 전달 |
Deref 구현 | 있음 | 없음 |
| 값 접근 | CONFIG.field | CONFIG.get() |
| 알맞은 상황 | 정규식, 룩업 테이블, 상수 성격의 설정 | 명령줄 인자, 환경 변수, 지연 캐싱 |
실무에서는 LazyLock을 쓸 일이 훨씬 많습니다. 전역에 두고 싶은 값 대부분은 초기화 방법이 선언 시점에 이미 정해져 있거든요. 런타임에 들어오는 값을 담아야 할 때만 OnceLock으로 내려가면 됩니다.
내부 구조를 보면 관계가 더 분명해집니다. LazyLock은 OnceLock 위에 초기화 클로저를 함께 들고 다니는 얇은 층이에요. 클로저를 미리 쥐고 있으니 첫 역참조(dereference) 때 알아서 초기화할 수 있는 겁니다.
static은 Drop이 호출되지 않습니다
여기서 한 가지 함정을 짚고 넘어가야 합니다. static으로 선언한 값은 프로그램이 끝날 때까지 살아 있고, 그래서 Drop이 호출되지 않습니다.
use std::sync::LazyLock;
struct Connection(&'static str);
impl Drop for Connection {
fn drop(&mut self) {
println!("{} 연결을 정리합니다", self.0);
}
}
static SHARED: LazyLock<Connection> = LazyLock::new(|| Connection("전역"));
fn main() {
let local = LazyLock::new(|| Connection("지역"));
println!("전역: {}", SHARED.0);
println!("지역: {}", local.0);
println!("main 종료");
}
전역: 전역
지역: 지역
main 종료
지역 연결을 정리합니다
지역 변수로 둔 쪽은 main이 끝나면서 정리 메시지를 찍는데, static은 조용합니다. LazyLock이 Drop을 구현하지 않아서가 아니라, static의 소멸자를 Rust가 아예 실행하지 않기 때문이에요.
평소에는 문제가 안 됩니다. 프로세스가 종료되면 운영체제가 메모리를 회수하니까요. 하지만 파일 버퍼를 비워야 하거나 데이터베이스 연결을 정중하게 닫아야 하는 자원이라면 이야기가 다릅니다. 이런 자원은 전역에 두지 말고, main에서 만들어 필요한 곳으로 넘기면서 종료 시점을 직접 관리하세요.
초기화가 실패하면 어떻게 될까요
초기화 클로저 안에서 패닉(panic)이 나면 두 타입의 반응이 갈립니다. 이게 실전에서 꽤 중요한 차이인데요.
use std::sync::{LazyLock, OnceLock};
use std::panic;
static FLAKY: LazyLock<u32> = LazyLock::new(|| panic!("초기화 실패"));
static RETRY: OnceLock<u32> = OnceLock::new();
fn main() {
panic::set_hook(Box::new(|_| {}));
for attempt in 1..=2 {
let r = panic::catch_unwind(|| *FLAKY);
println!("LazyLock 시도 {attempt} 성공 여부: {}", r.is_ok());
}
for attempt in 1..=2 {
let r = panic::catch_unwind(|| {
*RETRY.get_or_init(|| {
if attempt == 1 { panic!("첫 시도 실패"); }
42
})
});
println!("OnceLock 시도 {attempt}: {:?}", r.is_ok());
}
}
LazyLock 시도 1 성공 여부: false
LazyLock 시도 2 성공 여부: false
OnceLock 시도 1: false
OnceLock 시도 2: true
LazyLock은 한 번 실패하면 오염(poison)된 상태로 굳어서, 이후 모든 접근이 계속 패닉합니다. 반면 OnceLock은 실패를 확정으로 보지 않아요. 클로저가 패닉하면 값이 비어 있는 상태로 남아서, 다음 호출자가 다시 시도해 성공할 수 있습니다.
이 차이는 실패할 수 있는 초기화에서 갈립니다. 네트워크로 설정을 받아 온다든가 파일을 읽는 초기화라면 재시도 여지가 있는 OnceLock이 안전해요. 애초에 실패하면 프로그램이 살아 있을 이유가 없는 초기화라면 LazyLock으로 두고 빠르게 죽는 편이 낫고요.
참고로 실패를 값으로 다루고 싶어 get_or_try_init 같은 메서드를 찾아보실 수도 있는데, 아직 나이틀리(nightly) 채널 전용입니다. 안정 버전에서는 get_or_init 안에서 Result를 통째로 저장하거나, 초기화를 호출하는 쪽에서 처리한 뒤 set으로 넣는 방식을 쓰면 됩니다.
마치며
Rust에서 전역 값을 지연 초기화하는 일은 이제 표준 라이브러리 안에서 끝납니다. lazy_static이나 once_cell을 Cargo.toml에 추가하던 습관은 놓아주셔도 좋아요.
고르는 기준은 간단합니다. 초기화 방법을 선언하는 자리에서 안다면 LazyLock, 런타임에 들어오는 값이 필요하면 OnceLock입니다. 헷갈릴 때는 LazyLock으로 시작해 보고, 클로저 안에 넣을 수 없는 값이 나타나면 그때 OnceLock으로 내려가면 됩니다.
마지막으로 두 가지만 기억해 두세요. 전역에 둔 값은 Drop이 불리지 않으니 정리가 필요한 자원은 담지 말 것, 그리고 실패할 수 있는 초기화라면 재시도가 되는 OnceLock 쪽을 고려할 것.
여러 스레드가 값을 함께 바꿔야 하는 상황이라면 지연 초기화만으로는 부족한데요. Mutex와 RwLock으로 감싸서 LazyLock<Mutex<T>> 형태로 쓰는 조합이 자주 등장합니다.
더 자세한 내용은 std::sync::LazyLock - Rust 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0