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은 "관계 설명"이지 "수명 연장 장치"가 아닙니다.
실습 과제
fn find<'a>(haystack: &'a str, needle: &str) -> Option<&'a str>함수를 작성하고, 반환값의 lifetime이 입력과 일치하도록 보장해 보세요.- 구조체 필드에 참조를 저장하는 로거(logger)를 만들고,
'a를 이용해 컴파일 오류 없이 빌드해 보세요. - lifetime elision 규칙이 적용되지 않는 예제를 의도적으로 만들어 보고, 컴파일러 메시지를 읽어 보세요.
완료 기준
- lifetime 표기가 왜 필요한지, 어떤 문법으로 표현하는지 설명할 수 있다.
- 구조체나 함수에서 기본적인 lifetime 파라미터를 직접 작성해 봤다.
- lifetime elision 규칙 3가지를 말할 수 있다.
다음 편에서는 이런 참조 안전성을 유지한 상태로, 이터레이터와 클로저를 활용해 데이터 흐름을 간결하게 만드는 방법을 배웁니다.
💬 댓글
이 글에 대한 의견을 남겨주세요