러스트 언어 입문 시리즈 - 3편: 소유권(Ownership)과 빌림(Borrowing), 라이프타임(Lifetime)의 이해

이전 글에서는 Cargo를 사용해 프로젝트를 만들고, 변수 선언, 함수, 제어문을 통해 러스트의 기초적인 문법을 맛보았습니다. 이제 러스트의 핵심 개념인 소유권(Ownership), 빌림(Borrowing), 그리고 라이프타임(Lifetime)으로 넘어갈 차례입니다. 이 개념들은 러스트를 다른 언어와 차별화하는 중요한 기둥이며, C++와 가장 큰 철학적 차이를 보여주는 부분이기도 합니다.

 

C++에서 포인터나 참조를 사용할 때 언제 메모리를 할당하고 해제할지, 어디서 유효하며 언제 소멸하는지에 대한 관리가 까다롭습니다. 러스트는 이러한 문제를 언어 차원에서 다루어 메모리 안전성을 확보하고, 개발자가 의도하지 않은 메모리 오류를 최소화하도록 돕습니다.

소유권(Ownership) 기초 이해하기

러스트에서는 모든 값이 단 하나의 소유자(Owner)를 가집니다. 이 소유자가 범위를 벗어나는 순간(스코프를 빠져나갈 때) 해당 값은 메모리에서 해제됩니다. 이 과정을 통해 new, delete 같은 명령적 메모리 관리나 shared_ptr와 같은 복잡한 도구 없이도 안전한 메모리 관리가 가능합니다.

아주 간단한 예를 들어봅시다.

fn main() {
    let s = String::from("Hello");
    // s는 여기서 String의 소유자입니다.
    // s의 값은 힙(heap)에 할당됩니다.

    println!("{}", s);

    // main 함수가 끝나면 s의 스코프가 종료되고,
    // 힙에 할당된 문자열 메모리는 자동으로 해제됩니다.
}

 

C++에서도 std::string이 스코프를 벗어날 때 소멸자가 호출되며 메모리를 정리하지만, 러스트는 이 원칙을 모든 타입에 일관적으로 적용합니다. 기본 원칙은 "소유자는 범위를 벗어날 때 메모리를 해제한다"라는 것입니다.

이동(Move)와 복제(Clone)

C++11 이후 이동 시멘틱(Move semantics)을 도입했듯, 러스트도 값의 이동 개념이 있습니다. 하지만 러스트에서는 이 이동이 기본 동작으로 강제됩니다. 예를 들어 다음 코드를 보겠습니다.

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1; // s1의 소유권이 s2로 이동(Move)
    // println!("{}", s1); // 에러! s1은 더 이상 유효하지 않습니다.
    println!("{}", s2);
}

 

let s2 = s1;이 일어나는 순간 s1에서 s2로 힙 데이터의 소유권이 이전되며, s1은 더 이상 유효하지 않은 상태가 됩니다. C++에서 std::unique_ptr를 하나의 변수에서 다른 변수로 이동하면 원래 포인터가 무효화되는 것과 유사한 느낌입니다.

만약 데이터를 단순히 복제(clone)하고 싶다면 clone() 메서드를 사용해야 합니다.

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1.clone(); // 힙 데이터를 새로 복사하여 s2에 할당
    println!("s1: {}, s2: {}", s1, s2);
}

clone()은 C++의 std::string s2 = s1;와 비슷한 개념이며, 별도의 복제 비용이 듭니다. 따라서 필요할 때만 명시적으로 복제를 요청하도록 유도합니다.

빌림(Borrowing)과 불변 참조(Immutable Reference)

소유권을 완전히 넘기는 대신, 잠시 빌릴 수도 있습니다. C++ 참조(&) 개념과 비슷하지만, 러스트에서는 빌림 시에도 엄격한 규칙이 적용됩니다.

fn main() {
    let s = String::from("Rust");
    print_str(&s);
    // 여기서 &s는 s를 빌려서 함수 print_str에 전달합니다.
    // 이로써 s의 소유권은 여전히 main에 남아있습니다.
    println!("다시 main에서 s: {}", s);
}

fn print_str(s_ref: &String) {
    println!("빌려온 문자열: {}", s_ref);
}

 

&s는 s를 빌려온 불변 참조입니다. 이 상태에서는 s_ref를 통해 읽기만 가능하며, 값의 변경은 불가능합니다. 중요한 점은 빌림하는 동안에도 원본 s의 소유권은 main에 남아 있고, 빌려간 함수가 끝나면 참조는 사라지고 원본 s는 계속 유효하다는 것입니다.

C++ 참조와 달리 러스트는 빌림에 대해 매우 엄격합니다. 컴파일 타임에 빌림 규칙을 검사하여 사용이 잘못된 경우 컴파일 에러를 낸다는 점에서 안전성이 한층 강화됩니다.

가변 빌림(Mutable Borrow)

만약 빌린 값을 변경해야 한다면 가변 빌림(mutable borrow)을 사용합니다. 하지만 가변 빌림은 한 번에 하나만 허용되는 강력한 제약이 있습니다.

fn main() {
    let mut s = String::from("Hello");
    change_str(&mut s);
    println!("변경 후: {}", s);
}

fn change_str(s_ref: &mut String) {
    s_ref.push_str(", world!");
}

 

&mut s는 s를 가변으로 빌리는 참조입니다. 이 경우 s를 소유한 스코프에서는 동시에 다른 불변 참조나 가변 참조를 만들 수 없게 됩니다. 이는 데이터 경합(Race condition)을 원천적으로 차단하기 위한 장치입니다. C++에서 멀티스레드 환경을 생각하면, 이는 굉장히 유용한 개념입니다. 러스트는 이러한 규칙 덕분에 락 없이도 스레드 안전성을 극적으로 강화할 수 있습니다.

라이프타임(Lifetime)

C++에서는 참조가 유효한지, 객체가 파괴되었는데 참조는 남아있지 않은지 등에 대한 문제가 자주 발생합니다. 러스트는 이 문제를 컴파일 타임에 해결하기 위해 라이프타임(Lifetime) 개념을 도입했습니다.

라이프타임은 참조가 유효한 범위를 명시적으로 추론하는 개념입니다. 대부분의 경우 러스트가 알아서 라이프타임을 추론해주지만, 복잡한 함수 시그니처나 구조체를 다룰 때 명시적으로 라이프타임 파라미터를 표기해야 할 수도 있습니다.

fn main() {
    let s1 = String::from("Hello");
    let result;
    {
        let s2 = String::from("Rust");
        result = longest(&s1, &s2);
        // s2는 여기서 범위를 벗어나 사라집니다.
    }
    // result에 담긴 참조는 과연 유효할까요?
    println!("Result: {}", result); // 컴파일러가 이 상황을 방지해줄 겁니다.
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // 라이프타임 파라미터 'a를 사용해,
    // 반환되는 참조의 유효기간을 입력 참조들의 유효기간에 종속시킵니다.
    if x.len() > y.len() { x } else { y }
}

 

위 예제에서 longest 함수는 두 문자열 참조 중 더 긴 문자열을 반환합니다. C++에서는 둘 중 하나가 먼저 스코프를 떠나버리는 참조를 반환한다면 추후 런타임 오류를 초래할 수 있지만, 러스트는 라이프타임 파라미터를 통해 두 참조의 생존 범위를 명시적으로 연결하여 컴파일 시점에 잘못된 참조 반환을 막습니다.

C++와의 비교 요약

  • 소유권과 이동: C++에서의 move semantics 개념을 러스트는 더 엄격하고 일관되게 적용합니다. 러스트에선 기본 할당도 move로 취급하며 복제할 때는 명시적으로 clone()을 사용.
  • 불변/가변 빌림 참조: C++ 참조나 포인터보다 더 강력한 불변/가변 제약을 두어, 데이터 경합 없이 안전한 참조 사용을 보장.
  • 라이프타임: C++에서 자주 문제가 되는 댕글링 포인터(dangling pointer) 문제를 컴파일 타임에 원천 방지.

이러한 특징 덕분에 러스트에서는 메모리 관리 문제를 초반부터 확실히 잡아내어 안정성이 높은 코드를 작성할 수 있습니다.

앞으로의 학습 방향

소유권, 빌림, 라이프타임 개념은 러스트의 철학을 반영하는 가장 중요한 주제입니다. 이해가 살짝 어렵더라도 꾸준히 예제를 따라가다 보면 자연스럽게 손에 익을 것입니다. 다음 글에서는 이 개념들을 활용한 실습 예제를 다루고, 컬렉션과 슬라이스(Slice)와 같은 자료 구조를 활용하는 방법, 그리고 함수형 스타일의 이터레이터(Iterator)에 대해서도 살펴보겠습니다.

유용한 링크와 리소스

반응형