프로그래밍 PROGRAMMING/기타 ETC

[RUST] Rust Borrow Checker와 라이프타임: 메모리 안전성

매운할라피뇨 2026. 2. 27. 08:38
반응형

Rust Borrow Checker와 라이프타임: 메모리 안전성

개요

Rust에서 Borrow Checker라이프타임(Lifetimes) 은 가비지 컬렉터 없이 메모리 안전성과 스레드 안전성을 보장하는 두 축입니다. Borrow Checker는 "누가 언제 값을 빌리는지"를 검사하고, 라이프타임은 "참조가 언제까지 유효한지"를 타입에 명시합니다. 이 조합으로 use-after-free, 댕글링 포인터, 데이터 레이스가 컴파일 단계에서 걸러집니다. 이 글에서는 두 개념의 동작 원리와, 컴파일러가 어떤 규칙을 적용하는지 다룹니다.


Borrow Checker(빌림 검사기)란 무엇인가

Borrow Checker는 Rust 컴파일러의 일부로, 참조(&T, &mut T) 가 소유권 규칙을 위반하지 않는지 검사합니다. 검사는 컴파일 타임에만 이루어지며, 런타임 비용이 없습니다.

빌림의 세 가지 규칙

  1. 불변 참조(&T) 는 동시에 여러 개 허용됩니다. 해당 구간에서 값이 변경되면 안 됩니다.
  2. 가변 참조(&mut T) 는 동시에 단 하나만 허용됩니다. 같은 스코프에서 불변 참조와 공존할 수 없습니다.
  3. 참조는 소유자(데이터)보다 오래 살 수 없습니다. 소유자가 drop된 뒤 참조가 남으면 댕글링 포인터가 되기 때문입니다. (댕글링 포인터란, 이미 해제된 메모리를 가리키는 참조를 말합니다. 그 메모리는 다른 용도로 쓰이거나 비어 있을 수 있어서, 참조를 따라가면 잘못된 값을 읽거나 크래시가 날 수 있습니다.)

이 규칙을 위반하면 컴파일 에러가 나며, 에러 메시지에 "어디서 빌렸고, 어디서 사용돼서 충돌하는지"가 표시됩니다.


변경전: C 스타일로 생각했을 때의 실수

C나 C++에서는 "참조를 넘기고 나서 원본을 수정하거나 해제해도" 컴파일러가 막아 주지 않습니다. 다음처럼 같은 데이터에 대한 가변 접근이 겹치는 코드를 Rust로 쓰면 Borrow Checker가 에러를 냅니다.

// 변경전(의도): 벡터를 순회하면서 같은 벡터를 수정하려 함 — Borrow Checker가 거부
fn append_while_iter() {
    let mut v = vec![1, 2, 3];
    for x in &v {
        v.push(*x + 1);  // 컴파일 에러: v를 이미 불변으로 빌렸는데 가변 빌림 시도
    }
}

컴파일러 메시지(요지): for x in &v는 "v를 읽기만 하겠다"고 빌린 상태(불변 빌림)입니다. 그런데 루프 안에서 v.push(...)는 "v를 수정하겠다"고 빌리는(가변 빌림) 코드라서, 같은 시점에 "읽기"와 "수정"이 겹치게 됩니다. Rust는 이 둘을 동시에 허용하지 않습니다.
이렇게 허용하면 어떤 문제가 생기나요? 루프가 "지금 여기까지 읽었다"고 기억하는 동안, push로 새 값을 넣으면 벡터 안의 메모리 배치가 바뀌거나, 용량이 부족해 메모리를 새로 잡으면서 예전 메모리를 해제할 수 있습니다. 그러면 아직 읽는 중인 위치가 없어진 메모리를 가리키게 되어(반복자 무효화), 잘못된 값을 읽거나(use-after-free와 비슷한 상황) 크래시로 이어질 수 있습니다. 그래서 "읽는 동안에는 수정하지 말라"는 규칙으로 한 번에 하나의 용도만 허용하는 것입니다.


변경후: Borrow Checker 규칙에 맞게 수정

순회와 수정을 동시에 하지 않고, 순회가 끝난 뒤에 수정하거나, 인덱스로 접근하는 식으로 나누면 Borrow Checker를 통과합니다.

// 변경후: 먼저 순회로 필요한 값을 수집하고, 그 다음 가변 접근
fn append_after_collect() {
    let mut v = vec![1, 2, 3];
    let to_add: Vec<i32> = v.iter().map(|x| x + 1).collect();
    v.extend(to_add);  // 불변 빌림이 끝난 뒤 가변 접근
    println!("{:?}", v);
}

Output:

[1, 2, 3, 2, 3, 4]

요점: Borrow Checker는 "동시에 불변/가변 빌림이 겹치지 않도록" 강제합니다. 순서를 바꿔서 빌림이 겹치지 않게 쓰면 컴파일이 되고, 그 결과 메모리 안전성이 보장됩니다.


라이프타임(Lifetimes)이란 무엇인가

라이프타임은 참조가 어느 스코프(구간) 동안 유효한지를 타입에 붙인 정보입니다. 참조는 반드시 "참조되는 데이터"보다 먼저 소멸되어야 하므로, 컴파일러는 라이프타임을 이용해 댕글링 포인터가 나오지 않도록 검사합니다.

라이프타임 표기: 'a

라이프타임 파라미터는 'a, 'b처럼 작은따옴표 + 이름으로 씁니다. "이 참조는 최소한 'a만큼 살아 있어야 한다"는 제약을 표현합니다.

// 라이프타임 'a: x와 y 중 더 짧은 수명만큼만 결과 참조가 유효하다고 표시
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("short");
    let result;
    {
        let s2 = String::from("longer");
        result = longer(s1.as_str(), s2.as_str());
    }  // s2 drop
    println!("{}", result);  // 컴파일 에러: result가 s2를 가리킬 수 있어 댕글링 위험
}

컴파일러 메시지(요지): results2를 가리킬 수 있는데, s2는 블록을 벗어나면서 drop됩니다. 따라서 result의 라이프타임이 s2보다 길어질 수 없다고 판단하고 에러를 냅니다.


변경전: 라이프타임을 무시했을 때

함수가 참조를 반환할 때, "그 참조가 어떤 데이터를 가리키는지"를 컴파일러가 추론하지 못하면 에러를 요구합니다. 이때 라이프타임을 명시하지 않으면 컴파일이 되지 않습니다.

// 변경전: 두 참조 중 하나를 반환할 때 라이프타임을 명시하지 않음 — 컴파일 에러
fn first_of_two(a: &str, b: &str) -> &str {
    if a.len() > b.len() { a } else { b }
}
// 에러: 반환 참조의 라이프타임이 a인지 b인지 불명확. 컴파일러가 'a를 추론할 수 없음.

컴파일러 메시지(요지): 반환되는 &strab 중 어느 쪽을 참조하는지 알 수 없으므로, 반환 타입에 라이프타임을 명시하라고 요구합니다.


변경후: 라이프타임 파라미터로 관계 명시

반환 참조가 두 입력 참조 중 더 짧은 쪽만큼만 유효하다고 'a로 통일해 주면, 컴파일러가 수명 관계를 검사할 수 있습니다.

// 변경후: 반환 참조는 a, b 중 더 짧은 수명만큼만 유효
fn first_of_two<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let r = first_of_two(&s1, &s2);
    println!("{}", r);
}

Output:

world

요점: 'a는 "반환 참조가 ab 중 더 짧은 라이프타임을 가진 쪽보다 오래 살 수 없다"는 뜻입니다. 이렇게 명시하면 댕글링 포인터 가능성이 컴파일 타임에 제거됩니다.


Borrow Checker와 라이프타임이 함께 동작하는 방식

Borrow Checker는 빌림 규칙(불변/가변 동시 허용 여부)을 검사하고, 라이프타임은 참조의 유효 구간을 검사합니다. 둘 다 타입/제네릭 단계에서 처리되므로 런타임 오버헤드가 없습니다.

  • Borrow Checker: "이 시점에 v를 가변으로 빌리면, 아직 불변 빌림이 살아 있어서 안 된다" → 에러.
  • 라이프타임: "이 참조는 s2가 drop된 뒤에도 쓰일 수 있어서, s2보다 길게 사는 참조를 반환할 수 없다" → 에러.

실제 개발에서는 라이프타임 생략 규칙 때문에 많은 함수에서 'a를 직접 쓰지 않아도 되지만, 참조를 반환하거나 구조체가 참조를 들고 있을 때는 라이프타임 표기가 필요합니다.


맺음말

Borrow Checker는 빌림(참조)이 소유권 규칙을 지키는지 검사하고, 라이프타임은 참조가 가리키는 데이터보다 오래 살지 않도록 제약을 걸어 줍니다. 두 메커니즘이 결합되어 use-after-free, 댕글링 포인터, 데이터 레이스를 컴파일 타임에 막습니다. 초반에는 컴파일 에러가 부담스러울 수 있지만, 에러 메시지를 따라 수정하다 보면 "언제 빌리고, 언제까지 유효한지"에 대한 감이 생기고, 런타임 버그를 크게 줄일 수 있습니다.

반응형