Rust lifetime이 어려운 이유와 실전 패턴 5가지
lifetime 에러 메시지를 보면 아직도 가끔 현기증이 난다
Rust 학습의 벽은 ownership이 아니라 lifetime이다. Ownership은 "이해하면" 간단하지만, lifetime은 "이해한 후에도" 복잡하다.
C++을 오래 해온 나도 처음엔 한국말 같은 에러 메시지를 봤다. "for<'a> &Self::Foo(fn(&'a str) -> &'a str)"... 이게 뭐라는 건가?
이 글은 Rust lifetime의 5가지 실전 패턴을 정리한 것이다.
패턴 1: 함수 반환값과 인자
기본:
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
여기서 lifetime이 생략되어 있다. 확장하면:
fn first_word<'a>(s: &'a str) -> &'a str {
// ...
}
의미: "반환되는 참조는 입력 인자와 같은 lifetime을 가진다"
왜 생략되나? Rust의 lifetime elision 규칙:
- 함수에 인자가 1개면, 그 lifetime을 반환값에 적용
- self 메서드면, self의 lifetime을 반환값에 적용
패턴 2: 구조체에 참조 저장
// 틀린 예
struct Parser {
input: &str, // 에러: lifetime 필요
}
// 올바른 예
struct Parser<'a> {
input: &'a str,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Parser { input }
}
fn parse(&self) -> String {
self.input.to_uppercase()
}
}
의미: "Parser가 유효한 동안, input도 유효해야 한다"
사용:**
let data = String::from("hello");
let parser = Parser::new(&data);
// data가 scope를 벗어나면, parser도 사용 불가
// drop(data); // 에러가 날 것
패턴 3: 여러 lifetime
struct Processor<'a, 'b> {
input: &'a str,
output: &'b mut String,
}
impl<'a, 'b> Processor<'a, 'b> {
fn process(&mut self) {
*self.output = self.input.to_uppercase();
}
}
왜 필요한가? 두 참조가 다른 lifetime을 가질 수 있기 때문:
let input = String::from("hello");
let mut output = String::new();
{
let processor = Processor {
input: &input,
output: &mut output,
};
processor.process();
}
println!("{}", output); // "HELLO"
패턴 4: 'static lifetime
'static은 "프로그램 전체 동안 유효"를 의미한다.
fn print_static(s: &'static str) {
println!("{}", s);
}
print_static("Hello"); // 문자열 리터럴 = 'static
let data = String::from("world");
// print_static(&data); // 에러: &data는 'static이 아님
'static이 필요한 경우:
- 스레드 생성
- 전역 변수
- 동적 디스패치와 trait object
use std::thread;
let s = String::from("hello");
// thread::spawn(|| println!("{}", s));
// 에러: s가 'static이 아님
let s = "hello"; // 리터럴
thread::spawn(|| println!("{}", s)).join().unwrap(); // OK
패턴 5: 비동기와 lifetime
가장 까다로운 부분이다.
async fn fetch_data(url: &str) -> String {
// 에러: url의 lifetime이 future와 같아야 함
// ...
}
// 올바른 방법
async fn fetch_data(url: &'static str) -> String {
// ...
}
또는:
async fn fetch_data(url: String) -> String {
// String을 소유하면 lifetime 걱정 없음
// ...
}
실제 예제:**
use tokio::task;
#[tokio::main]
async fn main() {
let url = "https://example.com".to_string();
// task::spawn은 'static을 요구
task::spawn(async move {
// url을 move로 획득 → 'static이 됨
println!("{}", url);
}).await.unwrap();
}
lifetime 에러 해결 전략
에러 1: "borrowed value does not live long enough"
fn take_ref(s: &str) -> &str {
s // OK
}
fn bad_example() -> &str {
let s = String::from("hello");
&s // 에러: s가 함수 끝에서 drop됨
}
// 해결책 1: String을 반환
fn good_example() -> String {
String::from("hello")
}
// 해결책 2: 인자에서 참조받기
fn good_example<'a>(input: &'a str) -> &'a str {
input
}
에러 2: "cannot infer lifetime"
struct Config {
name: &str, // 에러: lifetime이 필요
}
// 해결책
struct Config<'a> {
name: &'a str,
}
에러 3: "lifetime mismatch"
fn combine<'a>(a: &'a str, b: &'a str) -> &'a str {
// 둘 다 같은 lifetime이어야 함
if a.len() > b.len() { a } else { b }
}
let a = String::from("hello");
let b = "world"; // 다른 lifetime?
// 일반적으로 작동함 (Rust가 짧은 lifetime으로 조정)
자주 사용하는 lifetime 패턴
패턴 1: Self와 반환값이 같은 lifetime
impl<'a> MyIterator<'a> {
fn next(&mut self) -> Option<&'a str> {
// ...
}
}
패턴 2: 입력과 출력이 다른 lifetime
fn combine<'a, 'b>(a: &'a str, b: &'b str) -> String {
format!("{}{}", a, b)
}
패턴 3: Trait bound
trait Handler<'a> {
fn handle(&'a self, data: &'a str);
}
실제 사례: JSON 파서
struct JsonParser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> JsonParser<'a> {
fn new(input: &'a str) -> Self {
JsonParser { input, pos: 0 }
}
fn parse_string(&mut self) -> Result<&'a str, String> {
// 입력 문자열의 부분을 반환
// 반환값의 lifetime은 input과 같음
let start = self.pos;
// ... 파싱 로직 ...
Ok(&self.input[start..self.pos])
}
}
fn main() {
let json = r#"{"name": "Alice"}"#;
let mut parser = JsonParser::new(json);
match parser.parse_string() {
Ok(s) => println!("{}", s),
Err(e) => println!("Error: {}", e),
}
}
lifetime 없이 짜기 (최후의 수단)
정말 복잡하면, String으로 소유권을 가지거나, Rc/Arc를 사용한다:
use std::rc::Rc;
struct Parser {
input: Rc, // 참조가 아니라 소유권
}
impl Parser {
fn new(input: String) -> Self {
Parser {
input: Rc::new(input),
}
}
}
결론
Rust lifetime이 어려운 이유: 메모리 안전성을 컴파일 타임에 보증하려면, 명시해야 하기 때문이다.
하지만 한 번 이해하면:
- 메모리 버그가 사라진다
- 코드 의도가 명확해진다
- 성능이 예측 가능해진다
오랜 개발 경험에서 얻은 깨달음: lifetime 에러 메시지는 불친절하지만, 거의 항상 옳다. 에러를 읽고 따르면 된다.