Rust에서의 Borrowing과 Lifetime - 예제로 이해하기

1. 개요

Borrowing과 lifetime은 Rust를 처음 배우는 개발자들이 컴파일러와 함께 가장 많이 씨름하는 개념입니다. 이 글에서는 Order, Customer 같은 익숙한 예제를 통해 borrowing(&T, &mut T)과 lifetime annotation('a)을 설명합니다. 이 글을 읽고 나면 borrow checker의 오류 메시지를 이해하고, lifetime annotation을 직접 작성해야 할 때와 컴파일러가 자동으로 추론할 때를 구분할 수 있게 될 것입니다.

2. 소유권(Ownership) 빠른 복습

Borrowing을 이야기하기 전에 먼저 기억해야 할 점은: Rust의 모든 값은 정확히 하나의 owner를 가진다는 것입니다. 값을 매개변수로 전달하면 ownership이 이동합니다.

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("고객: {}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("Nguyễn Văn A") };
    print_customer(customer);
    // println!("{}", customer.name); // 오류: customer가 이미 이동했습니다
}

print_customer는 Customer를 값으로 받으므로, customer는 함수로 이동합니다. 주석을 제거하면 오류가 발생합니다:

error[E0382]: borrow of moved value: `customer`

Borrowing은 정확히 이 문제를 해결하기 위해 존재합니다: 다른 함수가 ownership을 빼앗기지 않으면서도 데이터를 “빌려갈” 수 있게 하는 것입니다.

3. 불변 Borrowing: &T

&T는 데이터를 읽기만 하고 수정할 필요가 없을 때 사용합니다.

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("합계: {}", total);
    println!("항목 개수: {}", order.items.len()); // order를 계속 사용 가능
}
합계: 400
항목 개수: 3

calculate_total는 &Order, 즉 불변 참조를 받습니다. order는 여전히 main에 속하고, 함수는 읽기 위해서만 “임시로 빌려갑니다”. 따라서 함수 호출 후에도 main에서 order를 계속 사용할 수 있습니다. 한 번에 여러 &Order를 만들 수 있으며, &mut Order만 없으면 됩니다.

4. 가변 Borrowing: &mut T

데이터를 참조를 통해 수정해야 할 때는 &mut T를 사용합니다.

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("할인율: {}%", order.discount_percent);
}
할인율: 10%

두 가지 중요한 점: 변수 order를 mut로 선언해야 하고, 함수 호출 시 &mut order를 전달해야 합니다. &T와 달리, 한 번에 **정확히 하나의 &mut T**만 존재할 수 있으며, 동시에 다른 &T가 있으면 안 됩니다.

5. Borrow Checker: 규칙 - “한 명의 소유자, 여럿의 읽기 또는 한 명의 쓰기”

핵심 규칙: 하나의 데이터에 대해 한 번에 여러 &T 또는 **하나의 &mut T**만 가질 수 있으며, 섞어서 사용할 수 없습니다.

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // 오류: order.items가 루프에서 불변 참조로 빌려가고 있습니다
    }
}

for item in &order.items는 루프 전체에서 살아있는 불변 참조를 만듭니다. 루프 내에서 order.items.push(...)는 동일한 데이터에 대한 가변 참조가 필요합니다 → 충돌합니다. 컴파일러가 다음과 같이 보고합니다:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

일반적인 해결 방법: 추가할 값들을 임시 Vec에 수집한 다음 루프가 끝난 후 extend하기 - 즉, 두 borrow를 서로 다른 시점으로 분리하는 것입니다.

주의: borrow checker는 “실제 생명주기” (non-lexical lifetimes)에 따라 분석하며, 블록 {}에 따라 분석하지 않습니다. 두 borrow의 생명주기가 겹치지 않으면, 같은 함수 내에 있어도 유효합니다.

6. Lifetime이란 무엇인가

Lifetime은 Rust가 “이 참조가 얼마나 오래 유효한가”를 나타내는 방법으로, 컴파일러가 원본 데이터가 drop된 이후에 참조를 사용하는 상황을 방지합니다. 대부분의 경우 lifetime을 직접 작성할 필요가 없습니다 - 컴파일러가 추론합니다. 하지만 함수가 여러 참조를 받아서 참조를 반환할 때는, 반환된 참조가 어느 매개변수에서 “빌려온” 것인지 명확히 알려야 합니다.

6.1. 함수의 lifetime annotation

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("Nguyễn Văn A");
    let customer2 = String::from("Trần Thị B dài hơn");

    let longest = longest_name(&customer1, &customer2);
    println!("가장 긴 이름: {}", longest);
}
가장 긴 이름: Trần Thị B dài hơn

'a는 새로운 생명주기를 만드는 것이 아니라 제약을 나타내는 이름일 뿐입니다: a, b, 그리고 반환 값이 모두 최소한 'a만큼 살아있어야 합니다. 컴파일러는 런타임에 함수가 a를 반환할지 b를 반환할지 모르므로, 입출력이 모두 같은 lifetime을 공유하도록 강제하여 모든 경우에서 안전함을 보장합니다.

만약 잘못 작성해서 - 선언된 lifetime과 무관한 참조를 반환하면:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("tạm");
    &local // 오류: local이 함수 끝에서 drop됩니다
}
error[E0515]: cannot return value referencing local variable `local`

6.2. Struct의 lifetime

참조를 포함하는 struct도 lifetime annotation이 필요합니다. Rust는 struct가 빌려온 데이터보다 “오래 살아있지 않음”을 보장해야 하기 때문입니다.

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Lê Văn C") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - 합계: {}", summary.customer.name, summary.total);
}

OrderSummary<'a>는 Customer를 소유하지 않고, 단지 참조만 가집니다. Lifetime 'a는 summary가 customer보다 오래 살 수 없음을 강제합니다. 만약 summary를 사용하기 전에 customer를 drop하면, 컴파일러가 컴파일 시점에 차단합니다 - dangling pointer가 런타임에 도달할 수 없습니다.

6.3. Lifetime을 작성할 필요가 없는 경우 (elision)

Rust에는 대부분의 함수에 적용되는 3가지 lifetime 자동 추론 규칙(lifetime elision)이 있습니다:

  1. 각 참조 매개변수는 고유한 lifetime을 얻습니다.
  2. 함수가 정확히 하나의 입력 lifetime만 가지면, 그것이 출력에 할당됩니다.
  3. &self 또는 &mut self가 있으면, self의 lifetime이 출력에 할당됩니다.
fn first_word(text: &str) -> &str {
    // 규칙 2: 입력 lifetime이 1개만 -> 자동으로 출력에 할당됨
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

이 함수는 'a를 작성할 필요가 없습니다. 참조 매개변수가 하나뿐이므로 컴파일러가 자동으로 출력이 text에서 빌려온다고 추론합니다. lifetime을 직접 작성해야 하는 경우는 두 개 이상의 입력 참조가 있고 출력이 2번, 3번 규칙에 해당하지 않을 때입니다.

7. 실제 예제: 슬라이스를 반환하는 parser

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("giao hàng nhanh trong ngày");
    let word = first_word(&order_note);

    println!("첫 단어: {}", word);
}
첫 단어: giao

first_word는 새로운 String을 만들지 않고, 단지 order_note의 메모리 영역 안에 있는 slice(&str)를 반환합니다. 이는 Rust 파서에서 매우 흔한 패턴입니다: 추가 할당을 피하고 원본 데이터만 “가리키기”. Lifetime은 word가 order_note보다 오래 살 수 없음을 보장합니다.

컴파일러가 어떻게 보호하는지 보기 위해 잘못된 버전을 써봅시다:

fn first_word_broken() -> &str {
    let text = String::from("giao hàng nhanh");
    first_word(&text) // text는 함수 끝에서 drop됩니다
}
error[E0106]: missing lifetime specifier

컴파일러는 출력에 유효한 lifetime을 할당할 방법이 없기 때문에 즉시 컴파일을 거부합니다 - text는 반환 값이 호출자에서 사용되기 전에 확실히 죽기 때문입니다.

first_word에 대한 테스트

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn 첫_단어를_올바르게_반환() {
        let note = "giao hang nhanh trong ngay";
        assert_eq!(first_word(note), "giao");
    }

    #[test]
    fn 공백이_없을_때_전체_문장_반환() {
        let note = "ship";
        assert_eq!(first_word(note), "ship");
    }
}

cargo test를 실행하면:

running 2 tests
test tests::첫_단어를_올바르게_반환 ... ok
test tests::공백이_없을_때_전체_문장_반환 ... ok

8. 흔한 오류들

  • 지역 변수에 대한 참조 반환: 변수는 함수 끝에서 drop되지만, 참조는 호출자에서 “살아있습니다” → 컴파일러가 lifetime 오류로 차단하며, 절대 런타임에 도달하지 않습니다.

  • 오래된 &T를 유지한 후 &mut T 빌려가기: .iter() 결과를 변수에 저장한 후 같은 scope에서 원본 struct를 변경할 때 흔합니다. 해결 방법: 블록 {}으로 불변 borrow의 scope를 줄이거나 읽기/쓰기 로직을 두 단계로 분리합니다.

  • borrow checker를 피하기 위해 clone() 남용하기: clone()은 항상 컴파일되지만 데이터 복사 비용이 큽니다. 실제로 독립적인 복사본이 필요할 때만 사용하고, borrow 오류를 “응급 처치”하기 위해 사용하지 마세요.

  • 불필요한 lifetime annotation 작성하기: 함수가 참조 매개변수를 하나만 가지면 'a를 직접 작성하지 마세요 - elision rule이 이미 처리합니다. 추가로 작성하면 코드만 복잡해집니다.

주의: 코드가 컴파일되기 위해 'static을 추가해야 한다면, 멈추고 설계를 다시 생각해봅시다. 'static은 데이터가 프로그램 전체 기간 동안 살아있도록 강제하며, 이는 보통 진정한 ownership 문제를 회피하고 있다는 신호입니다.

9. 결론

Borrowing은 ownership을 빼앗기지 않으면서도 데이터를 빌려갈 수 있게 해주며, “여럿의 읽기 또는 한 명의 쓰기” 규칙은 컴파일 시점에 borrow checker가 확인합니다. Lifetime은 컴파일러와 개발자가 참조가 얼마나 오래 유효한지를 함께 명시하는 언어이며, 함수가 참조를 반환하거나 struct가 내부에 참조를 포함할 때 특히 중요합니다. 대부분의 경우 컴파일러가 elision rule을 통해 lifetime을 자동 추론합니다. 입력 참조가 두 개 이상이고 출력의 출처가 명확하지 않을 때만 직접 작성하면 됩니다.

rustborrowinglifetimeownershipmemory-safety