이미지 로딩 중...

Rust 입문 가이드 5 소유권(Ownership)이란 무엇인가 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 3 Views

Rust 입문 가이드 5 소유권(Ownership)이란 무엇인가

Rust의 가장 독특하고 강력한 기능인 소유권(Ownership)에 대해 배웁니다. 메모리 안전성을 컴파일 타임에 보장하는 Rust만의 혁신적인 메모리 관리 방식을 초급자 관점에서 쉽게 풀어 설명합니다. 가비지 컬렉터 없이도 메모리 누수와 댕글링 포인터를 방지하는 방법을 실전 예제와 함께 익혀보세요.


목차

  1. 소유권의 기본 개념 - Rust 메모리 관리의 핵심 원리
  2. 참조와 차용(Borrowing) - 소유권 이동 없이 값 사용하기
  3. 가변 참조(Mutable References) - 빌린 값 수정하기
  4. 소유권 규칙과 슬라이스(Slice) - 컬렉션의 일부 참조하기
  5. 스코프와 Drop 트레이트 - 자동 메모리 해제의 원리
  6. 소유권과 함수 - 값 전달과 반환의 패턴
  7. Copy vs Clone - 복제의 두 가지 방식
  8. 소유권과 스레드 - 안전한 동시성의 기초

1. 소유권의 기본 개념 - Rust 메모리 관리의 핵심 원리

시작하며

여러분이 C나 C++로 프로그램을 작성할 때 메모리 누수(memory leak)나 이중 해제(double free) 버그로 몇 시간씩 디버깅한 경험이 있나요? 또는 Python이나 JavaScript에서 편하게 개발하다가 성능 문제로 가비지 컬렉터의 일시 정지(GC pause) 때문에 고민했던 적이 있으신가요?

이런 문제는 실제 개발 현장에서 정말 자주 발생합니다. C/C++은 개발자가 직접 메모리를 관리해야 해서 실수할 여지가 많고, Java나 Python은 런타임에 가비지 컬렉터가 메모리를 관리하지만 성능 오버헤드가 있습니다.

결국 "안전성"과 "성능" 중 하나를 포기해야 했던 것이 전통적인 프로그래밍의 딜레마였죠. 바로 이럴 때 필요한 것이 Rust의 소유권(Ownership) 시스템입니다.

소유권은 컴파일 타임에 메모리 안전성을 보장하면서도 런타임 오버헤드가 전혀 없는 혁신적인 방법으로, 두 마리 토끼를 모두 잡을 수 있게 해줍니다.

개요

간단히 말해서, 소유권은 "각 값은 정확히 하나의 소유자(owner)를 가지며, 소유자가 스코프를 벗어나면 값이 자동으로 해제된다"는 원칙입니다. 왜 이 개념이 필요할까요?

전통적으로 C/C++에서는 malloc/new로 할당한 메모리를 개발자가 직접 free/delete로 해제해야 했습니다. 하나라도 빠뜨리면 메모리 누수가 발생하고, 두 번 해제하면 프로그램이 크래시됩니다.

예를 들어, 여러 함수 사이에서 포인터를 전달하다 보면 "누가 이 메모리를 해제해야 하지?"라는 혼란이 생기죠. 특히 멀티스레드 환경에서는 더욱 복잡해집니다.

기존에는 수동 메모리 관리(C/C++)나 가비지 컬렉터(Java/Python)를 사용했다면, 이제는 컴파일러가 소유권 규칙을 체크해서 안전하지 않은 코드는 아예 컴파일되지 않게 할 수 있습니다. 소유권의 핵심 특징은 세 가지입니다: 1) 각 값은 정확히 하나의 소유자를 갖는다, 2) 소유자가 스코프를 벗어나면 값이 자동으로 drop된다, 3) 소유권은 다른 변수로 이동(move)될 수 있다.

이러한 특징들이 컴파일 타임에 강제되기 때문에 런타임 에러를 원천적으로 차단할 수 있습니다.

코드 예제

fn main() {
    // s1이 String 값의 소유자가 됨
    let s1 = String::from("hello");

    // s1의 소유권이 s2로 이동(move)됨
    let s2 = s1;

    // 이제 s1은 더 이상 유효하지 않음
    // println!("{}", s1); // 컴파일 에러!

    println!("{}", s2); // "hello" 출력

    // take_ownership 함수로 소유권 이동
    take_ownership(s2);

    // s2도 이제 사용 불가
    // println!("{}", s2); // 컴파일 에러!
}

fn take_ownership(s: String) {
    println!("{}", s);
} // s가 스코프를 벗어나면서 자동으로 메모리 해제

설명

이것이 하는 일: 위 코드는 Rust의 소유권 이동(move)이 어떻게 작동하는지 보여줍니다. 변수 간에 값을 할당하거나 함수에 전달할 때 소유권이 이동하며, 이전 소유자는 더 이상 그 값을 사용할 수 없게 됩니다.

첫 번째로, let s1 = String::from("hello")는 힙 메모리에 "hello"라는 문자열을 할당하고 s1이 그 소유자가 됩니다. 이때 s1은 스택에 저장되는 포인터, 길이, 용량 정보를 가지고 있고, 실제 문자열 데이터는 힙에 있습니다.

왜 이렇게 하냐면, String은 크기가 가변적이어서 컴파일 타임에 크기를 알 수 없기 때문입니다. 그 다음으로, let s2 = s1이 실행되면 s1의 소유권이 s2로 완전히 이동(move)합니다.

이것은 단순히 포인터를 복사하는 것이 아니라, s1을 무효화(invalidate)시킵니다. 만약 C++처럼 둘 다 유효하다면, 두 변수가 스코프를 벗어날 때 같은 메모리를 두 번 해제하려는 이중 해제 버그가 발생할 수 있습니다.

Rust는 이런 위험을 컴파일 타임에 차단합니다. 마지막으로, take_ownership(s2)를 호출하면 s2의 소유권이 함수의 매개변수 s로 이동합니다.

함수가 끝나면 s가 스코프를 벗어나면서 자동으로 drop 함수가 호출되어 힙 메모리가 해제됩니다. 이 과정이 완전히 자동이고 개발자가 신경 쓸 필요가 없습니다.

여러분이 이 코드를 사용하면 메모리 누수를 원천적으로 방지할 수 있습니다. 컴파일러가 모든 소유권 규칙을 체크하기 때문에 실수로 해제된 메모리에 접근하거나(use-after-free), 메모리를 두 번 해제하거나(double-free), 메모리 해제를 잊어버리는(memory leak) 모든 경우를 방지할 수 있습니다.

또한 런타임 가비지 컬렉터가 없어서 예측 가능한 고성능을 달성할 수 있습니다.

실전 팁

💡 정수형이나 부울형 같은 Copy 트레이트를 구현한 타입은 소유권이 이동하지 않고 복사됩니다. let x = 5; let y = x; 이후에도 x를 계속 사용할 수 있습니다.

💡 함수에 값을 전달하면 소유권이 이동하므로, 이후에도 사용하고 싶다면 참조(&)를 전달하거나 함수에서 값을 반환받아야 합니다.

💡 컴파일 에러 메시지를 두려워하지 마세요. Rust 컴파일러는 매우 친절하게 어디서 소유권 규칙을 위반했는지 알려주고 해결 방법까지 제안해줍니다.

💡 String과 &str의 차이를 이해하세요. String은 소유권을 가진 힙 할당 문자열이고, &str은 문자열 슬라이스에 대한 참조입니다.

💡 소유권 때문에 코드가 복잡해진다면, 참조와 차용(borrowing)을 사용하세요. 다음 섹션에서 자세히 다룹니다.


2. 참조와 차용(Borrowing) - 소유권 이동 없이 값 사용하기

시작하며

여러분이 함수에 큰 데이터 구조를 전달해야 하는데, 그 데이터를 함수 호출 후에도 계속 사용해야 하는 상황을 만난 적 있나요? 소유권 규칙만 사용한다면 함수에서 값을 다시 반환받아야 하는데, 이게 여러 개라면 코드가 매우 복잡해집니다.

이런 문제는 실제로 함수 설계에서 자주 발생합니다. 모든 함수가 소유권을 가져간다면 코드가 금방 복잡해지고 가독성이 떨어집니다.

예를 들어, 문자열 길이만 확인하고 싶은데 소유권까지 가져가야 한다면 너무 과한 것이죠. 바로 이럴 때 필요한 것이 참조(Reference)와 차용(Borrowing)입니다.

소유권을 이동시키지 않고 값을 "빌려서" 사용할 수 있어, 효율적이고 깔끔한 코드를 작성할 수 있습니다.

개요

간단히 말해서, 참조는 소유권을 가져가지 않고 값을 가리키는 포인터이며, 차용은 이 참조를 통해 값을 빌리는 행위입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 함수는 데이터를 읽기만 하고 소유할 필요가 없습니다.

예를 들어, 문자열의 길이를 계산하거나, 리스트를 순회하거나, 구조체의 필드를 읽는 경우에는 소유권이 필요 없죠. 이런 경우마다 소유권을 이동시키고 다시 반환하면 코드가 불필요하게 복잡해집니다.

기존에는 소유권을 이동시킨 후 반환받거나 데이터를 복제(clone)했다면, 이제는 참조를 사용해서 소유권은 원래 위치에 두고 읽기만 할 수 있습니다. 참조의 핵심 특징은 세 가지입니다: 1) &를 사용해서 참조를 만들고, *를 사용해서 역참조할 수 있다, 2) 기본적으로 불변(immutable) 참조이며, &mut로 가변 참조를 만들 수 있다, 3) 참조는 항상 유효한 값을 가리켜야 한다(댕글링 참조 불가).

이러한 특징들이 메모리 안전성을 유지하면서도 유연한 코드를 작성할 수 있게 해줍니다.

코드 예제

fn main() {
    let s1 = String::from("hello world");

    // s1의 참조를 전달 (소유권은 이동하지 않음)
    let len = calculate_length(&s1);

    println!("'{}' 의 길이는 {}입니다.", s1, len);

    // s1을 여전히 사용 가능
    let word = first_word(&s1);
    println!("첫 번째 단어: {}", word);
}

// 참조를 매개변수로 받음 (&String)
fn calculate_length(s: &String) -> usize {
    s.len() // 참조를 통해 값을 읽음
} // s는 참조일 뿐이므로 메모리 해제 안 됨

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[..]
}

설명

이것이 하는 일: 위 코드는 참조를 사용해서 소유권을 이동시키지 않고 함수 간에 데이터를 공유하는 방법을 보여줍니다. &를 사용해서 참조를 만들고, 함수가 참조를 받아서 원본 데이터에 접근합니다.

첫 번째로, &s1은 s1에 대한 불변 참조를 만듭니다. 이것은 s1이 가리키는 데이터의 주소를 복사하지만, 소유권은 여전히 s1에 있습니다.

calculate_length 함수는 &String 타입의 매개변수를 받는데, 이는 "String의 참조"를 의미합니다. 왜 이렇게 하냐면, 함수는 문자열의 길이만 확인하면 되고 소유권까지 가질 필요가 없기 때문입니다.

그 다음으로, 함수 내부에서 s.len()을 호출할 때 자동으로 역참조가 일어납니다. Rust는 메서드 호출 시 자동으로 .를 사용하면 필요한 만큼 역참조하거나 참조를 만들어줍니다.

함수가 끝날 때 s는 참조일 뿐이므로 drop이 호출되지 않고, 원본 s1의 데이터는 안전하게 보존됩니다. 마지막으로, first_word 함수는 더 흥미로운 예시를 보여줍니다.

이 함수는 문자열 슬라이스 &str을 반환하는데, 이것은 원본 문자열의 일부에 대한 참조입니다. &s[0..i]는 s의 0번째부터 i-1번째까지의 문자들을 가리키는 참조를 만듭니다.

이 참조는 s가 유효한 동안만 유효하며, Rust 컴파일러가 이를 보장합니다(라이프타임 체크). 여러분이 이 코드를 사용하면 불필요한 복제나 소유권 이동을 피할 수 있습니다.

특히 큰 데이터 구조를 다룰 때 성능상 이점이 큽니다. 또한 코드의 의도가 명확해집니다: 참조를 받는 함수는 "나는 이 데이터를 읽기만 하고 소유하지 않는다"는 것을 명시적으로 표현합니다.

컴파일러는 참조가 유효한 데이터를 가리키는지 확인하므로 댕글링 포인터 버그가 발생할 수 없습니다.

실전 팁

💡 함수 시그니처를 볼 때 참조 여부를 확인하세요. fn foo(s: String)은 소유권을 가져가지만, fn foo(s: &String)은 빌리기만 합니다.

💡 불변 참조는 여러 개 동시에 만들 수 있습니다. let r1 = &s; let r2 = &s; 모두 가능하며, 읽기 전용이므로 안전합니다.

💡 문자열 함수를 작성할 때는 &String보다 &str을 매개변수로 받는 것이 더 유연합니다. String과 &str 모두 전달할 수 있기 때문입니다.

💡 참조는 항상 유효해야 하므로, 함수에서 로컬 변수의 참조를 반환하려고 하면 컴파일 에러가 발생합니다. 이는 댕글링 참조를 방지합니다.

💡 참조를 "빌린다(borrow)"고 표현하는 이유는, 실제로 빌린 물건처럼 일시적으로 사용한 후 원래 주인에게 돌려주기 때문입니다.


3. 가변 참조(Mutable References) - 빌린 값 수정하기

시작하며

여러분이 함수에서 어떤 데이터를 수정해야 하는데, 소유권까지 가져가고 싶지는 않은 상황을 생각해보세요. 예를 들어, 리스트의 모든 요소를 대문자로 바꾸거나, 구조체의 특정 필드만 업데이트하는 경우입니다.

이런 문제는 실제 개발 현장에서 매우 흔합니다. 불변 참조만으로는 값을 읽을 수만 있고 수정할 수 없습니다.

그렇다고 소유권을 가져가서 수정한 후 다시 반환하면 코드가 너무 복잡해지죠. 특히 여러 필드를 가진 구조체의 일부만 수정하고 싶을 때 이런 고민이 생깁니다.

바로 이럴 때 필요한 것이 가변 참조(Mutable Reference)입니다. &mut를 사용하면 소유권 없이도 값을 수정할 수 있으며, 동시에 Rust의 안전성 보장도 유지됩니다.

개요

간단히 말해서, 가변 참조는 &mut를 사용해서 만들며, 빌린 값을 수정할 수 있게 해주는 참조입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 많은 경우 데이터를 제자리에서(in-place) 수정하는 것이 가장 효율적입니다.

예를 들어, 큰 벡터의 모든 요소를 변환하거나, 게임 오브젝트의 위치를 업데이트하거나, 설정 객체의 일부를 변경하는 경우 복사본을 만들지 않고 직접 수정하는 것이 메모리와 성능 면에서 유리합니다. 기존에는 값을 복제(clone)하거나 소유권을 이동시켜야 했다면, 이제는 가변 참조를 사용해서 원본을 직접 수정할 수 있습니다.

가변 참조의 핵심 특징은 세 가지입니다: 1) 한 번에 하나의 가변 참조만 존재할 수 있다(배타적 접근), 2) 가변 참조가 있는 동안 불변 참조를 만들 수 없다, 3) 이 규칙들로 인해 데이터 레이스(data race)가 컴파일 타임에 방지된다. 이러한 특징들이 멀티스레드 환경에서도 안전성을 보장하는 핵심 메커니즘입니다.

코드 예제

fn main() {
    let mut s = String::from("hello");

    // 가변 참조를 전달
    change(&mut s);

    println!("{}", s); // "hello, world" 출력

    // 가변 참조는 한 번에 하나만 가능
    let r1 = &mut s;
    // let r2 = &mut s; // 컴파일 에러!
    r1.push_str("!");
    println!("{}", r1);

    // r1 사용이 끝났으므로 새로운 가변 참조 가능
    let r2 = &mut s;
    r2.push_str("!");
    println!("{}", r2);
}

fn change(s: &mut String) {
    // 가변 참조를 통해 값 수정
    s.push_str(", world");
}

설명

이것이 하는 일: 위 코드는 가변 참조를 사용해서 소유권 없이 값을 수정하는 방법과, Rust가 어떻게 배타적 접근을 강제하는지 보여줍니다. 첫 번째로, let mut s로 변수를 선언할 때 mut 키워드를 붙여야 가변 참조를 만들 수 있습니다.

이는 "이 변수는 수정될 수 있다"는 명시적인 의도를 나타냅니다. 그런 다음 &mut s로 가변 참조를 만들어 change 함수에 전달합니다.

왜 이렇게 하냐면, 함수가 문자열을 수정해야 하지만 소유권까지 필요하지 않기 때문입니다. 그 다음으로, change 함수는 &mut String 타입을 받아서 push_str 메서드로 문자열을 수정합니다.

이것은 원본 s를 직접 변경하며, 추가 메모리 할당이나 복사가 필요 없습니다. 함수가 끝나도 참조일 뿐이므로 메모리가 해제되지 않고, 원본 s에는 변경된 값이 남아있습니다.

마지막으로, 주석 처리된 let r2 = &mut s;는 중요한 규칙을 보여줍니다. r1이 유효한 동안에는 s에 대한 또 다른 가변 참조를 만들 수 없습니다.

이것이 바로 "배타적 접근" 규칙입니다. 하지만 r1의 마지막 사용 이후에는 r1이 더 이상 유효하지 않다고 간주되어(Non-Lexical Lifetimes), 새로운 가변 참조 r2를 만들 수 있습니다.

이 규칙 덕분에 두 개의 포인터가 동시에 같은 데이터를 수정하는 데이터 레이스가 불가능합니다. 여러분이 이 코드를 사용하면 안전하면서도 효율적인 제자리 수정이 가능합니다.

C++에서 발생할 수 있는 iterator invalidation이나 동시 접근 버그를 컴파일 타임에 차단할 수 있습니다. 또한 코드를 읽는 사람이 "&mut"를 보고 "이 함수는 데이터를 수정한다"는 것을 즉시 알 수 있어 가독성도 높아집니다.

멀티스레드 환경에서도 이 규칙이 적용되어 자동으로 스레드 안전성이 보장됩니다.

실전 팁

💡 가변 참조와 불변 참조를 동시에 가질 수 없습니다. let r1 = &s; let r2 = &mut s;는 컴파일 에러입니다. 읽는 중에 값이 바뀌는 것을 방지하기 위함입니다.

💡 Non-Lexical Lifetimes(NLL) 덕분에 참조의 마지막 사용 이후에는 새로운 참조를 만들 수 있습니다. 스코프가 끝나기 전이라도 더 이상 사용하지 않으면 됩니다.

💡 메서드 체이닝을 할 때 가변 참조를 반환하는 패턴이 자주 사용됩니다. impl 블록에서 &mut self를 반환하면 유창한(fluent) API를 만들 수 있습니다.

💡 벡터나 해시맵의 요소를 순회하면서 수정하려면 iter_mut()을 사용하세요. for item in vec.iter_mut() { *item += 1; }처럼 사용합니다.

💡 가변 참조가 너무 제한적이라고 느껴진다면, 내부 가변성(Interior Mutability) 패턴인 Cell, RefCell, Mutex 등을 고려해보세요.


4. 소유권 규칙과 슬라이스(Slice) - 컬렉션의 일부 참조하기

시작하며

여러분이 문자열이나 배열의 특정 부분만 필요한 경우를 생각해보세요. 예를 들어, 파일 경로에서 확장자만 추출하거나, 로그 메시지의 첫 100자만 표시하거나, 배열의 특정 범위만 정렬하고 싶은 경우입니다.

이런 문제는 실제로 매우 자주 발생합니다. 전체 데이터를 복사하면 메모리 낭비이고, 인덱스만 전달하면 원본 데이터에 대한 참조를 함께 전달해야 해서 코드가 복잡해집니다.

또한 원본 데이터가 수정되면 인덱스가 무효화될 수 있는 위험도 있습니다. 바로 이럴 때 필요한 것이 슬라이스(Slice)입니다.

슬라이스는 컬렉션의 연속된 일부를 참조하는 특별한 참조 타입으로, 안전하고 효율적인 부분 접근을 제공합니다.

개요

간단히 말해서, 슬라이스는 배열이나 문자열 같은 컬렉션의 연속된 일부 요소들에 대한 참조입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터의 부분을 다루는 것은 프로그래밍에서 기본적인 작업입니다.

예를 들어, HTTP 헤더를 파싱할 때 특정 필드만 추출하거나, 이미지 처리에서 특정 영역만 처리하거나, 대용량 파일의 일부만 읽는 경우가 그렇습니다. 이런 경우마다 데이터를 복사하면 성능과 메모리 효율이 크게 떨어집니다.

기존에는 시작 인덱스와 끝 인덱스를 별도로 전달하거나, 부분 문자열을 새로 생성(allocate)했다면, 이제는 슬라이스를 사용해서 원본 데이터의 일부를 가리키는 참조만 만들 수 있습니다. 슬라이스의 핵심 특징은 세 가지입니다: 1) 슬라이스는 데이터의 포인터와 길이를 가진 팻 포인터(fat pointer)이다, 2) 문자열 슬라이스(&str)와 배열 슬라이스(&[T])가 가장 자주 사용된다, 3) 슬라이스는 범위가 유효함을 컴파일러가 보장한다.

이러한 특징들이 메모리 효율적이면서도 안전한 부분 접근을 가능하게 합니다.

코드 예제

fn main() {
    let s = String::from("hello world programming");

    // 문자열 슬라이스: 바이트 인덱스 기반
    let hello = &s[0..5]; // "hello"
    let world = &s[6..11]; // "world"
    let programming = &s[12..]; // 끝까지

    println!("{}, {}, {}", hello, world, programming);

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

    println!("슬라이스 합: {}", sum_slice(slice));

    // 첫 단어 찾기 (안전한 방식)
    let first = first_word_safe(&s);
    println!("첫 단어: {}", first);
}

fn sum_slice(slice: &[i32]) -> i32 {
    slice.iter().sum()
}

fn first_word_safe(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

설명

이것이 하는 일: 위 코드는 문자열 슬라이스와 배열 슬라이스를 사용해서 원본 데이터를 복사하지 않고 부분에 접근하는 방법을 보여줍니다. 첫 번째로, &s[0..5]는 s의 0번째 바이트부터 5번째 바이트 직전까지를 가리키는 문자열 슬라이스를 만듭니다.

중요한 점은 이것이 새로운 String을 할당하는 것이 아니라, 기존 s의 힙 메모리를 가리키는 참조라는 것입니다. 슬라이스는 포인터(시작 위치)와 길이(5) 두 개의 값으로 구성된 "팻 포인터"입니다.

왜 이렇게 하냐면, 포인터만으로는 어디까지가 유효한 데이터인지 알 수 없기 때문입니다. 그 다음으로, 범위 문법을 유연하게 사용할 수 있습니다.

0..5는 0부터 5 직전까지, 6..11은 6부터 11 직전까지, 12..는 12부터 끝까지를 의미합니다. ..5는 처음부터 5 직전까지, ..는 전체를 의미합니다.

배열 슬라이스 &arr[1..4]도 동일한 방식으로 작동하며, 결과 타입은 &[i32]입니다. 이 슬라이스를 sum_slice 함수에 전달하면, 함수는 슬라이스의 길이를 알고 있으므로 안전하게 순회할 수 있습니다.

마지막으로, first_word_safe 함수는 슬라이스의 안전성을 잘 보여줍니다. 이 함수는 &str 타입을 받고 반환하는데, 반환된 슬라이스는 입력 문자열 s의 라이프타임에 묶여있습니다.

즉, 컴파일러가 "반환된 슬라이스는 s가 유효한 동안만 사용할 수 있다"는 것을 보장합니다. 만약 s를 수정하려고 하면 컴파일 에러가 발생해서, 슬라이스가 무효화되는 버그를 방지합니다.

여러분이 이 코드를 사용하면 메모리 복사 없이 효율적으로 부분 데이터를 다룰 수 있습니다. 특히 큰 문자열이나 배열을 다룰 때 성능 이점이 큽니다.

또한 슬라이스를 사용하면 함수가 더 유연해집니다: &String 대신 &str을 받으면 String, &str, 문자열 리터럴 모두 전달할 수 있습니다. 컴파일러가 슬라이스의 범위를 체크하므로 버퍼 오버플로우나 out-of-bounds 접근이 런타임 패닉으로 잡히거나 컴파일 타임에 방지됩니다.

실전 팁

💡 문자열 슬라이스는 UTF-8 바이트 기준이므로, 문자 경계가 아닌 곳에서 슬라이싱하면 패닉합니다. chars() 메서드를 사용해서 문자 단위로 처리하세요.

💡 함수가 문자열을 받을 때는 &String보다 &str을 사용하세요. 더 범용적이고 관례(convention)입니다.

💡 벡터의 슬라이스를 얻으려면 &vec[..] 또는 vec.as_slice()를 사용하세요. 타입은 &[T]입니다.

💡 슬라이스는 Copy 트레이트를 구현하지 않지만 크기가 작아(포인터+길이) 스택 복사 비용이 작습니다.

💡 패턴 매칭에서 슬라이스를 사용하면 강력합니다: match slice { [first, rest @ ..] => ... }처럼 첫 요소와 나머지를 분리할 수 있습니다.


5. 스코프와 Drop 트레이트 - 자동 메모리 해제의 원리

시작하며

여러분이 C++로 코딩할 때 new로 할당한 객체를 delete로 해제하는 것을 잊어버려서 메모리 누수가 발생한 경험이 있나요? 또는 RAII(Resource Acquisition Is Initialization) 패턴을 사용해서 소멸자에서 자동으로 리소스를 해제하는 스마트 포인터를 사용해본 적이 있나요?

이런 문제는 실제 개발 현장에서 리소스 관리의 핵심입니다. 메모리뿐만 아니라 파일 핸들, 네트워크 연결, 데이터베이스 커넥션 등 모든 리소스는 사용 후 적절히 해제되어야 합니다.

수동으로 관리하면 실수하기 쉽고, 가비지 컬렉터에 의존하면 예측 불가능한 타이밍에 해제됩니다. 바로 이럴 때 필요한 것이 Rust의 스코프 기반 자동 해제와 Drop 트레이트입니다.

변수가 스코프를 벗어나면 자동으로 Drop이 호출되어 확정적(deterministic)이고 안전한 리소스 관리가 가능합니다.

개요

간단히 말해서, Drop 트레이트는 값이 스코프를 벗어날 때 자동으로 호출되는 정리(cleanup) 코드를 정의하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 거의 모든 프로그램은 어떤 형태로든 리소스를 관리합니다.

예를 들어, 파일을 열면 반드시 닫아야 하고, 뮤텍스를 잠그면 해제해야 하고, 메모리를 할당하면 해제해야 합니다. 이를 수동으로 관리하면 조기 리턴, 예외 처리, 복잡한 제어 흐름에서 누락하기 쉽습니다.

기존에는 명시적 해제(C의 free), finally 블록(Java/Python), 또는 defer 문(Go)을 사용했다면, 이제는 Drop 트레이트를 구현해서 컴파일러가 자동으로 적절한 시점에 정리 코드를 호출하게 할 수 있습니다. Drop 트레이트의 핵심 특징은 세 가지입니다: 1) 변수가 스코프를 벗어날 때 자동으로 drop 메서드가 호출된다, 2) 생성의 역순으로 drop된다(LIFO), 3) 명시적으로 drop을 호출할 수 없고 std::mem::drop을 사용해야 한다.

이러한 특징들이 C++의 RAII와 유사한 안전한 리소스 관리를 제공합니다.

코드 예제

use std::ops::Drop;

struct FileHandler {
    name: String,
}

impl FileHandler {
    fn new(name: &str) -> Self {
        println!("파일 열림: {}", name);
        FileHandler {
            name: name.to_string(),
        }
    }
}

// Drop 트레이트 구현
impl Drop for FileHandler {
    fn drop(&mut self) {
        println!("파일 닫힘: {}", self.name);
    }
}

fn main() {
    {
        let _file1 = FileHandler::new("config.txt");
        let _file2 = FileHandler::new("data.txt");
        println!("파일 사용 중...");
    } // 스코프 종료: data.txt, config.txt 순으로 drop

    println!("스코프 종료됨");

    // 명시적으로 일찍 해제하기
    let file3 = FileHandler::new("log.txt");
    println!("파일3 생성됨");
    drop(file3); // std::mem::drop 호출
    println!("파일3 해제됨");
}

설명

이것이 하는 일: 위 코드는 Drop 트레이트를 구현해서 스코프 종료 시 자동으로 정리 작업이 수행되는 것을 보여줍니다. 파일 핸들러 예제를 통해 RAII 패턴의 작동 방식을 이해할 수 있습니다.

첫 번째로, FileHandler 구조체는 파일 이름을 저장하고, new 함수에서 파일이 열렸다고 가정합니다. 실제로는 std::fs::File을 사용하겠지만, 여기서는 개념 설명을 위해 단순화했습니다.

impl Drop for FileHandler 블록에서 drop 메서드를 구현하는데, 이것이 스코프 종료 시 자동으로 호출됩니다. 왜 이렇게 하냐면, 파일을 열었으면 반드시 닫아야 하는데, 이를 자동화하면 실수를 방지할 수 있기 때문입니다.

그 다음으로, 중괄호로 만든 내부 스코프에서 _file1_file2를 생성합니다. 변수명 앞에 언더스코어(_)를 붙이면 "사용하지 않는 변수"라는 경고를 억제합니다.

중요한 점은 스코프가 끝날 때 생성의 역순으로 drop이 호출된다는 것입니다: _file2가 먼저 drop되고, 그 다음 _file1이 drop됩니다. 이는 의존성이 있는 리소스를 안전하게 해제하는 데 중요합니다(예: 파일을 먼저 닫고 버퍼를 해제).

마지막으로, std::mem::drop(file3)를 사용해서 스코프가 끝나기 전에 명시적으로 drop을 호출할 수 있습니다. 단순히 file3.drop()을 호출할 수는 없는데, 이는 이중 drop을 방지하기 위함입니다.

std::mem::drop은 소유권을 가져가서 즉시 drop하고, file3는 더 이상 사용할 수 없게 됩니다. 이것은 락(lock)을 조기에 해제하거나 메모리를 빨리 회수하고 싶을 때 유용합니다.

여러분이 이 코드를 사용하면 리소스 누수를 방지할 수 있습니다. 예외 상황이나 조기 리턴이 있어도 스코프를 벗어나면 자동으로 정리되므로 안전합니다.

C++의 RAII와 유사하지만, Rust는 소유권 시스템과 결합되어 더 강력한 보장을 제공합니다. 특히 파일, 소켓, 뮤텍스, 데이터베이스 커넥션 같은 시스템 리소스를 다룰 때 Drop을 활용하면 안전하고 깔끔한 코드를 작성할 수 있습니다.

실전 팁

💡 String, Vec, Box 같은 표준 타입들은 이미 Drop을 구현하고 있어서 자동으로 힙 메모리를 해제합니다.

💡 Copy 트레이트와 Drop 트레이트는 함께 구현할 수 없습니다. Copy는 비트 복사만 하는데 Drop은 정리 로직이 필요하므로 개념적으로 충돌합니다.

💡 Drop 구현 시 패닉을 일으키지 않도록 주의하세요. 패닉 중에 drop이 호출되면 프로그램이 중단됩니다(double panic).

💡 std::mem::forget을 사용하면 drop을 건너뛸 수 있지만, 메모리 누수가 발생할 수 있으므로 unsafe와 함께 사용할 때만 고려하세요.

💡 MutexGuard, File 같은 타입들이 Drop을 활용한 좋은 예시입니다. 스코프가 끝나면 자동으로 락 해제나 파일 닫기가 수행됩니다.


6. 소유권과 함수 - 값 전달과 반환의 패턴

시작하며

여러분이 함수를 설계할 때 "이 함수는 소유권을 가져가야 할까, 참조를 받아야 할까?"라는 고민을 해본 적 있나요? 잘못 선택하면 불필요한 복제가 발생하거나, 코드가 복잡해지거나, 사용자가 불편해질 수 있습니다.

이런 문제는 실제로 API 설계에서 매우 중요합니다. 함수 시그니처는 코드의 의도를 나타내고 사용 패턴을 결정합니다.

소유권을 가져가는 함수는 값을 "소비(consume)"하고, 참조를 받는 함수는 "빌립니다". 이 차이를 이해하지 못하면 비효율적이거나 사용하기 어려운 API가 만들어집니다.

바로 이럴 때 필요한 것이 소유권과 함수의 상호작용 패턴을 이해하는 것입니다. 언제 소유권을 가져가고, 언제 참조를 사용하며, 언제 값을 반환해야 하는지 알면 Rust다운 깔끔한 API를 설계할 수 있습니다.

개요

간단히 말해서, 함수는 매개변수로 소유권을 가져가거나(T), 불변 참조를 빌리거나(&T), 가변 참조를 빌릴 수 있으며(&mut T), 소유권을 반환할 수도 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 함수 시그니처는 함수가 데이터를 어떻게 사용할지를 명시합니다.

예를 들어, 데이터를 변환하는 함수는 소유권을 가져가서 변환 후 반환하고, 데이터를 검증하는 함수는 읽기만 하므로 불변 참조를 받으며, 데이터를 수정하는 함수는 가변 참조를 받습니다. 이런 패턴을 따르면 사용자가 함수의 동작을 시그니처만 보고 예측할 수 있습니다.

기존에는 포인터나 참조의 의미가 모호했다면(const, non-const 등), 이제는 소유권 시스템이 명확한 의미론을 제공합니다. 함수와 소유권의 핵심 패턴은 네 가지입니다: 1) 소비 함수(T): 값을 가져가서 다시 반환하지 않거나 변환해서 반환, 2) 읽기 함수(&T): 값을 읽기만 함, 3) 수정 함수(&mut T): 값을 제자리에서 수정, 4) 빌더 패턴(self): 메서드 체이닝을 위해 self의 소유권을 가져가서 반환.

이러한 패턴들이 명확하고 효율적인 API를 만드는 기초입니다.

코드 예제

fn main() {
    let s = String::from("hello");

    // 1. 소비 함수: 소유권을 가져감
    let s = add_world(s); // s의 소유권이 이동
    println!("{}", s);

    // 2. 읽기 함수: 불변 참조
    let len = calculate_length(&s);
    println!("길이: {}", len);

    // 3. 수정 함수: 가변 참조
    let mut s = s; // 가변으로 재바인딩
    make_uppercase(&mut s);
    println!("{}", s);

    // 4. 빌더 패턴
    let mut builder = StringBuilder::new();
    builder = builder.add("Hello").add(" ").add("World");
    println!("{}", builder.build());
}

// 소유권을 가져가서 변환 후 반환
fn add_world(mut s: String) -> String {
    s.push_str(" world");
    s
}

// 불변 참조로 읽기
fn calculate_length(s: &String) -> usize {
    s.len()
}

// 가변 참조로 수정
fn make_uppercase(s: &mut String) {
    *s = s.to_uppercase();
}

// 빌더 패턴 예시
struct StringBuilder {
    content: String,
}

impl StringBuilder {
    fn new() -> Self {
        StringBuilder { content: String::new() }
    }

    fn add(mut self, s: &str) -> Self {
        self.content.push_str(s);
        self
    }

    fn build(self) -> String {
        self.content
    }
}

설명

이것이 하는 일: 위 코드는 소유권과 함수 간의 네 가지 주요 패턴을 보여줍니다. 각 패턴은 다른 사용 사례에 적합하며, 올바른 패턴을 선택하면 효율적이고 직관적인 API를 만들 수 있습니다.

첫 번째로, add_world 함수는 String을 직접 받아서 소유권을 가져갑니다. 함수 내부에서 s를 수정하고 반환하는데, 이것은 "값을 소비해서 변환한다"는 패턴입니다.

호출자는 let s = add_world(s)처럼 반환값을 다시 받아야 합니다. 왜 이렇게 하냐면, 원본을 수정하는 것이 아니라 새로운 값을 만들거나 변환하는 함수에 적합하기 때문입니다.

예를 들어, 문자열을 암호화하거나 데이터를 다른 형태로 변환할 때 사용합니다. 그 다음으로, calculate_length&String을 받아서 읽기만 합니다.

이것은 가장 흔한 패턴으로, 함수가 데이터를 변경하지 않고 정보만 추출할 때 사용합니다. 호출 후에도 s를 계속 사용할 수 있어서 편리합니다.

make_uppercase&mut String을 받아서 제자리에서 수정합니다. 새로운 String을 할당하지 않고 기존 메모리를 재사용하므로 효율적입니다.

마지막으로, StringBuilder는 빌더 패턴의 예시입니다. add 메서드가 self(소유권)를 받아서 수정 후 다시 반환하므로, builder.add(...).add(...)처럼 메서드 체이닝이 가능합니다.

이것은 유창한(fluent) API를 만드는 Rust의 관용구입니다. build 메서드는 최종적으로 소유권을 소비해서 결과를 반환하며, 이후 builder를 사용할 수 없게 만들어 "완료된" 상태를 타입 시스템으로 표현합니다.

여러분이 이 패턴들을 사용하면 명확하고 효율적인 API를 설계할 수 있습니다. 소유권을 가져가는 함수는 "이 값을 소비한다"는 의도를 명확히 하고, 참조를 받는 함수는 "빌리기만 한다"는 것을 나타냅니다.

사용자는 함수 시그니처만 보고 어떻게 사용해야 할지 알 수 있습니다. 또한 컴파일러가 모든 규칙을 강제하므로, 런타임 에러나 메모리 버그를 원천적으로 차단할 수 있습니다.

실전 팁

💡 일반적으로 읽기 전용 함수는 &T를, 수정 함수는 &mut T를, 소비/변환 함수는 T를 사용하세요.

💡 작은 Copy 타입(i32, bool 등)은 참조보다 값으로 전달하는 것이 더 효율적입니다. 참조의 간접 참조 비용이 더 클 수 있습니다.

💡 &String보다 &str을, &Vec<T>보다 &[T]를 매개변수로 받으면 더 범용적인 함수를 만들 수 있습니다(deref coercion 활용).

💡 여러 값을 반환해야 한다면 튜플을 사용하세요: fn foo() -> (String, usize). 또는 의미 있는 구조체를 정의하는 것이 더 좋습니다.

💡 메서드에서 self, &self, &mut self 중 선택할 때도 같은 원칙이 적용됩니다. 대부분의 메서드는 &self&mut self를 사용합니다.


7. Copy vs Clone - 복제의 두 가지 방식

시작하며

여러분이 변수를 다른 변수에 할당할 때 "이게 복사되는 건가, 이동하는 건가?"라고 헷갈린 적 있나요? 정수형은 let y = x 후에도 x를 사용할 수 있는데, String은 왜 안 될까요?

이런 문제는 실제로 Rust를 배울 때 가장 혼란스러운 부분 중 하나입니다. 어떤 타입은 암묵적으로 복사되고, 어떤 타입은 소유권이 이동합니다.

이 차이를 이해하지 못하면 예상치 못한 컴파일 에러를 만나게 됩니다. 바로 이럴 때 필요한 것이 Copy 트레이트와 Clone 트레이트의 차이를 이해하는 것입니다.

이 두 가지는 복제를 다루지만 완전히 다른 의미와 비용을 가지며, 언제 어떤 것을 사용할지 아는 것이 Rust 프로그래밍의 핵심입니다.

개요

간단히 말해서, Copy는 스택에서 비트 단위로 복사되는 암묵적이고 저렴한 복제이고, Clone은 명시적으로 호출해야 하는 잠재적으로 비싼 복제입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 타입의 복제 비용은 천차만별입니다.

정수형을 복사하는 것은 단 몇 비트를 복사하는 것으로 매우 저렴하지만, 큰 벡터를 복제하는 것은 힙 할당과 모든 요소의 복사를 포함해서 비쌉니다. 예를 들어, 게임에서 플레이어의 점수(i32)를 복사하는 것과 전체 게임 상태(수백만 개의 오브젝트)를 복제하는 것은 완전히 다른 비용입니다.

기존에는 모든 복제가 명시적이거나(C++의 복사 생성자), 모든 것이 참조였다면(Java/Python), 이제는 저렴한 복제(Copy)와 비싼 복제(Clone)를 타입 시스템으로 구분할 수 있습니다. Copy와 Clone의 핵심 차이점은 세 가지입니다: 1) Copy는 자동으로 일어나고 Clone은 명시적 호출이 필요하다, 2) Copy는 비트 단위 복사만 가능하고 Clone은 임의의 로직을 가질 수 있다, 3) Copy 타입은 소유권이 이동하지 않고 Clone은 새로운 소유자를 만든다.

이러한 차이가 성능과 명확성 사이의 균형을 제공합니다.

코드 예제

fn main() {
    // Copy 타입: 암묵적 복사
    let x = 5;
    let y = x; // x가 복사됨
    println!("x: {}, y: {}", x, y); // 둘 다 사용 가능

    let point = (3, 4);
    let point2 = point; // 튜플도 Copy
    println!("point: {:?}, point2: {:?}", point, point2);

    // Non-Copy 타입: 소유권 이동
    let s1 = String::from("hello");
    let s2 = s1; // 이동 발생
    // println!("{}", s1); // 에러!

    // Clone 사용: 명시적 복제
    let s3 = String::from("world");
    let s4 = s3.clone(); // 깊은 복사
    println!("s3: {}, s4: {}", s3, s4); // 둘 다 사용 가능

    // 복잡한 타입의 Clone
    let vec1 = vec![1, 2, 3, 4, 5];
    let vec2 = vec1.clone(); // 모든 요소 복사
    println!("vec1: {:?}, vec2: {:?}", vec1, vec2);
}

// Copy 가능한 커스텀 타입
#[derive(Copy, Clone, Debug)]
struct Point {
    x: i32,
    y: i32,
}

// Clone만 가능한 타입 (힙 할당 포함)
#[derive(Clone, Debug)]
struct Person {
    name: String, // String은 Copy가 아님
    age: i32,
}

설명

이것이 하는 일: 위 코드는 Copy와 Clone의 차이점과 각각이 어떻게 작동하는지 보여줍니다. Copy 타입은 할당 시 자동으로 복사되고, non-Copy 타입은 소유권이 이동하며, clone()을 호출하면 명시적으로 복제할 수 있습니다.

첫 번째로, 기본 타입들(i32, f64, bool, char 등)과 이들로만 구성된 튜플/배열은 Copy 트레이트를 구현합니다. let y = x를 실행하면 x의 비트들이 y로 복사되고, 둘 다 독립적인 값을 가집니다.

이것은 스택에서만 일어나고 포인터가 없으므로 매우 저렴합니다. 왜 이렇게 하냐면, 정수형 같은 작은 타입은 복사해도 비용이 거의 없고, 소유권 이동으로 인한 복잡성보다 편의성이 더 중요하기 때문입니다.

그 다음으로, String이나 Vec 같은 힙 할당 타입은 Copy를 구현하지 않습니다. 이들은 스택에 포인터를 가지고 힙에 실제 데이터를 가지는데, 포인터만 복사하면 이중 해제 문제가 발생합니다.

따라서 기본적으로 소유권이 이동합니다. 하지만 clone()을 명시적으로 호출하면 힙 데이터까지 전부 복사하는 "깊은 복사(deep copy)"가 일어납니다.

vec1.clone()은 새로운 힙 할당을 하고 모든 요소를 복사하므로 비용이 큽니다. 마지막으로, 커스텀 타입에 #[derive(Copy, Clone)]을 붙이면 자동으로 Copy를 구현할 수 있습니다.

단, 모든 필드가 Copy여야 합니다. Point는 두 개의 i32만 가지므로 Copy 가능하지만, Person은 String 필드를 가지므로 Clone만 가능합니다.

Clone을 derive하면 각 필드의 clone()을 호출하는 구현이 자동 생성됩니다. 여러분이 이 차이를 이해하면 성능과 편의성 사이에서 올바른 선택을 할 수 있습니다.

작은 타입은 Copy로 만들어서 편리하게 사용하고, 큰 타입은 non-Copy로 만들어서 불필요한 복제를 방지할 수 있습니다. clone() 호출을 보면 "여기서 비싼 작업이 일어난다"는 것을 바로 알 수 있어서 성능 병목을 찾기 쉽습니다.

또한 Copy 타입은 스레드 간에 공유하기도 쉬워서 병렬 프로그래밍에서 유용합니다.

실전 팁

💡 clone()이 코드에 많이 보인다면 설계를 재고해보세요. 참조나 Arc<T> 같은 스마트 포인터가 더 나을 수 있습니다.

💡 Copy와 Drop을 동시에 구현할 수 없습니다. Copy는 비트 복사만 하는데 Drop은 정리 로직이 필요하므로 개념적으로 충돌합니다.

💡 성능이 중요한 코드에서는 clone() 호출을 프로파일링하세요. 종종 가장 큰 병목이 됩니다.

💡 Rc<T>나 Arc<T>는 clone()이 참조 카운트만 증가시키므로 저렴합니다. 힙 데이터를 복사하지 않습니다.

💡 구조체를 설계할 때 "이것이 Copy여야 하나?"를 고민하세요. 일반적으로 작고 단순한 값 타입만 Copy로 만듭니다.


8. 소유권과 스레드 - 안전한 동시성의 기초

시작하며

여러분이 멀티스레드 프로그램을 작성할 때 데이터 레이스나 동기화 버그로 며칠씩 고생한 경험이 있나요? 한 스레드가 데이터를 읽는 동안 다른 스레드가 수정해서 일관성이 깨지거나, 락을 잊어버려서 교착상태(deadlock)에 빠지는 경우 말이죠.

이런 문제는 실제 개발 현장에서 가장 찾기 어렵고 재현하기 힘든 버그 중 하나입니다. 멀티스레드 버그는 타이밍에 의존하기 때문에 간헐적으로 발생하고, 디버거로 추적하면 사라지는 "하이젠버그(Heisenbug)" 현상도 흔합니다.

전통적인 언어들은 런타임에 이런 버그를 잡으려고 하지만, 이미 프로그램이 망가진 후입니다. 바로 이럴 때 필요한 것이 Rust의 소유권 기반 스레드 안전성입니다.

컴파일러가 스레드 간 데이터 공유를 체크해서 데이터 레이스를 컴파일 타임에 방지하며, "무서운 동시성(Fearless Concurrency)"을 가능하게 합니다.

개요

간단히 말해서, Rust는 소유권과 Send/Sync 트레이트를 사용해서 스레드 간에 안전하지 않은 데이터 공유를 컴파일 타임에 차단합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 소프트웨어는 거의 모두 멀티코어를 활용합니다.

웹 서버는 요청마다 스레드를 사용하고, 게임은 렌더링과 물리 연산을 병렬화하며, 데이터 처리는 여러 작업자 스레드로 분산합니다. 예를 들어, 이미지 처리 프로그램이 여러 스레드로 픽셀을 처리할 때, 각 스레드가 겹치는 영역을 동시에 수정하면 결과가 망가집니다.

기존에는 뮤텍스, 세마포어, 조건 변수 같은 동기화 프리미티브를 수동으로 관리했다면, 이제는 타입 시스템이 "이 데이터는 여러 스레드에서 안전하게 사용할 수 있다"를 보장합니다. 소유권 기반 스레드 안전성의 핵심 특징은 세 가지입니다: 1) Send 트레이트는 타입을 다른 스레드로 이동할 수 있는지 나타낸다, 2) Sync 트레이트는 타입의 참조를 여러 스레드에서 공유할 수 있는지 나타낸다, 3) 컴파일러가 이 트레이트들을 체크해서 데이터 레이스를 방지한다.

이러한 특징들이 안전한 병렬 프로그래밍의 기초를 제공합니다.

코드 예제

use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    // 1. 소유권 이동: Send
    let v = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        println!("벡터: {:?}", v); // v의 소유권이 이동됨
    });
    // println!("{:?}", v); // 에러! v는 이동됨
    handle.join().unwrap();

    // 2. Arc로 공유: Sync
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    let mut handles = vec![];

    for i in 0..3 {
        let data_clone = Arc::clone(&data); // 참조 카운트 증가
        let handle = thread::spawn(move || {
            println!("스레드 {}: {:?}", i, data_clone);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    // 3. Mutex로 가변 공유
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter_clone.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("결과: {}", *counter.lock().unwrap());
}

설명

이것이 하는 일: 위 코드는 Rust에서 안전하게 스레드 간 데이터를 공유하는 세 가지 패턴을 보여줍니다. 소유권 이동, 불변 공유, 가변 공유 각각의 방법과 타입 시스템이 어떻게 안전성을 보장하는지 이해할 수 있습니다.

첫 번째로, thread::spawn(move || ...) 클로저에 move 키워드를 사용하면 클로저가 사용하는 변수들의 소유권이 새 스레드로 이동합니다. v의 소유권이 스레드로 완전히 넘어가므로, 메인 스레드에서는 더 이상 사용할 수 없습니다.

왜 이렇게 하냐면, 두 스레드가 동시에 같은 벡터를 소유하면 이중 해제가 발생할 수 있기 때문입니다. Send 트레이트가 구현된 타입만 이동할 수 있으며, 대부분의 타입이 Send입니다.

그 다음으로, Arc<T> (Atomic Reference Counted)는 여러 스레드에서 불변 데이터를 공유하게 해줍니다. Arc::clone()은 참조 카운트만 증가시키고 데이터는 복사하지 않습니다.

각 스레드는 자신의 Arc를 소유하지만, 모두 같은 힙 데이터를 가리킵니다. 읽기 전용이므로 데이터 레이스가 발생하지 않습니다.

Arc는 원자적(atomic) 연산으로 참조 카운트를 관리해서 스레드 안전합니다(Sync 트레이트 구현). 마지막으로, Mutex<T>는 가변 데이터를 여러 스레드에서 안전하게 공유하게 해줍니다.

lock()을 호출하면 뮤텍스를 획득하고 MutexGuard를 반환하는데, 이것이 스코프를 벗어나면 자동으로 락이 해제됩니다(Drop 트레이트). 한 번에 하나의 스레드만 락을 획득할 수 있어서 데이터 레이스가 불가능합니다.

Arc<Mutex<T>>는 "여러 스레드가 소유권을 공유하면서 가변 접근을 동기화한다"는 흔한 패턴입니다. 여러분이 이 패턴들을 사용하면 동시성 버그를 크게 줄일 수 있습니다.

컴파일러가 Send/Sync가 아닌 타입을 스레드로 보내려고 하면 에러를 내므로, 런타임 데이터 레이스가 원천적으로 불가능합니다. C++에서는 실수로 같은 벡터를 여러 스레드에 전달할 수 있지만, Rust에서는 컴파일이 안 됩니다.

또한 MutexGuard가 자동으로 락을 해제하므로 락 해제를 잊어버리는 실수도 방지됩니다. 이것이 바로 "무서운 동시성"의 의미입니다: 동시성 코드를 작성할 때 두려워할 필요가 없다는 것입니다.

실전 팁

💡 대부분의 타입은 자동으로 Send/Sync입니다. Rc<T>나 Cell<T> 같은 일부 타입만 non-Send/non-Sync이며, 컴파일러가 알아서 체크합니다.

💡 Arc는 참조 카운팅 오버헤드가 있으므로, 단일 스레드에서는 Rc를 사용하세요. Arc는 원자적 연산이 필요해서 더 느립니다.

💡 Mutex lock을 최소한으로 유지하세요. 락을 오래 잡고 있으면 다른 스레드가 대기하므로 병렬성이 떨어집니다.

💡 교착상태를 피하려면 항상 같은 순서로 여러 락을 획득하세요. 또는 try_lock()을 사용해서 실패 시 재시도하는 전략을 사용하세요.

💡 RwLock<T>는 여러 읽기 또는 하나의 쓰기를 허용합니다. 읽기가 많고 쓰기가 적은 경우 Mutex보다 효율적입니다.


#Rust#Ownership#메모리관리#메모리안전성#소유권규칙#프로그래밍언어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.