생명주기로 참조 유효성 검증하기
생명주기로 참조 유효성 검증하기
참조를 다루다 보면 "이 참조가 가리키는 값이 과연 그 순간에도 유효할까?"라는 걱정이 생겨요. 생명주기(lifetimes)는 우리가 이미 쓰고 있던 또 다른 종류의 제네릭이에요. 다만 제네릭 타입은 "타입이 원하는 동작을 갖는지"를 보장하고, 생명주기는 "참조가 필요한 만큼 유효한지"를 보장해요. Rust의 모든 참조에는 수명이 있고, 대부분의 경우 타입 추론처럼 생명주기도 암시적으로 추론되어 우리가 신경 쓸 필요가 없어요. 다만 참조들의 수명이 여러 방식으로 관련될 수 있을 때는 명시적으로 표시해 줘야 해요.
생명주기 표시는 대부분의 프로그래밍 언어에 없는 개념이라 처음엔 낯설게 느껴져요. 이 페이지에서는 자주 마주치는 문법 위주로 다뤄서, 개념 자체에 익숙해지는 걸 목표로 할게요.
출처: The Rust Programming Language — Validating References with Lifetimes
댕글링 참조 방지
생명주기의 주된 목적은 댕글링 참조(dangling reference)를 막는 거예요. 댕글링 참조가 허용되면 프로그램이 의도한 데이터가 아닌 다른 데이터를 참조하게 되죠. 바깥 스코프와 안쪽 스코프가 있는 프로그램을 예로 들어 볼게요. 바깥 스코프는 초기 값 없이 변수 r을 선언하고, 안쪽 스코프는 초기 값 5로 변수 x를 선언해요. 안쪽 스코프에서 x에 대한 참조를 r로 설정하려 해요. 그러다 안쪽 스코프가 끝나고 r의 값을 출력하려고 하죠. 이 코드는 컴파일되지 않아요. r이 가리키는 값이 r을 쓰기 전에 이미 스코프를 벗어났기 때문이에요.
오류 메시지는 변수 x가 "충분히 오래 살지 않는다(does not live long enough)"고 말해요. x는 안쪽 스코프가 끝나면 스코프를 벗어나는데, r은 바깥 스코프에서 여전히 유효하니까요. 스코프가 더 크므로 r이 "더 오래 산다"고 해요. Rust가 이 코드를 허용했다면 r은 x가 스코프를 벗어날 때 해제된 메모리를 가리키는 참조가 되어, r로 하는 모든 작업이 제대로 동작하지 않을 거예요. 그럼 Rust는 어떻게 이 코드가 무효라고 판단할까요? 바로 대여 검사기(borrow checker)를 써서요.
대여 검사기
Rust 컴파일러에는 모든 대여(borrow)가 유효한지 판단하기 위해 스코프를 비교하는 '대여 검사기(borrow checker)'가 있어요. 같은 코드에서 각 변수의 수명에 표시를 붙여 보면, r의 수명을 'a, x의 수명을 'b라고 부를 수 있어요. 안쪽 'b 블록은 바깥 'a 수명 블록보다 훨씬 작죠. 컴파일 타임에 Rust는 두 수명의 크기를 비교해서, r은 수명 'a를 갖지만 'b 수명의 메모리를 가리킨다는 걸 봐요. 'b가 'a보다 짧으니 프로그램이 거부돼요. 참조의 대상이 참조보다 오래 살지 못하는 거죠.
반대로 x의 수명 'b가 'a보다 크다면, 즉 데이터가 참조보다 오래 산다면 유효한 참조예요. Rust는 r 안의 참조가 x가 유효한 동안 항상 유효하다는 걸 알 수 있으니까요.
함수에서의 제네릭 생명주기
두 문자열 슬라이스 중 더 긴 것을 반환하는 함수를 만들어 볼게요. 이 함수는 문자열 슬라이스, 즉 참조를 받아야 해요. 함수가 매개변수의 소유권을 가져가길 원하지 않으니까요. longest 함수는 이렇게 생겼어요.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
이대로는 컴파일되지 않아요. 오류 메시지의 도움말이 알려주는 것처럼, 반환 타입에 제네릭 생명주기 매개변수가 있어야 해요. Rust가 반환하는 참조가 x를 가리키는지 y를 가리키는지 알 수 없기 때문이죠. 실제로 우리도 모르는데, 함수 본문의 if 블록은 x에 대한 참조를, else 블록은 y에 대한 참조를 반환하니까요.
함수를 정의할 때는 어떤 구체 값이 전달될지, 그래서 if 쪽이 실행될지 else 쪽이 실행될지를 알 수 없어요. 전달될 참조들의 구체 수명도 모르니, 스코프를 봐서 반환 참조가 항상 유효한지 판단할 수도 없어요. 대여 검사기도 마찬가지로, x와 y의 수명이 반환 값의 수명과 어떻게 관련되는지 모르니 판단할 수 없어요. 이 오류를 고치려면 참조들 사이의 관계를 정의하는 제네릭 생명주기 매개변수를 추가해야 해요.
생명주기 표시 문법
생명주기 표시는 어떤 참조의 수명도 바꾸지 않아요. 여러 참조의 수명이 서로 어떻게 관련되는지 '만' 설명할 뿐이에요. 함수가 시그니처에 제네릭 타입 매개변수를 지정하면 어떤 타입이든 받을 수 있듯, 제네릭 생명주기 매개변수를 지정하면 어떤 수명의 참조든 받을 수 있어요.
생명주기 매개변수의 이름은 아포스트로피(')로 시작해야 하고, 보통 제네릭 타입처럼 소문자·짧은 이름이에요. 대부분 첫 번째 생명주기 표시로 'a를 써요. 표시는 참조의 & 뒤에, 참조의 타입과 공백으로 구분해 둬요. 예시를 볼게요. 생명주기 매개변수 없이 i32를 참조하는 것, 이름 'a의 생명주기 매개변수를 가진 i32 참조, 그리고 같은 생명주기 'a를 가진 가변 i32 참조예요.
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
생명주기 표시 하나만으로는 의미가 거의 없어요. 표시는 여러 참조의 제네릭 생명주기 매개변수가 서로 어떻게 관련되는지 Rust에 알려주기 위한 거니까요.
함수 시그니처에서
함수 시그니처에서 생명주기 표시를 쓰려면, 제네릭 타입 매개변수와 마찬가지로 함수 이름과 매개변수 목록 사이의 꺾쇠 안에 제네릭 생명주기 매개변수를 선언해요. 우리가 원하는 제약은 이거예요. "반환되는 참조는 두 매개변수가 모두 유효한 동안 유효하다." 수명을 'a라고 이름 짓고 각 참조에 추가해요.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
이 함수 시그니처는 Rust에 이렇게 알려줘요. 어떤 수명 'a에 대해 함수가 두 매개변수를 받는데, 둘 다 'a 이상 살아있는 문자열 슬라이스이고, 함수가 반환하는 문자열 슬라이스도 'a 이상 산다는 거죠. 실제로는 longest가 반환한 참조의 수명이 두 인자가 가리키는 값의 수명 중 더 짧은 것과 같아진다는 뜻이에요.
여기서 시그니처에 생명주기 매개변수를 지정해도 전달되거나 반환되는 값의 수명이 바뀌지는 않아요. 단지 이 제약에 맞지 않는 값은 대여 검사기가 거부하도록 지정하는 거예요. longest 함수는 x, y가 정확히 얼마나 살지 알 필요가 없어요. 이 시그니처를 만족시키는 어떤 스코프가 'a에 대입될 수 있기만 하면 돼요.
함수에서 생명주기를 표시할 땐 함수 본문이 아니라 시그니처에 넣어요. 생명주기 표시는 시그니처의 타입처럼 함수의 계약(contract)의 일부가 돼요. 함수 시그니처에 생명주기 계약이 담겨 있으면 Rust 컴파일러의 분석이 더 단순해져요. 함수가 어긋나게 표시되거나 호출되는 데 문제가 있으면 컴파일러 오류가 우리 코드의 해당 부분과 제약을 더 정확히 짚을 수 있어요. 만약 컴파일러가 수명 관계에 대한 우리의 의도를 더 많이 추론했다면, 문제의 원인에서 여러 단계 떨어진 코드 사용처만 가리킬 수도 있을 거예요.
longest에 구체 참조를 넘기면 'a에 대입되는 구체 수명은 x의 스코프와 y의 스코프가 겹치는 부분이 돼요. 즉 제네릭 수명 'a는 x와 y의 수명 중 더 짧은 것과 같은 구체 수명을 갖게 돼요. 반환 참조도 같은 생명주기 매개변수 'a로 표시했으므로, 반환 참조는 x, y 중 짧은 쪽의 수명 동안 유효해요.
그런데 result 변수 선언을 안쪽 스코프 밖으로 옮기고, result에 값을 할당하는 건 string2가 있는 안쪽 스코프 안에 두고, result를 쓰는 println!을 안쪽 스코프가 끝난 뒤로 옮기면 컴파일되지 않아요. 사람 눈에는 string1이 string2보다 길어서 result가 string1 참조를 담는 게 보여요. string1은 아직 스코프를 벗어나지 않았으니 그 참조는 여전히 유효할 것 같죠. 하지만 컴파일러는 그 참조가 유효한지 볼 수 없어요. 우리가 longest가 반환한 참조의 수명이 입력 참조 중 더 짧은 것과 같다고 Rust에 말해두었으니, 대여 검사기는 result가 무효 참조를 가질 수도 있다며 이 코드를 불허해요.
오류 메시지가 result가 println!에 유효하려면 string2가 바깥 스코프 끝까지 유효해야 한다고 알려줘요. 우리가 함수 매개변수와 반환 값의 수명을 같은 'a로 표시해뒀으니 Rust가 이걸 알 수 있는 거죠.
관계
생명주기 매개변수를 어떻게 지정할지는 함수가 하는 일에 따라 달라져요. 예를 들어 longest 함수의 구현을 바꿔 "항상 첫 번째 매개변수를 반환"하게 한다면, y 매개변수에는 수명을 지정할 필요가 없어요.
fn first<'a>(x: &'a str, y: &str) -> &'a str {
x
}
매개변수 x와 반환 타입에는 수명 매개변수 'a를 지정했지만 y에는 지정하지 않았어요. y의 수명이 x나 반환 값의 수명과 아무 관계가 없기 때문이에요.
함수에서 참조를 반환할 때, 반환 타입의 생명주기 매개변수는 매개변수 중 하나의 생명주기 매개변수와 맞아야 해요. 만약 반환하는 참조가 매개변수 중 하나를 가리키지 않는다면, 그건 이 함수 안에서 만들어진 값을 가리켜야 하는데, 그러면 댕글링 참조가 돼요. 그 값은 함수 끝에서 스코프를 벗어나니까요. result가 longest 함수 끝에서 스코프를 벗어나 정리되는데 그 참조를 반환하려는 건, 어떤 생명주기 매개변수를 지정해도 댕글링 참조를 바꿀 수 없어요. Rust는 댕글링 참조를 만들지 못하게 막아요. 이 경우 최선의 해결책은 참조 대신 소유한(owned) 데이터 타입을 반환해서, 호출하는 함수가 값을 정리하도록 하는 거예요.
결국 생명주기 문법은 함수의 여러 매개변수와 반환 값의 수명을 서로 연결하는 일이에요. 연결되면 Rust는 메모리 안전한 연산을 허용하고, 댕글링 포인터를 만들거나 메모리 안전을 위반할 연산은 불허할 충분한 정보를 갖게 돼요.
구조체 정의에서
지금까지 정의한 구조체들은 모두 소유한 타입만 담았어요. 구조체가 참조를 담게 하려면, 그 구조체 정의의 모든 참조에 생명주기 표시를 붙여야 해요. 문자열 슬라이스를 담는 ImportantExcerpt 구조체를 볼게요.
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}
이 구조체는 참조인 문자열 슬라이스를 담는 필드 part 하나를 가져요. 제네릭 데이터 타입과 마찬가지로, 구조체 이름 뒤의 꺾쇠 안에 제네릭 생명주기 매개변수의 이름을 선언해 구조체 정의 본문에서 그 매개변수를 쓸 수 있어요. 이 표시는 ImportantExcerpt 인스턴스가 part 필드에 담은 참조보다 오래 살 수 없다는 뜻이에요. main에서 novel 변수가 소유한 String의 첫 문장을 참조하는 인스턴스를 만들어요. novel의 데이터는 인스턴스가 만들어지기 전에 존재하고, novel은 인스턴스가 스코프를 벗어난 뒤에야 스코프를 벗어나므로, 인스턴스의 참조는 유효해요.
생명주기 생략(Lifetime Elision)
모든 참조에는 수명이 있고, 참조를 쓰는 함수나 구조체에는 수명 매개변수를 지정해야 한다고 배웠어요. 그런데 생명주기 표시 없이도 컴파일되는 함수가 있어요. 예를 들어 이 함수는 매개변수와 반환 타입이 둘 다 참조인데도 표시 없이 컴파일돼요.
fn first_word(s: &str) -> &str {
// ...
}
이게 컴파일되는 이유는 역사적이에요. Rust 초기 버전(pre-1.0)에서는 모든 참조에 명시적인 수명이 필요해서 이 코드가 컴파일되지 않았고, 시그니처를 fn first_word<'a>(s: &'a str) -> &'a str처럼 써야 했어요. Rust 팀은 코드를 많이 쓰다 보니 프로그래머들이 특정 상황에서 같은 생명주기 표시를 계속 반복해서 입력한다는 걸 발견했어요. 그 상황들은 예측 가능하고 몇 가지 결정적인 패턴을 따랐죠. 개발자들은 이 패턴을 컴파일러 코드에 프로그래밍해서, 그 상황에서 대여 검사기가 수명을 추론할 수 있게 하고 명시적인 표시를 요구하지 않게 했어요.
컴파일러의 참조 분석에 프로그래밍된 이 패턴들을 '생명주기 생략 규칙(lifetime elision rules)'이라고 불러요. 프로그래머가 따라야 할 규칙이라기보다는, 컴파일러가 고려할 특정 사례들의 집합이에요. 코드가 그 사례에 맞으면 수명을 명시적으로 쓸 필요가 없죠. 단, 생략 규칙이 완전한 추론을 제공하지는 않아요. 규칙을 적용한 뒤에도 참조가 가질 수명에 모호함이 남으면 컴파일러는 나머지 참조의 수명을 추측하지 않아요. 추측하는 대신, 생명주기 표시를 추가해 해결할 수 있는 오류를 내줘요.
함수나 메서드 매개변수의 수명은 '입력 수명(input lifetimes)', 반환 값의 수명은 '출력 수명(output lifetimes)'이라고 해요. 컴파일러는 명시적인 표시가 없을 때 참조 수명을 알아내기 위해 세 규칙을 써요. 첫 규칙은 입력 수명에, 둘째와 셋째 규칙은 출력 수명에 적용돼요. 세 규칙을 다 거친 뒤에도 수명을 알아낼 수 없는 참조가 남으면 컴파일러는 오류로 멈춰요. 이 규칙들은 fn 정의와 impl 블록 모두에 적용돼요.
- 첫 규칙: 컴파일러는 참조인 각 매개변수에 수명 매개변수 하나를 할당해요. 매개변수가 하나면 수명도 하나(
fn foo<'a>(x: &'a i32)), 둘이면 수명도 둘(fn foo<'a, 'b>(x: &'a i32, y: &'b i32))이 되는 식이에요. - 둘째 규칙: 입력 수명 매개변수가 정확히 하나면, 그 수명이 모든 출력 수명 매개변수에 할당돼요(
fn foo<'a>(x: &'a i32) -> &'a i32). - 셋째 규칙: 입력 수명 매개변수가 여러 개인데 그중 하나가
&self나&mut self라면(메서드이기 때문),self의 수명이 모든 출력 수명 매개변수에 할당돼요. 이 규칙 덕분에 필요한 기호가 줄어들어 메서드를 읽고 쓰기가 훨씬 좋아져요.
우리가 컴파일러라고 상상하며 first_word 시그니처에 적용해 보죠. 처음엔 참조와 연결된 수명이 없어요. 첫 규칙이 각 매개변수에 자기 수명을 할당하고, 수명이 정확히 하나이므로 둘째 규칙이 그 입력 수명을 출력 수명에 할당해요. 결국 fn first_word<'a>(s: &'a str) -> &'a str가 되고, 모든 참조에 수명이 생겨 프로그래머가 표시할 필요 없이 분석을 계속할 수 있어요.
longest 함수에는 세 규칙을 적용해도 반환 타입의 수명을 알아낼 수 없어서 오류가 났어요. 입력 수명이 둘 이상이라 둘째 규칙이 적용되지 않고, longest는 메서드가 아니라 함수라 어떤 매개변수도 self가 아니어서 셋째 규칙도 적용되지 않죠. 세 규칙을 다 거친 뒤에도 반환 타입의 수명을 모르니 컴파일러가 오류를 내준 거예요.
메서드 정의에서
수명이 있는 구조체에 메서드를 구현할 때는 제네릭 타입 매개변수와 같은 문법을 써요. 구조체 필드의 수명 이름은 항상 impl 키워드 뒤에 선언한 뒤 구조체 이름 뒤에서 써야 해요. 그 수명들이 구조체 타입의 일부이기 때문이에요. impl 블록 안의 메서드 시그니처에서 참조는 구조체 필드의 수명에 묶일 수도 있고 독립적일 수도 있어요. 또한 생명주기 생략 규칙 덕분에 메서드 시그니처에서 생명주기 표시가 필요 없는 경우도 많아요.
ImportantExcerpt를 예로 들어 볼게요. 매개변수가 &self 하나뿐이고 i32를 반환하는 level 메서드에서는, impl과 타입 이름 뒤의 수명 매개변수 선언은 필요하지만, 첫 번째 생명주기 생략 규칙 덕분에 self 참조의 수명을 표시할 필요가 없어요. &self를 포함해 입력 수명이 둘인 메서드(예: &self와 announcement)에서는 첫 규칙이 각각에 수명을 주고, 매개변수 중 하나가 &self이므로 반환 타입이 &self의 수명을 받아 모든 수명이 설명돼요.
스태틱 수명
특별한 수명 하나를 다뤄야 해요. 'static은 해당 참조가 프로그램 전체 실행 동안 살 수 있음을 뜻해요. 모든 문자열 리터럴은 'static 수명을 가져요.
let s: &'static str = "I have a static lifetime.";
이 문자열의 텍스트는 프로그램 바이너리에 직접 저장되어 늘 사용 가능하므로, 모든 문자열 리터럴의 수명은 'static이에요. 오류 메시지에서 'static 수명을 쓰라는 제안을 볼 수 있는데, 참조에 'static 수명을 지정하기 전에 그 참조가 정말로 프로그램 전체 수명 동안 사는지, 그리고 그걸 원하는지 생각해 봐야 해요. 대부분의 경우 'static 수명을 제안하는 오류는 댕글링 참조를 만들려 하거나 사용 가능한 수명이 맞지 않아서예요. 그런 때는 'static 수명을 지정하는 게 아니라 그 문제들을 고치는 게 해결책이에요.
제네릭 타입 매개변수, 트레이트 바운드, 생명주기 함께 쓰기
함수 하나에 제네릭 타입 매개변수·트레이트 바운드·생명주기를 한꺼번에 지정하는 문법도 볼게요. 앞의 longest 함수에, Display 트레이트를 구현한 어떤 타입이든 채울 수 있는 제네릭 타입 T의 매개변수 ann을 추가했어요. {} 로 ann을 출력하므로 Display 트레이트 바운드가 필요해요. 생명주기도 제네릭의 일종이므로, 수명 매개변수 'a와 제네릭 타입 매개변수 T의 선언은 함수 이름 뒤 같은 꺾쇠 목록에 들어가요.
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() {
x
} else {
y
}
}
요약
이번에 제네릭 타입 매개변수·트레이트와 트레이트 바운드·제네릭 생명주기 매개변수를 배웠어요. 이제 중복 없는 코드를 여러 상황에서 쓸 준비가 됐어요. 제네릭 타입 매개변수는 코드를 다양한 타입에 적용하게 해주고, 트레이트와 트레이트 바운드는 타입이 제네릭이어도 코드가 필요한 동작을 갖도록 보장해요. 생명주기 표시는 이런 유연한 코드에 댕글링 참조가 생기지 않게 해주고, 이 모든 분석은 컴파일 타임에 일어나 런타임 성능에는 영향을 주지 않아요.
이 주제는 배울 게 더 많아요. Chapter 18은 트레이트를 쓰는 또 다른 방식인 트레이트 객체를 다루고, 생명주기 표시의 더 복잡한 시나리오는 아주 고급 상황에서만 필요해요. 그런 경우엔 Rust Reference를 참고하면 돼요. 다음으로는 코드가 의도대로 동작하는지 확인하는 테스트 작성법을 배울 거예요.
더 알아보기 (Learn more)
- 참조와 대여, 그리고 그 규칙은 Chapter 4 — 참조와 대여에서 다뤄요.
- 트레이트 바운드,
impl Trait,where절 문법은 Chapter 10 — 트레이트에서 자세히 볼 수 있어요. - 더 복잡한 생명주기 시나리오는 The Rust Reference — Trait Bounds를 참고하세요.