이 글은 Rust의 lifetime을 정리합니다. Rust 학습 노트 시리즈의 다섯 번째 글이며, 이전 글에서 다룬 borrow는 한 가지 대전제를 깔고 있었습니다. 바로, “빌림은 영원하지 않다는 전제”입니다. 이 글에서는 그 유한한 기간을 Rust 컴파일러가 어떻게 추적하는지, 그리고 그 추적의 결과가 코드에 어떻게 드러나는지를 다룹니다.
이전 글의 마무리에서 이렇게 적었습니다.
빌림이 영원하지 않다는 사실은 이 글에서 전제로 깔았지만, 그 유효 기간을 컴파일러가 어떻게 추적하는지는 따로 다뤄야 할 주제입니다. 바로 lifetime입니다.
borrow가 "주인은 그대로 두고 잠시 들여다본다"는 약속이라면, lifetime은 그 "잠시"가 정확히 언제까지인지를 컴파일러와 함께 정의하는 장치입니다. Rust를 익히면서 개인적으로 가장 어려웠던 것이, lifetime이었습니다. lifetime이 다른 언어에서는 아예 표기 자체가 없는 개념이라는 점에 있습니다. 항상 어떠한 개념이든, 외부에서 빗대어서 표현하기 마련인데, JavaScript에서는, 변수가 언제까지 살아 있는지를 코드에 써넣을 자리가 없습니다. Rust는 그 자리를 지정할 수 있습니다.
빌림이 가리키는 데이터가 먼저 사라지면
lifetime을 이야기하기 전에, 그것이 왜 존재하는지 짚어보겠습니다.
다음 코드는 외부 변수에 내부 변수의 참조를 담으려고 시도한 예시입니다.
let r; { let i = 1; r = &i; } println!("{}", r); // 컴파일 에러
i는 내부 block이 끝나면 scope를 벗어나 메모리에서 사라집니다. 그런데 r은 그 사라진 i를 가리키는 참조를 들고 외부에 살아남습니다. println!이 r을 출력하려는 순간, r이 가리키는 자리에는 더 이상 유효한 데이터가 없습니다.이 상황을
dangling reference(이미 사라진 데이터를 가리키고 있는 참조)라고 부릅니다. C, C++에서는 use-after-free라고도 불리고, 보통은 runtime (실행 시점)에 잘못된 메모리를 들여다보면서 알 수 없는 값을 읽거나 프로그램이 갑자기 죽는 형태로 드러납니다. Rust는 이 가능성을 애초에 컴파일 시점에 막아줍니다. 항상 컴파일 타임에 예측 가능하다는 점이 Rust를 매력적이게 만드는 요소중 하나죠. 과거에는 컴파일이 끝난 뒤에도 runtime에 여전히 신경을 써야 했지만, Rust에서는 그 부담의 상당 부분이 컴파일러 쪽으로 옮겨갑니다.
컴파일러가 보여주는 에러는 다음과 같습니다.
error[E0597]: `i` does not live long enough --> 01_dangling_reference.rs:12:13 | 11 | let i = 1; | - binding `i` declared here 12 | r = &i; // ❌ E0597: borrowed value does not live long enough | ^^ borrowed value does not live long enough 13 | } | - `i` dropped here while still borrowed 14 | println!("{}", r); | - borrow later used here error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0597`.
여기서 처음 등장하는 단어가
live long enough입니다. 빌림을 시작한 자리에서 그 빌림이 마지막으로 쓰이는 자리까지, 그 사이 내내 원본 데이터가 살아 있어야 한다는 의미입니다. lifetime이라는 단어가 가리키는 정확한 영역이 바로 이 구간입니다.lifetime이라는 이름이 가리키는 것
lifetime은 직역하면 수명이지만, Rust에서의 lifetime은 변수의 수명이 아닙니다. 참조가 유효해야 하는 코드의 구간입니다. 변수와 참조는 비슷해 보이지만 lifetime의 관점에서는 다른 자리에 놓입니다.
- 변수의 scope: 그 변수가 선언된 자리부터 block을 벗어나기 전까지
- 참조의 lifetime: 그 참조가 만들어진 자리부터 마지막으로 쓰이는 자리까지
이전 글에서 잠깐 짚었던 non-lexical lifetimes(빌림의 유효 범위를 변수의 scope가 아니라 실제 마지막 사용 시점까지로 좁히는 컴파일러 동작)가 정확히 이 차이에 기대고 있습니다. 변수는 scope를 벗어나야 사라지지만, 참조는 더 이상 쓰이지 않는 그 자리에서 컴파일러의 관점상 끝납니다.
다음은 두 lifetime이 겹치는 모양을 도식으로 나타낸 것입니다.
변수 i의 scope [─────────────────] 참조 r의 lifetime [────] │ │ │ └── r의 마지막 사용 └── r = &i 조건: r의 lifetime ⊂ i의 scope
r의 lifetime이 i의 scope 안에 완전히 포함되어 있어야 안전합니다. 위 코드가 막힌 이유는 r의 lifetime이 i의 scope를 벗어나기 때문이었습니다.컴파일러는 모든 참조에 대해 이런 추적을 매 순간 하고 있습니다. 다만 대부분의 경우 그 추적 결과가 코드에 드러나지 않습니다. 컴파일러가 알아서 해결할 수 있는 단순한 경우라면 굳이 개발자에게 알려줄 필요가 없기 때문입니다. lifetime 표기가 코드에 등장하는 순간은, 컴파일러의 추론이 한계에 부딪치는 자리입니다.
'a라는 표기가 등장하는 순간
Rust 코드를 읽다 보면
'a라는 표기를 만나게 됩니다. 처음 봤을 때는 ‘가 왜 거기 붙어 있는지부터 의아했습니다. 함수에서 참조를 반환하려고 시도하던 자리에서 컴파일러가 처음 이 표기를 요구했습니다.다음 코드는 두 문자열 중 더 긴 쪽의 참조를 반환하는 함수입니다.
fn longest_broken(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } }
이 함수는 컴파일되지 않습니다. 에러 메시지는 다음과 같습니다.
error[E0106]: missing lifetime specifier --> 03_longest_with_annotation.rs:9:40 | 9 | fn longest_broken(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` help: consider introducing a named lifetime parameter | 9 | fn longest_broken<'a>(x: &'a str, y: &'a str) -> &'a str { | ++++ ++ ++ ++ error[E0425]: cannot find function `longest` in this scope --> 03_longest_with_annotation.rs:26:18 | 26 | result = longest(s1.as_str(), s2.as_str()); | ^^^^^^^ not found in this scope error: aborting due to 2 previous errors Some errors have detailed explanations: E0106, E0425. For more information about an error, try `rustc --explain E0106`.
함수의 반환 타입이 빌려온 값이지만, 그 값이
x에서 빌린 것인지 y에서 빌린 것인지 시그니처가 말해주지 않는다는 것입니다. 함수 본문을 보면 어디서 빌렸는지 알 수 있지 않은가싶기도 하지만, 컴파일러는 호출자 입장에서 시그니처만 보고 안전성을 판단해야 합니다. 호출자는 함수 본문을 알 필요가 없고, 알 의무도 없습니다. 시그니처가 그 자체로 완결된 계약이어야 한다는 원칙이 이 자리에 박혀 있습니다.'a는 이 계약을 적기 위한 표지입니다.fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }
이 표기를 TypeScript 사용자라면 generic과 겹쳐 볼 수 있습니다.
fn longest<'a>와 function longest<T>는 꺽쇠괄호 안에 parameter를 적고 시그니처 곳곳에서 다시 사용하는 방식이 같고, 호출하는 자리마다 그 parameter가 다른 값으로 채워진다는 점도 같습니다. Rust 공식 문서가 lifetime parameter를 "generic lifetime parameter"라고 부르는 것도 같은 맥락입니다. 차이는 무엇을 매개변수화하느냐에 있습니다. <T>가 어떤 타입이든 받겠다는 자리를 비워두는 것이라면, <'a>는 어떤 구간이든 받겠다는 자리를 비워두는 것입니다. 같은 모양의 도구가 타입과 시간이라는 서로 다른 영역에서 동일하게 동작하는 셈입니다.세 자리에
'a가 박혔습니다. x와 y 두 입력 참조, 그리고 반환 참조가 모두 같은 'a를 공유합니다. 이 표기가 말하는 것은 다음과 같습니다. "어떤 lifetime 'a가 있다고 했을 때, 이 함수의 두 입력은 모두 적어도 'a만큼 살아 있어야 하고, 반환되는 참조도 'a만큼 살아 있다."여기서 한 가지를 분명히 짚을 필요가 있습니다.
'a는 시간을 지정하는 것이 아니라는 것입니다. 30초인지 5분인지를 적는 자리가 아니라, "이 셋의 lifetime은 같은 척도로 묶여 있다"는 사실을 명시하는 표기법입니다. 구체적인 값은 함수가 호출되는 자리마다 다르게 결정됩니다. 호출자에서 가장 짧은 lifetime을 가진 참조가 'a의 실제 값이 되고, 반환된 참조도 그만큼만 유효하다고 컴파일러가 판단합니다.처음에는 이 명칭이 시간을 의미한다고 오해했습니다. 그러나 시간을 적는 것이라면
'a라는 추상적인 이름이 아니라 구체적인 숫자가 들어가야 자연스럽습니다. lifetime annotation은 여러 참조의 lifetime이 어떻게 묶여 있는지를 묘사할 뿐, 그 lifetime의 길이를 바꾸지는 못합니다.잠깐, 그러면 매번 'a를 적어야 하는가
여기까지 읽고 아래와 같은 Rust 코드를 볼 때, 한 가지 의문이 따라옵니다. 책에 나오는 예제는 대부분 lifetime annotation 없이 멀쩡히 컴파일되는데, 왜 위
longest 함수만 유난히 까다로운가.다음 코드는 lifetime을 적지 않았지만 통과합니다.
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[..] }
이 함수도 입력 참조에서 빌려온 참조를 반환합니다.
longest와 구조적으로 같은 형태인데, lifetime을 적지 않아도 컴파일러가 받아줍니다. 이유는 lifetime elision rules(컴파일러가 자주 등장하는 패턴에 대해서는 lifetime을 자동으로 채워 넣어 주는 규칙)에 있습니다.규칙은 세 개입니다. The Rust Programming Language의 정의를 그대로 옮기면 다음과 같습니다.
위의
first_word함수는 두 번째 규칙으로 풀립니다. 입력 참조가 하나뿐이니 그 lifetime이 반환 참조에 그대로 붙고, 따라서 개발자가 따로 적을 필요가 없습니다.longest는 어디서 막혔는지가 분명해집니다. 첫 번째 규칙으로 x와 y가 각각 다른 lifetime을 받습니다. 두 번째 규칙은 입력이 둘이라 적용되지 않습니다. 세 번째 규칙은 self가 없으니 해당이 없습니다. 세 규칙이 모두 비껴가면서 반환 참조의 lifetime이 채워지지 않은 채 남고, 컴파일러는 멈춥니다.이 규칙들의 핵심은 추론이 아니라 단축 표기라는 점입니다. 자주 등장하는 패턴에 한해 적지 않아도 통과시켜 주는 것일 뿐, 컴파일러가 코드의 의도를 똑똑하게 파악해 주는 것이 아닙니다. 패턴에서 벗어난 시그니처라면 정확히 적어야 합니다.
'static 이라는 특수한 lifetime
마지막으로 짚을 것은
'static입니다. lifetime을 다루다 보면 컴파일러가 이 키워드를 권유하는 순간을 자주 만나게 되는데, 그 권유는 필수가 아니고, 받아들이면 안 되는 경우도 많다는 것을 알아두면 좋습니다.'static은 프로그램이 시작되어 끝날 때까지 살아 있는 lifetime을 의미합니다. 가장 흔하게 만나는 예시는 문자열 리터럴입니다.let s: &'static str = "I have a static lifetime.";
문자열 리터럴이
&'static str 타입을 갖는 이유는 그 데이터가 컴파일된 binary 안에 박혀 있기 때문입니다. heap에서 할당되고 해제되는 일반적인 String과 달리, 리터럴은 프로그램의 binary 자체에 포함되어 read-only 메모리에 자리 잡습니다. 따라서 프로그램이 실행되는 내내 사라질 일이 없고, 어디서 참조하든 안전합니다.문제는 컴파일러 에러를 풀려고 할 때
'static이 자주 등장한다는 점입니다. 다음과 같은 상황을 가정해봅시다.fn create() -> &str { let s = String::from("hello"); &s }
이 코드는 컴파일되지 않습니다.
s가 함수 안에서 만들어진 변수라 함수가 끝나면 drop되는데, 그 참조를 반환하면 dangling reference가 됩니다. 에러 메시지의 일부는 lifetime을 추가하라는 힌트와 함께 'static을 제안하기도 합니다.'static을 박아 넣으면 시그니처상 컴파일 에러는 잠시 사라질 수 있습니다. 그러나 그것은 문제를 푼 것이 아니라 덮어둔 것에 가깝습니다. &'static str은 "프로그램 전체에서 살아 있는 참조"라는 약속이고, 함수 안에서 만들어졌다 사라지는 String은 그 약속을 지킬 수 없습니다. 약속을 적어놓고 지키지 못한다는 새 에러가 따라옵니다.The Rust Programming Language의 lifetime 챕터를 보면 이 부분을 분명히 짚습니다.
컴파일러가'static을 제안하는 에러를 만나면, 정말로 그 참조가 프로그램 전체에서 살아 있어야 하는지 먼저 생각해야 합니다. 대부분의 경우 그 에러는 dangling reference 또는 lifetime mismatch 때문에 발생하며,'static을 박는 것은 해결이 아닙니다.
위
create 함수의 진짜 해결은 lifetime을 바꾸는 것이 아니라, 소유권을 반환하는 것입니다.fn create() -> String { String::from("hello") }
이렇게 바꾸면 함수가
String의 소유권을 호출자에게 넘기고, 호출자가 그 책임을 받습니다. 참조가 아니므로 lifetime을 따질 일도 없습니다. 학습 노트 (3)에서 정리한 소유권 모델이 lifetime 문제의 답인 경우가 의외로 많다는 것을, 이 자리에서 다시 강조해도 무색합니다.'static을 박는 것이 진짜 답인 경우도 물론 있습니다. 문자열 리터럴을 다루거나, static 키워드로 선언된 전역 상수를 가리키는 경우입니다. 그러나 일반적인 함수의 반환 lifetime을 풀기 위해 'static을 박는 것은 거의 잘못된 선택입니다. 컴파일러가 권유한다고 해서 따를 일이 아니라, 왜 그 권유가 나왔는지를 한 단계 더 파보아야 하는 케이스입니다.마무리
lifetime은 "참조가 유효해야 하는 코드의 구간을 컴파일러와 함께 정의하는 장치"로 요약됩니다.
'a 같은 표기는 그 구간의 길이를 지정하는 것이 아니라 여러 참조의 lifetime이 어떻게 묶여 있는지를 묘사할 뿐이며, 대부분의 경우 elision 규칙으로 가려져 코드에 드러나지 않습니다.이전 학습 노트들을 묶어 보면 Rust의 안전성 모델이 세 단계로 쌓여 있다는 것이 더 분명해집니다. 변수는 값의 주인이고(소유권), 그 주인됨은 잠시 빌려줄 수 있으며(borrow), 그 빌림은 정확히 어떤 구간 동안 유효한지를 표기로 명시한다(lifetime). 각 단계는 앞 단계가 남긴 빈자리를 메우기 위해 등장하고, 다음 단계는 그 단계가 다 풀지 못한 자리를 다시 받습니다.
다음 학습 노트에서는 struct를 본격적으로 다루려고 합니다. 이 글에서
'static까지 다룬 lifetime 이야기는 결국 "참조가 어느 변수에 묶여 있는가"라는 한 질문의 변주였습니다. struct는 애석하게도, 그 질문이 한 단계 복잡해지게 합니다. 여러 필드를 묶은 하나의 타입이 그 자체로 참조를 들고 있다면, 그 참조의 유효 구간은 누구의 lifetime을 따르는가. struct가 무엇인지부터, TypeScript의 interface나 객체와 어떻게 다른지, 그리고 lifetime이 struct와 만나는 자리까지 한 호흡으로 짚어보려 합니다.
kyu-log