Rust 소유권 | Ownership, Borrowing, Lifetime

Rust 소유권 | Ownership, Borrowing, Lifetime

이 글의 핵심

Rust 소유권에 대한 실전 가이드입니다. Ownership, Borrowing, Lifetime 등을 예제와 함께 상세히 설명합니다.

들어가며

소유권(Ownership)은 Rust의 핵심으로, 가비지 컬렉터(실행 중에 쓸모없는 메모리를 자동으로 회수하는 런타임 기능) 없이 메모리 안전을 잡는 규칙입니다.

비유: 힙에 있는 값은 열쇠가 하나뿐이고, 그 열쇠를 가진 변수만 문을 열 수 있습니다. 빌림(Borrowing)은 열쇠를 넘기지 않고 잠깐 대여해 보는 행위로, &T·&mut T 참조로 표현됩니다.

C++에서는 스마트 포인터unique_ptr(단일 소유)·shared_ptr(참조 카운팅)을 쓰고, RAII이동 의미론으로 자원·소유권을 표현합니다. GC가 있는 언어는 런타임에 도달 가능성으로 회수하는 반면, Rust는 규칙을 컴파일 타임에 검사합니다. C++ 쪽 누수·도구는 메모리 누수 가이드, Valgrind, 누수 탐지 실전을 참고하세요.


1. 소유권 규칙

규칙 1: 각 값은 소유자가 있다

fn main() {
    let s = String::from("hello");
    // s가 "hello" 문자열의 소유자
}

이 코드의 의미: String::from으로 힙에 할당된 문자열이 만들어지면, 그 시점에서 s가 그 메모리의 유일한 소유자입니다. 스코프를 벗어나면 Rust가 drop을 호출해 메모리를 정리하므로, C++처럼 수동 free가 필요 없습니다.

규칙 2: 소유자는 하나만

Rust의 핵심 규칙으로, 각 값은 정확히 하나의 소유자만 가질 수 있습니다:

fn main() {
    // s1이 "hello" 문자열의 소유자
    let s1 = String::from("hello");
    
    // 소유권 이동 (move)
    // s1의 소유권이 s2로 완전히 이동
    // s1은 더 이상 유효하지 않음 (무효화됨)
    let s2 = s1;
    
    // println!("{}", s1);  // 컴파일 에러!
    // "value borrowed here after move"
    // s1은 이미 소유권을 잃어서 사용 불가
    
    println!("{}", s2);  // ✅ OK - s2가 소유권을 가짐
}
// s2가 스코프를 벗어나면 메모리 자동 해제

왜 이동(move)할까?

C++의 문제:

// C++: 얕은 복사 → 이중 해제 위험
std::string s1 = "hello";
std::string s2 = s1;  // 둘 다 같은 메모리를 가리킴
// 소멸자가 두 번 호출되어 이중 해제 (double free) 발생 가능

비교 관점: C++에서 값 의미론(value semantics)을 엄격히 지키지 않으면, 복사 생성자·이동 생성자·소멸자 규칙을 한 번이라도 놓치기 쉽습니다. Rust는 기본 이동 + 명시적 clone()으로 “비싼 복사”를 눈에 띄게 만듭니다.

Rust의 해결:

  • 깊은 복사는 비용이 큼 (메모리 할당 + 데이터 복사)
  • Rust는 기본적으로 소유권 이동 (얕은 복사 + 원본 무효화)
  • 이중 해제 불가능 (소유자가 하나뿐)
  • 복사가 필요하면 명시적으로 clone() 사용
let s1 = String::from("hello");
let s2 = s1.clone();  // 깊은 복사 (명시적)

// 이제 s1과 s2는 각각 독립적인 메모리를 가짐
println!("{}, {}", s1, s2);  // 둘 다 OK

실무에서의 clone: CPU·메모리 비용이 큰 편이라, 뜨겁게 도는 루프 안에서 불필요한 clone을 반복하지 않도록 프로파일링하는 습관이 좋습니다. 반대로, 비용보다 API 단순성이 우선일 때는 clone으로 싸게 심리적 복잡도를 줄이기도 합니다.

Copy 트레이트:

// 정수, 불리언 등 작은 타입은 Copy 트레이트 구현
// 이들은 이동이 아닌 복사가 일어남
let x = 5;
let y = x;  // 복사 (이동 아님)
println!("{}, {}", x, y);  // 둘 다 OK

// 이유: 스택에 저장되고 크기가 작아서 복사 비용이 저렴

규칙 3: 스코프를 벗어나면 삭제

fn main() {
    {
        let s = String::from("hello");
        println!("{}", s);
    }  // s의 drop() 자동 호출
    
    // println!("{}", s);  // 에러! s는 이미 삭제됨
}

RAII와의 연결: 내부 블록에서만 필요한 리소스(파일, 잠금, 커넥션)를 같은 패턴으로 묶으면, 스코프를 벗어날 때 정리 로직이 자동으로 호출됩니다. 이는 Drop 트레이트와도 연결되는 Rust 스타일의 자원 관리입니다.


2. 함수와 소유권

소유권 이동

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    
    // println!("{}", s);  // 에러! 소유권 이동됨
}

fn takes_ownership(s: String) {
    println!("{}", s);
}  // s 삭제됨

함수 인자로 넘길 때: String을 그대로 넘기면 호출자는 그 값을 더 이상 쓸 수 없습니다. 필요하면 .clone()이나 참조(&String / &str)로 빌려오는 설계를 고릅니다. 팀 코드베이스에서는 “소비(consuming)” API와 “빌림” API를 이름만으로도 구분하는 컨벤션이 도움이 됩니다.

소유권 반환

fn main() {
    let s1 = gives_ownership();
    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2);
    
    println!("{}, {}", s1, s3);
}

fn gives_ownership() -> String {
    String::from("hello")
}

fn takes_and_gives_back(s: String) -> String {
    s
}

3. 참조와 빌림

불변 참조 (&)

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는 참조이므로 삭제 안됨

가변 참조 (&mut)

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

fn change(s: &mut String) {
    s.push_str(", world");
}

참조 규칙 (Borrowing Rules)

Rust의 참조 규칙은 데이터 레이스를 컴파일 타임에 방지합니다:

fn main() {
    let mut s = String::from("hello");
    
    // 규칙 1: 불변 참조(&)는 여러 개 동시에 가능
    let r1 = &s;
    let r2 = &s;
    // 둘 다 읽기만 하므로 안전
    // 동시에 여러 곳에서 읽는 것은 문제없음
    println!("{}, {}", r1, r2);  // ✅ OK
    
    // 규칙 2: 가변 참조(&mut)는 하나만 가능
    let r3 = &mut s;
    // let r4 = &mut s;  // ❌ 컴파일 에러!
    // "cannot borrow `s` as mutable more than once at a time"
    // 이유: 동시에 여러 곳에서 수정하면 데이터 레이스 발생
    println!("{}", r3);  // ✅ OK
    
    // 규칙 3: 불변 참조와 가변 참조는 동시에 불가
    let r5 = &s;
    // let r6 = &mut s;  // 에러!
    println!("{}", r5);
}

4. 슬라이스 (Slice)

슬라이스는 컬렉션의 일부를 참조하는 타입입니다:

fn main() {
    let s = String::from("hello world");
    // s: "hello world" (11글자)
    
    // 슬라이스: 문자열의 일부를 참조
    let hello = &s[0..5];
    // 인덱스 0부터 5 직전까지 (0, 1, 2, 3, 4)
    // hello: "hello" (타입: &str)
    
    let world = &s[6..11];
    // 인덱스 6부터 11 직전까지 (6, 7, 8, 9, 10)
    // world: "world" (타입: &str)
    
    println!("{}, {}", hello, world);  // hello, world
    
    // 슬라이스 범위 생략
    let hello2 = &s[..5];    // 처음부터 5 직전까지
    let world2 = &s[6..];    // 6부터 끝까지
    let full = &s[..];       // 전체 문자열
}

// 실전 예시: 첫 단어 찾기
fn first_word(s: &String) -> &str {
    // as_bytes(): 문자열을 바이트 배열로 변환
    let bytes = s.as_bytes();
    
    // iter(): 반복자 생성
    // enumerate(): (인덱스, 값) 튜플 반환
    for (i, &item) in bytes.iter().enumerate() {
        // b' ': 공백 문자의 바이트 값
        if item == b' ' {
            // 공백을 찾으면 처음부터 그 위치까지 슬라이스 반환
            return &s[0..i];
        }
    }
    
    // 공백이 없으면 전체 문자열 반환
    &s[..]
}

// 사용 예시
fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("첫 단어: {}", word);  // 첫 단어: hello
    
    // 슬라이스의 장점: 원본 문자열이 변경되면 컴파일 에러
    // let mut s = String::from("hello world");
    // let word = first_word(&s);
    // s.clear();  // ❌ 에러! word가 s를 빌리고 있음
    // println!("{}", word);
}

슬라이스 타입:

// 문자열 슬라이스
let s: String = String::from("hello");
let slice: &str = &s[0..2];  // "he"

// 배열 슬라이스
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..3];  // [2, 3]

// 슬라이스는 포인터 + 길이 정보를 가짐
// 메모리 안전: 범위를 벗어나면 패닉

5. 라이프타임

라이프타임 애노테이션

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

fn main() {
    let s1 = String::from("long string");
    let s2 = String::from("short");
    
    let result = longest(&s1, &s2);
    println!("가장 긴 문자열: {}", result);
}

구조체 라이프타임

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    
    let excerpt = ImportantExcerpt {
        part: first_sentence,
    };
    
    println!("{}", excerpt.part);
}

6. 실전 예제

예제: 문자열 처리

fn main() {
    let text = String::from("hello rust world");
    
    let words = split_words(&text);
    println!("단어: {:?}", words);
    
    let first = first_word(&text);
    println!("첫 단어: {}", first);
}

fn split_words(s: &String) -> Vec<&str> {
    s.split_whitespace().collect()
}

fn first_word(s: &String) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

정리

핵심 요약

  1. 소유권: 각 값은 하나의 소유자
  2. 이동: 소유권 이전
  3. 빌림: 참조로 사용
  4. 라이프타임: 참조 유효 범위
  5. 안전성: 컴파일 타임 보장

다음 단계

  • Rust 구조체와 열거형
  • Rust 에러 처리
  • Rust 트레이트

다른 언어와 비교

  • C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move

관련 글

  • C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
  • Rust 메모리 안전성 완벽 가이드 | 소유권·Borrow checker·수명·unsafe 실전
  • Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]
  • C++ Observer Pointer |
  • C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]