Rust 기초: 수명(Lifetime)으로 참조의 유효 범위 관리하기
Rust로 코드를 짜다 보면 어김없이 만나게 되는 오류가 있습니다.
error[E0597]: `x` does not live long enough
분명히 변수를 선언하고 참조했을 뿐인데 “충분히 오래 살지 못한다”는 야박한 평가를 받는데요. 이 오류는 Rust의 또 다른 핵심 개념인 수명(lifetime)과 관련이 있습니다.
수명은 모든 참조가 가지는 속성이고, 컴파일러의 빌림 검사기(borrow checker)가 메모리 안전성을 보장하는 데 쓰는 도구이기도 합니다. 이 글에서는 수명이 무엇인지, 컴파일러가 어떻게 검증하는지, 우리가 직접 표기해야 하는 순간은 언제인지 알아보겠습니다. 기본적인 소유권과 빌림 개념을 먼저 짚고 오면 이해가 한결 수월할 거예요.
수명이란?
수명은 참조가 유효한 범위, 즉 가리키는 값이 살아 있는 동안의 시간적 구간을 의미합니다. 모든 참조에는 수명이 있고, Rust 컴파일러는 참조가 가리키는 값보다 참조가 더 오래 살아남지 않도록 검사해요.
fn main() {
let r;
{
let x = 5;
r = &x;
} // x가 여기서 drop됨
println!("r: {r}"); // 오류! r은 이미 사라진 x를 가리킴
}
error[E0597]: `x` does not live long enough
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 |
8 | println!("r: {r}");
| --- borrow later used here
x는 내부 블록이 끝나면 drop되지만 r은 그 이후에도 x를 가리키려고 합니다.
이걸 그대로 두면 해제된 메모리를 참조하는 댕글링 참조(dangling reference)가 되어버려요.
다른 언어에서는 런타임에야 발견되는 버그를 Rust는 컴파일 시점에 잡아내는 거죠.
빌림 검사기가 하는 일
대부분의 경우 우리는 수명을 직접 표기할 필요가 없습니다. 빌림 검사기가 자동으로 추론해주거든요.
fn main() {
let s = String::from("hello");
let r = &s;
println!("{r}");
}
여기서 r은 s를 빌리고 있고, 둘 다 main 함수가 끝날 때까지 살아 있습니다.
빌림 검사기는 두 수명을 비교해서 “참조가 원본보다 오래 살지 않는다”는 걸 확인하고 통과시켜요.
문제가 되는 건 컴파일러가 혼자 추론하기 어려운 상황인데요. 대표적인 예가 함수 시그니처입니다.
함수에 수명 표기하기
다음 함수를 한번 볼까요?
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
두 문자열 슬라이스 중 더 긴 쪽을 반환하는 단순해 보이는 함수지만, 이 코드는 컴파일되지 않습니다.
error[E0106]: missing lifetime specifier
|
1 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `x` or `y`
문제는 컴파일러가 반환되는 참조의 수명을 알 수 없다는 거예요.
x를 빌렸는지 y를 빌렸는지에 따라 반환값의 유효 범위가 달라지는데, 함수 시그니처만 봐서는 알 수 없으니까요.
이럴 때 수명 매개변수로 명시해줍니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
'a는 수명 매개변수(lifetime parameter)인데요.
제네릭 타입 매개변수처럼 함수 이름 뒤에 <'a>로 선언합니다.
이 표기의 의미는 “x와 y, 그리고 반환값이 모두 같은 수명 'a를 공유한다”는 거예요.
정확히는 'a가 x와 y의 수명 중 더 짧은 쪽으로 결정됩니다.
이렇게 명시해주면 컴파일러는 “반환값은 적어도 'a만큼 유효하다”고 보장할 수 있게 되죠.
fn main() {
let s1 = String::from("long string");
let s2 = String::from("short");
let result = longest(&s1, &s2);
println!("긴 문자열: {result}");
}
긴 문자열: long string
수명 매개변수는 실제 값을 바꾸지 않습니다. 컴파일러에게 참조들의 관계를 알려주는 메타데이터일 뿐이에요.
수명 생략 규칙
여기까지 보고 “그럼 모든 함수에 수명을 표기해야 하나?”라고 걱정될 수 있는데요. 다행히 흔한 패턴에 대해서는 컴파일러가 자동으로 수명을 채워주는 생략 규칙(lifetime elision)이 있습니다.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[..i];
}
}
s
}
이 함수는 수명 표기 없이도 컴파일되는데요. 컴파일러가 다음 세 규칙에 따라 알아서 채워주거든요.
- 각 입력 참조에 서로 다른 수명을 부여합니다
- 입력 참조가 하나뿐이면 그 수명을 모든 출력에 적용합니다
&self나&mut self가 있으면self의 수명을 출력에 적용합니다
위 first_word는 입력 참조가 하나뿐이라 2번 규칙으로 출력 수명이 자동 결정됩니다.
반면 앞에서 본 longest는 입력 참조가 둘이고 어느 쪽에서 빌려오는지 컴파일러가 알 수 없어서 직접 표기해야 했던 거고요.
메서드를 작성할 때도 3번 규칙 덕분에 대부분 수명을 명시할 필요가 없습니다.
구조체에 참조 담기
구조체가 참조를 필드로 가진다면 수명 표기가 반드시 필요합니다.
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("좋은 아침이었다. 새들이 노래했다.");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt {
text: first_sentence,
};
println!("발췌: {}", excerpt.text);
}
발췌: 좋은 아침이었다
Excerpt<'a>는 “이 구조체 인스턴스는 text 필드가 가리키는 데이터보다 오래 살 수 없다”는 의미예요.
novel이 살아 있는 동안만 excerpt도 유효한 거죠.
이 제약이 있어야 구조체가 댕글링 참조를 들고 다니지 못합니다.
만약 구조체에 참조를 담는 게 번거롭다면 String이나 Vec 같은 소유 타입으로 받는 걸 고려해보세요.
‘static 수명
'static은 특별한 수명인데요. 프로그램이 실행되는 동안 내내 유효하다는 뜻입니다.
fn main() {
let s: &'static str = "안녕하세요";
println!("{s}");
}
문자열 리터럴은 'static 수명을 갖습니다.
바이너리에 직접 포함되어 프로그램이 종료될 때까지 사라지지 않거든요.
'static은 트레이트 바운드로도 자주 등장하는데요.
예를 들어 thread::spawn이 받는 클로저는 캡처하는 모든 참조가 'static이어야 합니다.
새 스레드가 언제까지 살지 알 수 없으니, 잠깐 빌린 참조를 넘기는 건 위험하기 때문이에요.
다만 'static이라는 단어에 너무 휘둘리지 말아야 합니다.
참조가 'static이라는 건 “프로그램 전체에 걸쳐 유효한 데이터를 가리킨다”는 뜻이지, 그 참조 자체를 영원히 들고 다녀야 한다는 의미는 아니거든요.
또 String::from("hi")로 만든 String처럼 소유 타입은 T: 'static 바운드를 자연스럽게 만족합니다.
참조가 아닌 자기 자신이 데이터를 들고 있으니까요.
수명과 다른 도구들
수명은 빌림을 안전하게 다루는 강력한 도구지만, 모든 상황에 맞는 건 아닙니다. 복잡한 자료구조나 여러 곳에서 공유되어야 하는 데이터를 다룰 때는 수명만으로 표현하기 어려울 수 있어요.
이럴 때는 Rc나 Arc 같은 스마트 포인터로 공유 소유권을 갖는 게 더 깔끔합니다. 수명 매개변수가 함수 시그니처를 어지럽히기 시작한다면, 참조 대신 소유 타입을 받거나 스마트 포인터로 갈아타는 걸 고민해보세요.
마치며
수명은 Rust가 GC 없이 메모리 안전성을 보장하는 핵심 메커니즘입니다. 처음에는 까다롭게 느껴지지만 결국 “참조가 가리키는 값보다 오래 살아서는 안 된다”는 단순한 규칙이에요.
핵심을 정리해보면 이렇습니다.
모든 참조에는 수명이 있고, 빌림 검사기가 컴파일 시점에 댕글링 참조를 막아줍니다.
대부분의 경우 컴파일러가 자동으로 추론해주니까 직접 표기할 필요는 없어요.
함수가 여러 참조를 받고 그중 하나를 반환할 때, 그리고 구조체가 참조 필드를 가질 때는 'a 같은 수명 매개변수로 관계를 명시해줘야 합니다.
'static은 특별한 수명으로 프로그램 전체 기간 동안 유효한 참조를 가리키죠.
수명에 익숙해지면 Rust 코드를 보는 시야가 한결 넓어집니다. 함수 시그니처만 봐도 “이 함수는 입력에서 빌려와서 같은 수명의 참조를 돌려준다”는 식의 의도가 읽히거든요.
더 자세한 내용은 Lifetime Elision - Rust Reference를 참고하세요.
This work is licensed under
CC BY 4.0