Rust 소유권·빌림·라이프타임: 댕글링 포인터와 데이터 레이스를 컴파일 타임에 막는 원리, C++와 비교

이 글의 핵심

C++에서는 런타임에 터지던 댕글링 포인터와 데이터 레이스가 Rust에서는 컴파일 에러로 바뀝니다. 소유권 이동과 빌림 규칙, 구조체·정적 라이프타임을 벡터·문자열 슬라이스·클로저 예제로 확인하고, Rc<RefCell<T>>와 Arc<Mutex<T>>를 언제 쓰는지, use of moved value 같은 에러를 어떻게 푸는지 정리합니다.

들어가며

Rust의 소유권 시스템은 가비지 컬렉터 없이 메모리 안전성을 보장하는 핵심 기능입니다. 소유권(Ownership), 빌림(Borrowing), 라이프타임(Lifetime) 세 가지 개념으로 구성됩니다. 비유로 말씀드리면, 소유권은 책의 주인이고, 빌림은 책을 빌려주는 것이며, 라이프타임은 빌린 기간입니다. 한 번에 한 명만 책을 소유하며, 빌려준 동안은 주인이 책을 버릴 수 없으며, 빌린 사람이 돌려주기 전에 주인이 사라지면 안 됩니다.

이 세 규칙이 막으려는 버그는 구체적입니다. 해제된 메모리를 가리키는 댕글링 포인터(use-after-free), 같은 메모리를 두 번 해제하는 이중 해제(double free), 그리고 여러 스레드가 동기화 없이 같은 데이터를 쓰는 데이터 레이스입니다. C와 C++에서는 이런 버그가 테스트를 통과한 뒤 운영 환경에서 가끔 크래시나 보안 취약점으로 드러나는 경우가 많습니다. Rust는 “누가 이 메모리를 해제할 책임이 있는가”와 “지금 이 데이터를 누가 수정할 수 있는가”를 타입 시스템에 새겨 넣어, 이 질문의 답이 불분명한 코드를 컴파일하지 않습니다.


소유권 기초

소유권 규칙

  1. 각 값은 하나의 소유자(owner)를 가집니다
  2. 한 번에 하나의 소유자만 존재합니다
  3. 소유자가 스코프를 벗어나면 값이 해제됩니다

소유권 이동 (Move)

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1의 소유권이 s2로 이동
    
    // println!("{}", s1);  // 컴파일 에러! s1은 더 이상 유효하지 않음
    println!("{}", s2);     // OK
}

C++와 비교:

// C++11 std::move
std::string s1 = "hello";
std::string s2 = std::move(s1);
std::cout << s1 << std::endl;  // 컴파일·실행 모두 됨, 값은 "유효하지만 미지정" (보통 빈 문자열)
std::cout << s2 << std::endl;  // OK

차이점:

  • Rust: 컴파일 타임에 이동 후 사용을 방지
  • C++: 이동 후 사용을 막지 않음. 표준 라이브러리 타입은 “유효하지만 미지정” 상태가 되고, unique_ptr처럼 nullptr이 되는 타입을 역참조하면 정의되지 않은 동작

C++의 std::move는 이름과 달리 아무것도 옮기지 않고 rvalue로 캐스팅할 뿐이며, 이동 이후의 원본 객체는 여전히 살아 있는 변수입니다. 그래서 C++에서는 이동 후 사용이 논리 버그(빈 문자열을 처리함)나 미정의 동작(null 역참조) 중 하나가 되지만 어느 쪽이든 컴파일러는 알려주지 않습니다. Rust에서 let s2 = s1;은 스택에 있는 포인터·길이·용량 세 값을 복사하고 s1을 컴파일러의 추적 대상에서 지웁니다. 이후 s1을 쓰면 error[E0382]: borrow of moved value: s1이 나고, 에러 메시지에는 어디서 이동됐는지와 “String이 Copy를 구현하지 않기 때문”이라는 이유까지 함께 표시됩니다. 스코프 끝에서도 s1에 대해서는 drop이 호출되지 않으므로 이중 해제가 원천적으로 불가능합니다.

복사 (Copy)

Copy 트레이트를 구현한 타입은 이동 대신 복사됩니다:

fn main() {
    let x = 5;
    let y = x;  // 복사 (i32는 Copy 트레이트 구현)
    
    println!("x: {}, y: {}", x, y);  // 둘 다 사용 가능
}

Copy 트레이트를 구현한 타입:

  • 모든 정수형 (i32, u64 등)
  • 불리언 (bool)
  • 부동소수점 (f32, f64)
  • 문자 (char)
  • 튜플 (모든 요소가 Copy일 때) Copy 트레이트를 구현하지 않은 타입:
  • String
  • Vec<T>
  • Box<T>
  • 구조체 (기본값)

기준은 “비트 단위 복사만으로 완전한 복제가 되는가”입니다. i32는 스택의 4바이트를 복사하면 끝이지만, String을 비트 복사하면 두 변수가 같은 힙 버퍼를 가리키게 되고 둘 다 스코프를 벗어날 때 같은 메모리를 두 번 해제하게 됩니다. 그래서 힙 자원을 소유하거나 Drop을 구현한 타입은 Copy가 될 수 없고, 명시적으로 .clone()을 호출해야 깊은 복사가 일어납니다. 직접 만든 구조체도 모든 필드가 Copy라면 #[derive(Clone, Copy)]로 복사 타입이 될 수 있습니다. 좌표나 색상처럼 작은 값 타입에는 유용하지만, 나중에 String 필드를 추가하면 Copy를 떼야 하고 그 타입을 쓰던 코드 곳곳에서 이동 에러가 나므로 공개 API 타입에는 신중하게 붙이는 편이 좋습니다.

함수와 소유권

함수 인자로 값을 넘기는 것도 let s2 = s1;과 같은 이동입니다. String을 받는 함수에 넘기면 호출한 쪽 변수는 더 이상 쓸 수 없고, 함수가 끝날 때 매개변수가 drop됩니다. 반대로 함수가 값을 반환하면 소유권이 호출자에게 넘어옵니다. i32 같은 Copy 타입은 복사되므로 넘긴 뒤에도 그대로 쓸 수 있습니다. “넘겼다가 돌려받는” 코드를 매번 쓰는 대신 아래의 참조(빌림)를 쓰는 것이 보통이며, 함수 호출·반환 시 소유권이 움직이는 기본 예제와 슬라이스는 Rust 소유권 시리즈 2편에서 단계별로 다룹니다. 이 글은 그 규칙이 C++의 어떤 버그를 막는지, 그리고 실제로 부딪히는 컴파일 에러를 어떻게 푸는지에 초점을 둡니다.


빌림 규칙

참조 (References)

소유권을 이동하지 않고 값을 사용하려면 참조를 사용합니다:

fn main() {
    let s1 = String::from("hello");
    
    let len = calculate_length(&s1);  // 참조 전달
    
    println!("'{}' 길이: {}", s1, len);  // s1 여전히 사용 가능
}
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s는 참조이므로 drop되지 않음

실무에서는 매개변수를 &String보다 &str로 받는 것이 관례입니다. &String은 String에서만 만들 수 있지만, &str은 String, 문자열 리터럴, 다른 문자열의 슬라이스를 모두 받을 수 있습니다(&String은 자동 역참조로 &str이 됩니다). Clippy도 &String 매개변수에 ptr_arg 경고를 냅니다. Vec<T>도 마찬가지로 읽기만 한다면 &[T]로 받는 것이 더 유연합니다.

빌림 규칙

  1. 불변 참조는 여러 개 가능
  2. 가변 참조는 하나만 가능
  3. 불변 참조와 가변 참조는 동시에 존재할 수 없음

불변 참조 (Immutable References)

fn main() {
    let s = String::from("hello");
    
    let r1 = &s;  // OK
    let r2 = &s;  // OK
    let r3 = &s;  // OK
    
    println!("{}, {}, {}", r1, r2, r3);
}

가변 참조 (Mutable References)

fn main() {
    let mut s = String::from("hello");
    
    change(&mut s);
    
    println!("{}", s);  // "hello, world"
}
fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

가변 참조 제한

한 번에 하나의 가변 참조만 가능:

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &mut s;
    // let r2 = &mut s;  // 컴파일 에러! 이미 가변 참조 존재
    
    println!("{}", r1);
}

이유: 데이터 레이스 방지

// 데이터 레이스 예시 (Rust에서는 불가능)
fn main() {
    let mut s = String::from("hello");
    
    let r1 = &mut s;
    let r2 = &mut s;  // 컴파일 에러!
    
    r1.push_str(" world");
    r2.push_str("!");  // 동시 수정 불가
}

위 예시는 단일 스레드라 엄밀한 의미의 데이터 레이스는 아니지만, 규칙이 막는 문제는 스레드가 없어도 생깁니다. push_str은 용량이 부족하면 버퍼를 재할당하므로, r1이 재할당을 일으킨 순간 r2가 알고 있던 버퍼 주소는 무효가 됩니다. C++에서 벡터를 순회하면서 push_back하다가 반복자가 무효화되는 버그와 같은 구조입니다. Rust는 “공유(aliasing)와 변경(mutation)을 동시에 허용하지 않는다”는 하나의 규칙으로 반복자 무효화와 데이터 레이스를 함께 막습니다. 멀티스레드 상황에서는 Send/Sync 트레이트가 이 규칙을 스레드 경계까지 확장합니다.

불변·가변 참조 혼용 불가

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;      // OK
    let r2 = &s;      // OK
    // let r3 = &mut s;  // 컴파일 에러! 불변 참조 존재
    
    println!("{}, {}", r1, r2);
}

참조 스코프 (Non-Lexical Lifetimes)

Rust 2018부터 참조 스코프가 더 유연해졌습니다:

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2);
    // r1, r2는 여기서 더 이상 사용되지 않음
    
    let r3 = &mut s;  // OK! r1, r2의 스코프가 끝남
    println!("{}", r3);
}

NLL(Non-Lexical Lifetimes) 이전에는 참조의 수명이 중괄호 블록 끝까지였기 때문에 위 코드도 에러였고, 사람들은 억지로 블록을 추가해 참조 범위를 좁혔습니다. NLL 이후에는 참조가 마지막으로 사용된 지점까지만 살아 있는 것으로 계산됩니다. 그래서 “빌림 에러가 났는데 코드 순서만 바꿨더니 해결됐다”는 경험을 자주 하게 됩니다. 에러 메시지의 세 번째 표시(immutable borrow later used here)가 가리키는 줄이 바로 참조를 오래 살려 두는 원인이므로, 그 사용을 가변 빌림보다 앞으로 옮기거나 값을 복사해 두면 대부분 해결됩니다.

댕글링 참조 방지

Rust는 댕글링 참조를 컴파일 타임에 방지합니다:

fn main() {
    // let reference_to_nothing = dangle();  // 컴파일 에러!
    let valid_string = no_dangle();
    println!("{}", valid_string);
}
// fn dangle() -> &String {  // 컴파일 에러!
//     let s = String::from("hello");
//     &s  // s가 스코프를 벗어나면서 해제됨
// }
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // 소유권 이동
}

C++와 비교:

// C++ 댕글링 포인터 (런타임 에러)
std::string* dangle() {
    std::string s = "hello";
    return &s;  // 경고(address of local variable returned)만 나고 컴파일됨, 호출 측 사용은 미정의 동작
}

C++ 컴파일러는 이 코드에 경고를 내지만 빌드를 막지는 않습니다. 더 까다로운 것은 이 포인터를 역참조해도 대개 한동안은 정상처럼 보인다는 점입니다. 해제된 스택 공간이 다른 함수 호출로 덮어써지기 전까지는 원래 값이 남아 있기 때문에, 디버그 빌드에서는 멀쩡하다가 최적화 빌드나 다른 호출 순서에서만 값이 깨집니다. Rust는 같은 코드를 error[E0106]: missing lifetime specifier로 거부하고, 반환할 참조가 어떤 입력에서 빌려 온 것인지 설명할 수 없다는 점을 알려 줍니다.


라이프타임

라이프타임이란?

참조가 유효한 스코프를 명시하는 제네릭 파라미터입니다.

라이프타임 어노테이션

fn main() {
    let string1 = String::from("long string");
    let string2 = String::from("short");
    
    let result = longest(&string1, &string2);
    println!("가장 긴 문자열: {}", result);
}
// 라이프타임 어노테이션 필요
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

의미:

  • 'a: 라이프타임 파라미터
  • x, y, 반환값 모두 같은 라이프타임 'a를 가짐
  • 반환값은 x와 y 중 짧은 라이프타임을 가짐

라이프타임 어노테이션은 참조를 더 오래 살게 만드는 기능이 아닙니다. 이미 존재하는 관계, 즉 “반환값은 x나 y 중 하나에서 빌려 온 것”을 컴파일러에게 알려 주는 계약일 뿐입니다. 함수 시그니처만 보고 호출 측을 검사할 수 있도록 하는 것이 목적이라, 컴파일러는 함수 본문을 들여다보지 않고 이 계약만으로 let result; { let s2 = String::from("x"); result = longest(&s1, &s2); } println!("{}", result);같은 코드를 “s2 does not live long enough”로 거부합니다. 처음 라이프타임을 배울 때 흔히 'a를 붙이면 문제가 풀릴 거라 기대하지만, 에러가 난다면 대부분 설계상 정말로 참조가 원본보다 오래 살아야 하는 상황이고, 이때는 어노테이션이 아니라 소유권을 넘기는(String 반환) 쪽으로 구조를 바꿔야 합니다.

라이프타임 규칙

컴파일러가 자동으로 추론하는 규칙:

  1. 각 참조 파라미터는 고유한 라이프타임을 가짐
  2. 참조 파라미터가 하나면 반환값도 같은 라이프타임
  3. 메서드에서 &self 또는 &mut self가 있으면 반환값도 같은 라이프타임

구조체 라이프타임

struct ImportantExcerpt<'a> {
    part: &'a str,
}
fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    
    let i = ImportantExcerpt {
        part: first_sentence,
    };
    
    println!("{}", i.part);
}

정적 라이프타임

'static 라이프타임은 프로그램 전체 기간 동안 유효합니다:

fn main() {
    let s: &'static str = "I have a static lifetime.";
    println!("{}", s);
}

문자열 리터럴은 모두 'static 라이프타임을 가집니다.

에러 메시지의 도움말이 “consider using the 'static lifetime”을 제안하는 경우가 있는데, 대부분은 따라가면 안 되는 제안입니다. 함수가 지역에서 만든 String의 참조를 'static으로 반환할 방법은 없고(Box::leak으로 메모리를 일부러 누수시키는 방법만 있습니다), 결국 소유한 값을 반환하도록 바꾸는 것이 정답입니다. 또 제네릭 바운드의 T: 'static은 “영원히 사는 참조”가 아니라 “빌린 참조를 포함하지 않는 타입”이라는 뜻이라, String이나 Vec<i32> 같은 소유 타입은 모두 이 조건을 만족합니다. thread::spawn이 클로저에 'static을 요구하는 이유도 스레드가 호출자보다 오래 살 수 있어 지역 변수의 참조를 들고 가면 안 되기 때문입니다.


실전 구현

벡터 소유권

fn main() {
    let v = vec![1, 2, 3, 4, 5];
    
    let first = &v[0];  // 불변 참조
    
    // v.push(6);  // 컴파일 에러! 불변 참조 존재
    
    println!("첫 번째 요소: {}", first);
    
    // first 스코프 끝
    
    let mut v2 = v;  // 소유권 이동
    v2.push(6);      // OK
    println!("{:?}", v2);
}

문자열 슬라이스

fn main() {
    let s = String::from("hello world");
    
    let hello = &s[0..5];   // 불변 참조 슬라이스
    let world = &s[6..11];
    
    println!("{} {}", hello, world);
    
    let first_word = first_word(&s);
    println!("첫 단어: {}", first_word);
}
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    
    &s[..]
}

구조체 소유권

#[derive(Debug)]
struct User {
    username: String,
    email: String,
    active: bool,
}
fn main() {
    let user1 = User {
        username: String::from("user1"),
        email: String::from("[email protected]"),
        active: true,
    };
    
    // 부분 이동
    let user2 = User {
        username: user1.username,  // 이동
        email: String::from("[email protected]"),
        active: user1.active,      // 복사 (bool은 Copy)
    };
    
    // println!("{}", user1.username);  // 컴파일 에러!
    println!("{}", user1.active);       // OK (Copy)
    println!("{:?}", user2);
}

클로저와 소유권

fn main() {
    let x = vec![1, 2, 3];
    
    // move 키워드로 소유권 이동
    let equal_to_x = move |z| z == x;
    
    // println!("{:?}", x);  // 컴파일 에러! x가 이동됨
    
    let y = vec![1, 2, 3];
    assert!(equal_to_x(y));
}

C++와 비교

소유권 vs 스마트 포인터

개념RustC++
단독 소유T (기본)std::unique_ptr<T>
공유 소유Rc<T> / Arc<T>std::shared_ptr<T>
참조&T / &mut TT& / const T&
검사 시점컴파일 타임런타임 (일부)

Rust 소유권

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // 이동
    
    // println!("{}", s1);  // 컴파일 에러!
}

C++ unique_ptr

#include <memory>
#include <iostream>
int main() {
    auto s1 = std::make_unique<std::string>("hello");
    auto s2 = std::move(s1);  // 이동
    
    // std::cout << *s1 << std::endl;  // 런타임 에러 (정의되지 않은 동작)
    std::cout << *s2 << std::endl;     // OK
}

Rust 참조

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;
    let r2 = &s;
    // let r3 = &mut s;  // 컴파일 에러!
    
    println!("{}, {}", r1, r2);
}

C++ 참조

#include <string>
#include <iostream>
// 변수 선언 및 초기화
int main() {
    std::string s = "hello";
    
    const std::string& r1 = s;
    const std::string& r2 = s;
    std::string& r3 = s;  // OK (컴파일 타임·런타임 모두 검사 없음)
    
    std::cout << r1 << ", " << r2 << ", " << r3 << std::endl;
}

차이점:

  • Rust: 컴파일 타임에 불변·가변 참조 규칙 강제
  • C++: const 참조와 비const 참조가 같은 객체를 동시에 가리킬 수 있음. const T&는 “이 경로로는 수정하지 않는다”는 약속일 뿐 “아무도 수정하지 않는다”는 보장이 아니므로, 다른 경로의 수정이 r1을 통해 보이는 값을 바꾸고, 여러 스레드에 걸치면 데이터 레이스가 됨

이 차이 때문에 C++ 경험자가 Rust에서 가장 당황하는 지점이 “C++에서는 문제없던 코드”가 거부되는 순간입니다. 대부분은 실제로 버그가 아니지만, 컴파일러가 안전함을 증명할 수 없는 코드입니다. 그래프 구조, 부모를 가리키는 자식 노드, 서로를 참조하는 객체처럼 소유 관계가 트리가 아닌 설계는 Rust에서 그대로 옮기기 어렵고, 인덱스(Vec<Node> + usize ID)나 아래의 Rc/Weak 조합으로 다시 설계해야 합니다.


고급 패턴

내부 가변성 (Interior Mutability)

RefCell<T>로 런타임 빌림 검사:

use std::cell::RefCell;
fn main() {
    let data = RefCell::new(5);
    
    {
        let mut value = data.borrow_mut();
        *value += 1;
    }  // 가변 참조 스코프 끝
    
    println!("{}", data.borrow());  // 6
}

RefCell은 빌림 규칙을 없애는 것이 아니라 검사 시점을 런타임으로 미루는 도구입니다. 같은 규칙을 어기면 컴파일 에러 대신 already borrowed: BorrowMutError 같은 panic이 납니다. 위 예제에서 중괄호 블록을 없애 borrow_mut()의 가드가 살아 있는 상태로 data.borrow()를 호출하면 바로 이 panic이 발생합니다. 저는 콜백 안에서 이미 borrow_mut() 중인 RefCell을 다시 빌리는 코드를 짰다가 특정 이벤트 순서에서만 panic이 나는 문제를 겪었는데, 컴파일 타임 검사의 장점을 포기한 대가가 이런 형태로 돌아옵니다. RefCell은 꼭 필요한 곳에만 쓰고, 가드(Ref/RefMut)를 오래 들고 있지 않는 것이 요령입니다. 값이 Copy 타입이라면 가드 없이 값을 통째로 넣고 빼는 Cell이 더 단순하고 panic 위험도 없습니다.

Rc와 RefCell 조합

use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });
    
    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
    
    println!("branch: {:?}", branch);
}

Rc는 참조 카운트가 0이 될 때 값을 해제하므로, 자식이 부모를 Rc로 다시 가리키면 서로의 카운트가 영원히 0이 되지 않는 순환 참조 누수가 생깁니다. Rust의 메모리 안전성 보장은 누수까지 막지는 않습니다(누수는 “안전한” 동작으로 분류됩니다). 부모 방향 참조는 카운트를 올리지 않는 Weak<T>로 두고, 필요할 때 upgrade()로 Option<Rc<T>>를 얻는 것이 표준 패턴입니다.

Arc와 Mutex (멀티스레드)

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Result: {}", *counter.lock().unwrap());  // 10
}

여기서 Arc::clone(&counter)를 루프 안에서 매번 호출하는 이유는 move 클로저가 캡처한 변수의 소유권을 가져가기 때문입니다. 바깥 counter를 직접 캡처하면 첫 번째 반복에서 이동되어 두 번째 반복부터 use of moved value 에러가 납니다. Arc를 빼고 Mutex만 넘기려 하면 “borrowed value does not live long enough”나 'static 요구 에러가 나는데, 스레드가 main보다 오래 살 수도 있다고 컴파일러가 가정하기 때문입니다(스레드가 반드시 먼저 끝나는 경우에는 std::thread::scope로 Arc 없이 빌려줄 수 있습니다). lock().unwrap()은 다른 스레드가 락을 쥔 채 panic하면 Mutex가 오염(poisoned) 되어 Err를 반환하므로, 한 스레드의 panic이 연쇄적으로 전파된다는 점도 알아 두면 좋습니다.


트러블슈팅

cannot borrow as mutable

문제:

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;
    let r2 = &mut s;  // 에러!
    
    println!("{}, {}", r1, r2);
}

해결:

fn main() {
    let mut s = String::from("hello");
    
    {
        let r1 = &s;
        println!("{}", r1);
    }  // r1 스코프 끝
    
    let r2 = &mut s;  // OK
    println!("{}", r2);
}

use of moved value

문제:

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    
    println!("{}", s);  // 에러!
}
fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}

해결 1: 참조 사용

// 함수 정의 및 구현
fn main() {
    let s = String::from("hello");
    uses_reference(&s);
    
    println!("{}", s);  // OK
}
fn uses_reference(some_string: &String) {
    println!("{}", some_string);
}

해결 2: clone 사용

fn main() {
    let s = String::from("hello");
    takes_ownership(s.clone());
    
    println!("{}", s);  // OK
}
fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}

missing lifetime specifier (E0106)

문제:

fn longest(x: &str, y: &str) -> &str {  // 에러!
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

해결:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

cannot return reference to local variable

문제:

fn dangle() -> &String {  // 에러!
    let s = String::from("hello");
    &s
}

해결:

fn no_dangle() -> String {
    let s = String::from("hello");
    s  // 소유권 이동
}

마무리

Rust의 소유권 시스템은 메모리 안전성을 컴파일 타임에 보장하는 강력한 기능입니다.

핵심 원칙:

  1. 각 값은 하나의 소유자만 가짐
  2. 불변 참조는 여러 개, 가변 참조는 하나만
  3. 참조는 항상 유효해야 함 (라이프타임) 장점:
  • 댕글링 포인터 방지
  • 이중 해제 방지
  • 데이터 레이스 방지
  • 런타임 오버헤드 없음 단점:
  • 학습 곡선이 가파름
  • 빌림 검사기와 싸워야 함
  • 일부 패턴 구현이 복잡함 처음에는 빌림 검사기가 답답하게 느껴질 수 있지만, 익숙해지면 댕글링 참조·이중 해제·데이터 레이스라는 버그 부류 전체를 코드 리뷰와 테스트가 아닌 컴파일러에게 맡길 수 있습니다. 논리 버그나 데드락, 메모리 누수까지 막아 주지는 않는다는 점도 함께 기억해 두세요. C++에서 Rust로 전환하는 개발자라면 Rust 시리즈 인트로와 함께 보시면 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. Rc<RefCell>와 Arc<Mutex>는 언제 각각 쓰나요?

A. 둘 다 여러 소유자가 같은 값을 공유하면서 수정해야 할 때 쓰지만, Rc와 RefCell은 단일 스레드 전용이고 빌림 규칙 위반을 런타임 panic으로 검사합니다. 스레드 사이에서 공유해야 한다면 원자적 참조 카운트인 Arc와 락으로 접근을 직렬화하는 Mutex를 조합해야 하며, Rc는 Send가 아니라서 thread::spawn에 넘기면 컴파일 에러가 납니다. 단일 스레드에서 굳이 Arc와 Mutex를 쓰면 원자 연산과 락 비용만 늘어납니다.


같이 보면 좋은 글