Rust lifetime이 어려운 이유와 실전 패턴 5가지

게시일: 2025년 10월 10일 · 15분 읽기

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 규칙:

패턴 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 에러 메시지는 불친절하지만, 거의 항상 옳다. 에러를 읽고 따르면 된다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.