Rust 소유권 | Ownership, Borrowing, Lifetime

이 글의 핵심

Rust를 배우다 가장 많이 막히는 지점이 소유권입니다. 값마다 소유자가 하나뿐이고 스코프를 벗어나면 해제된다는 규칙이 함수 인자·반환값·참조에 어떻게 적용되는지 단계별로 따라가고, 가변 참조를 동시에 하나만 허용하는 이유와 라이프타임 표기가 필요한 순간을 다른 언어와 비교해 이해합니다.

시리즈 안내

#02 | 📋 전체 목차 | 이전: #01 시작하기 · 다음: #03 구조체


들어가며

소유권(Ownership)은 Rust의 핵심으로, 가비지 컬렉터(실행 중에 쓸모없는 메모리를 자동으로 회수하는 런타임 기능) 없이 메모리 안전을 잡는 규칙입니다. 비유: 힙에 있는 값은 열쇠가 하나뿐이며, 그 열쇠를 가진 변수만 문을 열 수 있습니다. 빌림(Borrowing)은 열쇠를 넘기지 않고 잠깐 대여해 보는 행위로, &T·&mut T 참조로 표현됩니다. C++에서는 스마트 포인터로 unique_ptr(단일 소유)·shared_ptr(참조 카운팅)을 쓰며, RAII와 이동 의미론으로 자원·소유권을 표현합니다. GC가 있는 언어는 런타임에 도달 가능성으로 회수하는 반면, Rust는 규칙을 컴파일 타임에 검사합니다. C++ 쪽 누수·도구는 메모리 누수 가이드, Valgrind, 누수 탐지 실전을 참고하세요.


소유권의 세 가지 규칙

규칙 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);  // 컴파일 에러!
    // error[E0382]: borrow of moved value: `s1`
    // (note: value borrowed here after move)
    // s1은 이미 소유권을 잃어서 사용 불가
    
    println!("{}", s2);  // ✅ OK - s2가 소유권을 가짐
}
// s2가 스코프를 벗어나면 메모리 자동 해제

왜 이동(move)할까? C++의 문제:

// C++: 복사 생성자를 잘못 만든(또는 기본 복사에 맡긴) 원시 포인터 소유 클래스
struct Buffer {
    char* data;
    Buffer(const char* s) : data(strdup(s)) {}
    ~Buffer() { free(data); }
    // 복사 생성자를 정의하지 않음 → 컴파일러 기본 복사는 포인터 값만 복사
};
Buffer b1("hello");
Buffer b2 = b1;  // b1.data와 b2.data가 같은 메모리를 가리킴
// 스코프를 벗어나면 소멸자가 두 번 free → 이중 해제 (double free)

참고로 std::string은 복사 생성자가 깊은 복사를 하므로 std::string s2 = s1;은 안전합니다. 대신 매번 새 메모리를 할당하고 내용을 복사하는 비용이 조용히 발생하고, 복사를 피하려면 std::move(s1)을 명시해야 하며, 이동 후의 s1은 “유효하지만 지정되지 않은 상태”로 여전히 접근할 수 있습니다. Rust는 이 기본값을 뒤집었습니다. 대입은 항상 이동이고, 이동된 변수는 컴파일러가 사용을 막으며, 복사가 필요하면 clone()을 적어야 합니다. 비교 관점: 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
// 이유: 비트 단위 복사만으로 온전한 사본이 되는 타입이라서

Copy의 기준은 “스택에 있느냐”가 아니라 “비트를 그대로 복사해도 안전한가”입니다. 정수, 부동소수점, bool, char, 그리고 원소가 모두 Copy인 튜플과 배열이 여기에 속하고, 불변 참조 &T도 Copy입니다. 반대로 힙 메모리처럼 해제해야 할 자원을 가진 타입(String, Vec, Box)은 비트 복사를 하면 두 변수가 같은 자원을 소유하게 되므로 Copy가 될 수 없고, 언어 규칙상 Drop을 구현한 타입은 Copy를 구현할 수 없습니다. 직접 만든 구조체도 필드가 모두 Copy면 #[derive(Clone, Copy)]로 복사 의미론을 가질 수 있지만, 나중에 String 필드를 추가하는 순간 Copy를 뗄 수밖에 없어 호출하는 코드가 전부 깨진다는 점을 생각하고 붙이는 편이 좋습니다.

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

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

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


함수 호출에서 소유권이 이동하고 돌아오는 방식

소유권 이동

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를 이름만으로도 구분하는 컨벤션이 도움이 됩니다. takes_ownership(s) 이후 s를 쓰면 나는 에러는 error[E0382]: borrow of moved value: s“이고, 컴파일러는 “value moved here”라는 주석으로 이동이 일어난 줄을 정확히 짚어 줍니다. 처음에는 이 에러가 반복적으로 나서 모든 곳에 .clone()을 붙이게 되기 쉬운데, 저도 Rust를 처음 쓸 때 그랬습니다. 대부분의 경우 올바른 해결은 복사가 아니라 함수가 소유권이 정말 필요한지를 다시 보는 것입니다. 값을 읽기만 한다면 &str/&T를 받고, 저장하거나 다른 스레드로 넘기는 경우에만 소유권(String, T)을 받는 식으로 시그니처가 의도를 드러내게 만드는 것이 Rust다운 설계입니다.

소유권 반환

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
}

참조와 빌림: &와 &mut의 규칙

불변 참조 (&)

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);
}

이 코드가 규칙 2와 3을 어기는 것처럼 보이는데도 컴파일되는 이유는 NLL(Non-Lexical Lifetimes) 때문입니다. Rust 2018부터 빌림의 수명은 변수의 스코프 끝이 아니라 그 참조가 마지막으로 사용된 지점까지로 계산됩니다. r1, r2는 첫 println! 이후 쓰이지 않으므로 거기서 빌림이 끝나고, 그래서 r3 = &mut s가 허용됩니다. 반대로 let r3 = &mut s; 뒤에 println!("{}", r1);을 한 줄 추가하면 error[E0502]: cannot borrow s as mutable because it is also borrowed as immutable이 납니다.

“가변 참조는 하나만” 규칙은 멀티스레드의 데이터 레이스만을 위한 것이 아닙니다. 단일 스레드에서도 C++의 반복자 무효화 같은 버그를 막아 줍니다. 예를 들어 for x in &v { v.push(*x); }는 벡터를 읽기 위해 빌린 상태에서 push로 가변 빌림을 시도하므로 컴파일되지 않는데, C++에서 같은 코드는 재할당으로 반복자가 해제된 메모리를 가리키는 미정의 동작입니다.


문자열·배열 슬라이스

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

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);
}

한국어 문자열을 다룰 때는 슬라이스 범위가 글자가 아니라 UTF-8 바이트 위치라는 점이 가장 흔한 함정입니다. 한글 한 글자는 UTF-8로 3바이트이므로, let s = String::from("안녕하세요"); let x = &s[0..1];은 컴파일은 되지만 실행 중에 byte index 1 is not a char boundary; it is inside '안' (bytes 0..3) 패닉이 납니다. s.len()도 글자 수가 아닌 바이트 수(15)를 돌려줍니다. 글자 단위로 다루려면 s.chars()로 반복하고, 앞의 n글자를 자르려면 s.char_indices().nth(n)으로 바이트 위치를 구한 뒤 슬라이스하거나, 검증된 경계만 받는 s.get(0..3)처럼 Option을 돌려주는 메서드를 쓰면 패닉 대신 None으로 처리할 수 있습니다. 위 first_word는 공백(1바이트 ASCII) 위치에서만 자르기 때문에 한글이 섞여도 안전합니다.

슬라이스 타입:

// 문자열 슬라이스
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]
// 슬라이스는 포인터 + 길이 정보를 가짐
// 메모리 안전: 범위를 벗어나면 패닉

라이프타임 애노테이션이 필요한 경우

라이프타임 애노테이션

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);
}

<'a>는 “반환된 참조는 x와 y 둘 다 살아 있는 동안만 유효하다”는 관계를 적은 것이고, 실제로는 두 인자 중 더 짧은 수명이 'a로 선택됩니다. 그래서 다음 코드는 거부됩니다.

let s1 = String::from("long string");
let result;
{
    let s2 = String::from("short");
    result = longest(&s1, &s2);
}   // s2가 여기서 해제됨
// println!("{}", result);  // error[E0597]: `s2` does not live long enough

실행해 보면 result는 더 긴 s1을 가리키므로 문제가 없을 것 같지만, 컴파일러는 함수 시그니처만 보고 판단하기 때문에 “어느 쪽을 반환할지 모른다”는 전제로 막습니다. 반대로 참조 인자가 하나뿐인 함수(fn first(s: &str) -> &str)나 &self 메서드는 라이프타임 생략 규칙으로 반환 참조가 그 인자(또는 self)에 묶인다고 자동 추론되어, 대부분의 코드에서는 'a를 쓸 일이 없습니다. 라이프타임 표기가 필요하다는 에러가 자주 난다면, 참조를 들고 다니는 대신 소유한 값(String)을 반환하는 설계가 더 단순하지 않은지 먼저 검토해 보는 것이 좋습니다.

구조체 라이프타임

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);
}

참조를 필드로 가진 구조체는 그 참조가 가리키는 값보다 오래 살 수 없습니다. 위 예제에서 excerpt는 novel이 살아 있는 동안만 쓸 수 있고, novel을 함수 안에서 만들어 ImportantExcerpt만 반환하려고 하면 E0515(returns a value referencing data owned by the current function) 에러가 납니다. 파서나 토크나이저처럼 원본 텍스트를 복사하지 않고 조각만 가리키는 구조에는 이 방식이 효율적이지만, 설정 객체나 캐시처럼 오래 보관할 데이터라면 String을 소유하는 구조체로 만드는 편이 라이프타임 전파 문제를 피할 수 있습니다.


예제: 문자열 처리 함수

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. 안전성: 컴파일 타임 보장

다음 단계


다른 언어와 비교


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 문자열을 받는 함수의 인자를 &String 대신 &str로 두는 이유는 무엇인가요?

A. &str은 문자열 슬라이스라서 String 전체의 참조뿐 아니라 &s[0..5] 같은 부분 슬라이스와 문자열 리터럴까지 모두 받을 수 있습니다. &String을 넘기면 자동으로 &str로 변환되므로 호출하는 쪽의 불편도 없습니다. 반환값을 슬라이스로 돌려주면 원본 String이 살아 있는 동안만 쓸 수 있도록 빌림 검사기가 막아 주기 때문에, 원본을 비운 뒤 결과를 쓰는 실수도 컴파일 타임에 잡힙니다.