[Rust 시리즈 14편] Lifetime 입문

English version

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에 묶여 있으므로, 호출자는 ab 중 더 짧은 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 표기를 생략합니다.

  1. 입력 참조마다 고유한 lifetime 파라미터를 가정합니다.
  2. 입력이 하나뿐이면 그 lifetime이 출력과 동일하다고 가정합니다.
  3. &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은 "관계 설명"이지 "수명 연장 장치"가 아닙니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

실습 과제

  1. fn find<'a>(haystack: &'a str, needle: &str) -> Option<&'a str> 함수를 작성하고, 반환값의 lifetime이 입력과 일치하도록 보장해 보세요.
  2. 구조체 필드에 참조를 저장하는 로거(logger)를 만들고, 'a를 이용해 컴파일 오류 없이 빌드해 보세요.
  3. lifetime elision 규칙이 적용되지 않는 예제를 의도적으로 만들어 보고, 컴파일러 메시지를 읽어 보세요.

완료 기준

  • lifetime 표기가 왜 필요한지, 어떤 문법으로 표현하는지 설명할 수 있다.
  • 구조체나 함수에서 기본적인 lifetime 파라미터를 직접 작성해 봤다.
  • lifetime elision 규칙 3가지를 말할 수 있다.

다음 편에서는 이런 참조 안전성을 유지한 상태로, 이터레이터와 클로저를 활용해 데이터 흐름을 간결하게 만드는 방법을 배웁니다.

💬 댓글

이 글에 대한 의견을 남겨주세요