Ownership과 borrowing을 익히면 대부분의 코드는 자연스럽게 컴파일되지만, 참조를 반환하는 함수에서 종종 "lifetime을 명시하라"는 오류를 만납니다. 이번 편에서는 lifetime 표기가 왜 필요한지, 최소한의 표기로 어떤 문제를 해결하는지, 그리고 컴파일러가 어떻게 빌려간 참조의 범위를 추적하는지 설명합니다.
이 글은 Rust 시리즈에서 가장 추상적으로 느껴질 수 있는 편 중 하나입니다. 그래서 처음부터 모든 규칙을 외우려 하기보다, "lifetime은 참조들 사이의 관계를 설명하는 이름표"라는 감각 하나만 먼저 잡고 따라오면 됩니다.
Lifetime은 무엇인가
- 정의: 참조가 유효한 기간을 컴파일러가 추론할 수 있도록 돕는 이름표입니다.
- 목적: dangling reference(이미 해제된 값을 가리키는 참조)를 예방합니다.
- 표기법:
'a,'b처럼 작은따옴표로 시작하는 이름을 사용합니다.
여기서 중요한 점은 'a가 실제 시간을 세는 값이 아니라, "이 참조와 저 참조는 같은 범위 안에서 살아 있어야 한다"는 관계를 표시하는 이름표라는 것입니다.
쉽게 비유하면, lifetime은 시계를 추가하는 것이 아니라 "이 출입증은 어느 구역까지 함께 들어갈 수 있는가"를 적어 두는 라벨에 가깝습니다.
왜 lifetime 표기가 필요할까
문제를 먼저 아주 단순하게 보면 아래와 같습니다.
- 함수가 참조를 입력으로 받는다.
- 함수가 참조를 다시 반환한다.
- 그런데 반환되는 참조가 어느 입력에서 왔는지 컴파일러가 확신하지 못한다.
이때 lifetime 표기가 필요해집니다.
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
- 위 함수는 컴파일되지 않습니다. 반환되는 참조가
a에서 왔는지b에서 왔는지 컴파일러가 확신할 수 없기 때문입니다. - 하지만 반환되는 참조가 한 매개변수에 종속된다는 사실을 컴파일러가 확실히 알아야 합니다.
- 더 복잡한 구조나 구조체 필드에 참조를 저장하려 할 때, 컴파일러는 추가 정보 없이는 안전성을 증명할 수 없습니다.
즉, lifetime 표기는 "참조를 얼마나 오래 살게 만들까"를 적는 문법이 아니라 "반환 참조가 어떤 입력 참조와 연결되는가"를 알려 주는 문법입니다.
명시적 lifetime 파라미터
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
<'a>는 이 함수가'a라는 lifetime 파라미터를 사용함을 뜻합니다.&'a str은 "'a동안 유효한 문자열 슬라이스"를 의미합니다.- 반환 타입도
'a에 묶여 있으므로, 호출자는a와b중 더 짧은 lifetime을 갖는 쪽만큼만 값이 유효하다는 사실을 알게 됩니다.
처음 읽을 때는 문장을 이렇게 번역해 보면 됩니다.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str
- 입력
a는'a동안 유효한 참조 - 입력
b도'a동안 유효한 참조 - 반환값도 같은
'a범위 안에서만 유효
이 정도만 읽혀도 lifetime 문법은 절반 이상 이해한 것입니다.
구조체에서 lifetime 사용하기
struct Highlight<'a> {
snippet: &'a str,
}
impl<'a> Highlight<'a> {
fn show(&self) {
println!("{}", self.snippet);
}
}
- 참조를 필드에 저장하는 구조체는 lifetime 파라미터를 선언해야 합니다.
Highlight<'a>는'a동안만 살아 있는 데이터를 빌려 쓴다는 의미입니다. 즉,Highlight인스턴스는snippet이 가리키는 원본 데이터보다 더 오래 살 수 없습니다.- 만약 소유권을 갖는
String을 필드로 가지면 lifetime 표기가 필요 없습니다.
함수 시그니처에서 자주 쓰는 lifetime 패턴
| 형태 | 의미 | 예시 |
|---|---|---|
fn foo<'a>(x: &'a str) -> &'a str |
입력과 출력이 같은 lifetime | 슬라이스 비교 후 반환 |
fn bar<'a, 'b>(x: &'a str, y: &'b str) -> &'a str |
각 참조가 독립 lifetime | 두 입력 중 한 쪽만 반환 |
impl<'a> Struct<'a> |
구조체 필드가 참조를 보존 | parser, lexer 등 |
Lifetime elision 규칙
Rust는 세 가지 기본 규칙을 통해 많은 경우 lifetime 표기를 생략합니다.
- 입력 참조마다 고유한 lifetime 파라미터를 가정합니다.
- 입력이 하나뿐이면 그 lifetime이 출력과 동일하다고 가정합니다.
&self또는&mut self메서드는self의 lifetime을 반환값에 적용합니다.
이 규칙이 적용되지 않는 복잡한 함수에서만 lifetime을 명시하면 됩니다. 그리고 컴파일러가 애매하다고 판단하면, 그때는 생략하지 말고 직접 lifetime을 적는 편이 가장 안전합니다.
입문 단계에서는 아래처럼 판단해도 충분합니다.
- 입력 참조가 하나: 생략되는 경우가 많다.
- 입력 참조가 둘 이상: 반환 참조가 누구와 연결되는지 애매해질 수 있다.
- 구조체 필드에 참조 저장: 직접 lifetime을 적는 경우가 많다.
'static lifetime
- 프로그램 전체 동안 살아 있는 데이터를 의미합니다.
- 문자열 리터럴(
"hello")은'static입니다. fn foo(x: &'static str)는 프로그램 종료까지 살아 있는 참조만 허용하므로, 사용 폭이 좁습니다.static제약을 무조건 붙이는 것은 좋은 해결책이 아닙니다.
Borrow checker와 lifetime 오류 읽기
fn main() {
let result;
{
let s = String::from("rust");
result = s.as_str();
}
println!("{}", result);
}
- 컴파일 오류:
s가 스코프를 벗어나면서 참조가 무효화됩니다. - lifetime 표기를 추가하더라도, 실제 데이터가 더 짧게 살아 있으면 문제를 해결할 수 없습니다. 즉, lifetime은 참조의 실제 생명주기를 연장해 주지 않습니다.
이 예제가 특히 중요한 이유는, lifetime이 마법처럼 데이터를 오래 살게 만들지 않는다는 점을 분명히 보여 주기 때문입니다. lifetime은 "관계 설명"이지 "수명 연장 장치"가 아닙니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요