현대 소프트웨어 개발에서 메모리 관리는 가장 까다로운 문제 중 하나입니다. C나 C++ 같은 언어를 사용해본 개발자라면 누구나 세그멘테이션 폴트, 메모리 누수, 댕글링 포인터와 같은 문제로 머리를 싸맨 경험이 있을 것입니다. 반면 Java나 Python 같은 언어는 가비지 컬렉터로 이러한 문제를 해결하지만, 성능 오버헤드와 예측 불가능한 지연 시간이라는 대가를 치릅니다. Rust는 이 두 세계의 장점을 결합한 혁신적인 접근 방식을 제시합니다. 바로 컴파일 타임에 메모리 안전성을 보장하면서도 런타임 오버헤드가 없는 시스템입니다.
이 글에서는 Rust의 메모리 안전성 메커니즘을 처음부터 끝까지 상세히 다루겠습니다. 초보자도 이해할 수 있도록 기본 개념부터 시작해서 점진적으로 고급 주제까지 살펴보겠습니다.
메모리 관리가 왜 어려운가
프로그래밍에서 메모리 관리가 어려운 이유를 이해하려면 먼저 메모리가 어떻게 사용되는지 알아야 합니다. 프로그램이 실행될 때 사용하는 메모리는 크게 스택과 힙으로 나뉩니다.
스택은 함수 호출과 지역 변수를 저장하는 공간입니다. 함수가 호출되면 스택에 새로운 프레임이 추가되고, 함수가 반환되면 그 프레임이 제거됩니다. 이 과정은 매우 빠르고 자동으로 이루어집니다. 하지만 스택의 크기는 제한적이고, 함수가 끝나면 데이터가 사라진다는 한계가 있습니다.
힙은 동적으로 할당되는 메모리 공간입니다. 프로그램 실행 중에 필요한 만큼 메모리를 요청하고 해제할 수 있습니다. 크기 제한이 덜하고 함수 호출이 끝나도 데이터가 유지되지만, 수동으로 관리해야 하므로 실수하기 쉽습니다.
전통적인 언어에서는 개발자가 직접 메모리를 할당하고 해제해야 합니다. 메모리를 할당한 후 해제하지 않으면 메모리 누수가 발생하고, 너무 일찍 해제하면 이미 해제된 메모리에 접근하는 use-after-free 버그가 발생합니다. 여러 곳에서 같은 메모리를 동시에 수정하려 하면 데이터 경쟁이 일어나 예측 불가능한 동작이 발생할 수 있습니다.
Rust의 소유권 시스템: 게임의 규칙을 바꾸다
Rust는 이러한 문제를 소유권 시스템이라는 독특한 방식으로 해결합니다. 소유권 시스템은 세 가지 핵심 규칙을 기반으로 합니다.
첫째, Rust의 모든 값은 정확히 하나의 소유자를 가집니다. 변수가 값을 소유한다는 것은 그 변수가 해당 메모리를 관리할 책임이 있다는 의미입니다. 이것은 마치 도서관에서 책을 빌린 사람이 그 책에 대한 책임을 지는 것과 비슷합니다.
둘째, 한 번에 하나의 소유자만 존재할 수 있습니다. 값의 소유권은 다른 변수로 이동할 수 있지만, 여러 변수가 동시에 같은 값을 소유할 수는 없습니다. 이는 책을 한 번에 한 사람만 빌릴 수 있는 것과 같습니다.
셋째, 소유자가 스코프를 벗어나면 값은 자동으로 삭제됩니다. 변수가 선언된 중괄호 블록이 끝나면 그 변수가 소유한 메모리는 자동으로 해제됩니다. 도서관 대출 기간이 끝나면 자동으로 책이 반납되는 것처럼 말이죠.
간단한 예제로 이를 살펴보겠습니다.
rust
fn main() {
// s는 문자열 "hello"를 소유합니다
let s = String::from("hello");
// 소유권이 s에서 t로 이동합니다
// 이제 s는 더 이상 유효하지 않습니다
let t = s;
// 컴파일 에러! s는 이미 소유권을 잃었습니다
// println!("{}", s);
println!("{}", t); // 이것은 정상 작동합니다
} // t가 스코프를 벗어나면서 메모리가 자동으로 해제됩니다이 코드에서 String::from("hello")는 힙에 메모리를 할당하고 그 소유권을 변수 s에게 줍니다. let t = s;를 실행하면 소유권이 s에서 t로 이동합니다. 이를 move semantics라고 부릅니다. 이동 후에는 s를 사용할 수 없습니다. 컴파일러가 이를 검사해서 에러를 발생시키므로 런타임에 잘못된 메모리 접근이 일어날 수 없습니다.
복사와 이동: 타입에 따른 다른 동작
여기서 흥미로운 점은 모든 타입이 이동 시맨틱을 따르는 것은 아니라는 것입니다. 정수, 부동소수점, 불리언 같은 간단한 타입은 복사 시맨틱을 따릅니다.
rust
fn main() {
let x = 5;
let y = x; // 값이 복사됩니다
println!("x = {}, y = {}", x, y); // 둘 다 사용 가능합니다
}이 코드가 정상 작동하는 이유는 정수 타입이 Copy 트레잇을 구현하기 때문입니다. Copy 트레잇을 구현한 타입은 값이 이동하는 대신 복사됩니다. 복사는 스택에 있는 데이터를 비트 단위로 복제하는 것이므로 매우 빠릅니다.
어떤 타입이 Copy를 구현할 수 있을까요? 일반적으로 스택에만 저장되는 단순한 타입들이 해당됩니다. 모든 정수 타입, 부동소수점 타입, 불리언, 문자 타입이 Copy를 구현합니다. 또한 Copy 타입들로만 이루어진 튜플이나 배열도 Copy를 구현합니다.
반면 String, Vec, Box 같이 힙 메모리를 관리하는 타입은 Copy를 구현하지 않습니다. 만약 이런 타입을 복사하면 같은 힙 메모리를 가리키는 두 개의 포인터가 생기고, 둘 다 스코프를 벗어날 때 같은 메모리를 두 번 해제하려는 double free 문제가 발생할 수 있기 때문입니다.
참조와 빌림: 소유권을 이동하지 않고 데이터에 접근하기
소유권이 이동하면 원래 변수를 사용할 수 없게 되는데, 이것이 항상 편리한 것은 아닙니다. 함수에 값을 전달하고 나서도 그 값을 계속 사용하고 싶은 경우가 많습니다. Rust는 이를 위해 참조와 빌림 개념을 제공합니다.
참조는 다른 값을 가리키는 포인터입니다. 중요한 점은 참조는 값을 소유하지 않는다는 것입니다. 참조를 만드는 것을 빌림이라고 부르는데, 이는 실제로 책을 소유하지 않고 잠시 빌려 읽는 것과 비슷합니다.
rust
fn main() {
let s1 = String::from("hello");
// s1의 참조를 만듭니다 (불변 참조)
let len = calculate_length(&s1);
// s1은 여전히 유효합니다
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
// s는 String의 참조입니다
// 소유권이 없으므로 함수가 끝나도 메모리가 해제되지 않습니다
s.len()
} // 여기서 s가 스코프를 벗어나지만, 소유권이 없으므로 아무 일도 일어나지 않습니다앰퍼샌드 기호 &는 참조를 만듭니다. &s1은 s1의 값을 가리키지만 소유하지는 않습니다. calculate_length 함수는 &String 타입의 매개변수를 받으므로 참조만 받고 소유권은 받지 않습니다. 함수가 끝나도 원래 값은 그대로 유지됩니다.
기본적으로 참조는 불변입니다. 참조를 통해 값을 수정할 수 없습니다. 값을 수정하려면 가변 참조를 사용해야 합니다.
rust
fn main() {
let mut s = String::from("hello");
// 가변 참조를 만듭니다
change(&mut s);
println!("{}", s); // "hello, world"가 출력됩니다
}
fn change(s: &mut String) {
// 가변 참조를 통해 값을 수정할 수 있습니다
s.push_str(", world");
}&mut 키워드는 가변 참조를 만듭니다. 하지만 가변 참조에는 중요한 제약이 있습니다. 특정 스코프에서 특정 데이터에 대한 가변 참조는 딱 하나만 가질 수 있습니다.
rust
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 컴파일 에러!
println!("{}, {}", r1, r2);
}이 코드는 컴파일되지 않습니다. 같은 데이터에 대한 두 개의 가변 참조를 동시에 만들 수 없기 때문입니다. 이 제약이 있는 이유는 데이터 경쟁을 방지하기 위해서입니다.
데이터 경쟁은 세 가지 조건이 동시에 만족될 때 발생합니다. 두 개 이상의 포인터가 같은 데이터에 접근하고, 그중 적어도 하나가 데이터를 쓰고, 접근을 동기화하는 메커니즘이 없을 때입니다. Rust는 컴파일 타임에 이런 상황을 방지합니다.
또 다른 중요한 규칙은 가변 참조와 불변 참조를 동시에 가질 수 없다는 것입니다.
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 불변 참조
let r2 = &s; // 또 다른 불변 참조
let r3 = &mut s; // 컴파일 에러!
println!("{}, {}, and {}", r1, r2, r3);
}불변 참조를 가진 사람들은 값이 갑자기 변경되지 않을 것이라고 기대합니다. 하지만 가변 참조가 있으면 값이 변경될 수 있으므로 이는 안전하지 않습니다. 여러 개의 불변 참조는 허용됩니다. 데이터를 읽기만 하는 것은 다른 읽기 작업에 영향을 주지 않기 때문입니다.
흥미롭게도 참조의 스코프는 마지막으로 사용된 지점까지입니다. 선언된 지점부터 중괄호가 끝날 때까지가 아닙니다.
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// r1과 r2는 여기서 마지막으로 사용되므로 스코프가 끝납니다
let r3 = &mut s; // 이제 가변 참조를 만들 수 있습니다
println!("{}", r3);
}이 코드는 정상 작동합니다. r1과 r2는 println! 이후에 더 이상 사용되지 않으므로, r3을 만들 때는 활성화된 불변 참조가 없기 때문입니다.
댕글링 참조 방지하기
많은 언어에서 흔한 버그 중 하나는 댕글링 포인터입니다. 이미 해제된 메모리를 가리키는 포인터를 말합니다. Rust는 컴파일러가 이를 절대 허용하지 않습니다.
rust
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String { // 컴파일 에러!
let s = String::from("hello");
&s // s의 참조를 반환하려고 합니다
} // s가 스코프를 벗어나면서 메모리가 해제됩니다
// 참조는 이제 유효하지 않은 메모리를 가리킵니다이 코드는 컴파일되지 않습니다. s는 dangle 함수 안에서 생성되고, 함수가 끝나면 메모리가 해제됩니다. 그런데 &s를 반환하려고 하면 이미 해제된 메모리를 가리키는 참조를 반환하게 됩니다. Rust 컴파일러는 이를 감지하고 에러를 발생시킵니다.
해결책은 참조 대신 소유권을 이동시키는 것입니다.
rust
fn no_dangle() -> String {
let s = String::from("hello");
s // 소유권이 호출자에게 이동합니다
}이렇게 하면 소유권이 함수 밖으로 이동하므로 메모리가 해제되지 않습니다. 호출자가 반환된 String의 소유권을 받아 메모리를 관리하게 됩니다.
슬라이스: 컬렉션의 일부를 참조하기
슬라이스는 컬렉션의 연속된 일부를 참조하는 특별한 종류의 참조입니다. 소유권을 갖지 않습니다.
문자열 슬라이스를 예로 들어보겠습니다. 문자열의 첫 번째 단어를 찾는 함수를 작성한다고 가정해봅시다.
rust
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[..]
}
fn main() {
let s = String::from("hello world");
let word = first_word(&s);
println!("The first word is: {}", word); // "hello"
}&str는 문자열 슬라이스 타입입니다. &s[0..i] 문법은 문자열 s의 인덱스 0부터 i까지의 슬라이스를 만듭니다. 슬라이스는 시작 위치와 길이를 저장하는 참조입니다.
슬라이스의 강력한 점은 원본 데이터와 연결되어 있다는 것입니다. 슬라이스를 가지고 있는 동안에는 원본 데이터를 수정할 수 없습니다.
rust
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // 컴파일 에러!
println!("The first word is: {}", word);
}word는 s의 불변 참조입니다. s.clear()는 가변 참조가 필요한데, 불변 참조 word가 아직 사용 중이므로 컴파일러가 에러를 발생시킵니다. 이를 통해 슬라이스가 유효하지 않은 데이터를 가리키는 것을 방지합니다.
문자열 리터럴도 실은 슬라이스입니다.
rust
let s = "Hello, world!";여기서 s의 타입은 &str입니다. 문자열 리터럴은 바이너리의 특정 위치를 가리키는 슬라이스이며, 이것이 불변인 이유입니다. &str은 불변 참조이기 때문입니다.
배열도 슬라이스를 가질 수 있습니다.
rust
fn main() {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
}배열 슬라이스의 타입은 &[i32]입니다. 문자열 슬라이스와 같은 방식으로 작동하며, 시작 위치와 길이를 저장합니다.
수명: 참조가 유효한 기간
지금까지 우리는 컴파일러가 참조가 항상 유효하다는 것을 어떻게 보장하는지 살펴봤습니다. 이를 가능하게 하는 메커니즘이 바로 수명입니다. 모든 참조는 수명을 가지며, 이는 참조가 유효한 스코프를 의미합니다.
대부분의 경우 수명은 암시적이고 추론됩니다. 하지만 여러 참조의 수명이 서로 다른 방식으로 연결될 수 있을 때는 명시적으로 수명을 표시해야 합니다.
rust
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}
fn longest(x: &str, y: &str) -> &str { // 컴파일 에러!
if x.len() > y.len() {
x
} else {
y
}
}이 코드는 컴파일되지 않습니다. 컴파일러는 반환되는 참조가 x인지 y인지 알 수 없으므로, 반환된 참조의 수명을 결정할 수 없습니다. 이를 해결하기 위해 수명 매개변수를 사용합니다.
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}수명 매개변수는 어포스트로피로 시작하며, 보통 매우 짧은 이름을 사용합니다. 'a라는 수명 매개변수는 제네릭 타입 매개변수와 비슷하게 함수 시그니처에 선언됩니다.
이 시그니처는 “함수가 두 개의 매개변수를 받는데, 둘 다 최소한 수명 'a만큼 살아있는 문자열 슬라이스이며, 반환되는 문자열 슬라이스도 수명 'a만큼 살아있을 것”이라고 말합니다.
실제로는 반환값의 수명이 두 매개변수 중 더 짧은 것과 같아집니다.
rust
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
}이 코드는 정상 작동합니다. result의 수명은 string2의 수명과 같고, string2가 유효한 범위 안에서만 result를 사용하기 때문입니다.
하지만 다음 코드는 컴파일되지 않습니다.
rust
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result); // 컴파일 에러!
}result는 string2의 수명과 연결될 수 있는데, string2는 안쪽 스코프에서 해제됩니다. result를 바깥 스코프에서 사용하려고 하면 이미 해제된 메모리를 참조할 수 있으므로 컴파일러가 이를 방지합니다.
구조체도 참조를 필드로 가질 수 있지만, 수명 표시가 필요합니다.
rust
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);
}ImportantExcerpt 구조체는 문자열 슬라이스인 part 필드를 가집니다. 수명 표시 <'a>는 구조체의 인스턴스가 part 필드가 참조하는 데이터보다 오래 살 수 없다는 것을 의미합니다.
스마트 포인터: 소유권의 유연성 확장하기
지금까지는 참조만 살펴봤지만, Rust는 스마트 포인터라는 더 강력한 도구도 제공합니다. 스마트 포인터는 포인터처럼 작동하지만 추가 메타데이터와 기능을 가진 데이터 구조입니다.
가장 기본적인 스마트 포인터는 Box입니다. Box는 데이터를 스택이 아닌 힙에 저장할 수 있게 해줍니다. 스택에는 Box 자체만 남고, Box가 가리키는 데이터는 힙에 있습니다.
rust
fn main() {
let b = Box::new(5);
println!("b = {}", b);
} // b가 스코프를 벗어나면서 Box와 힙의 데이터 모두 해제됩니다Box는 컴파일 타임에 크기를 알 수 없는 타입을 사용할 때 유용합니다. 예를 들어 재귀 타입을 정의할 때 사용합니다.
rust
enum List {
Cons(i32, Box),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Box 없이 이 타입을 정의하면 무한한 크기를 가지게 되어 컴파일되지 않습니다. Box를 사용하면 포인터의 크기만 알면 되므로 타입의 크기가 결정됩니다.
Rc는 참조 카운팅 스마트 포인터입니다. 하나의 값에 여러 소유자를 가질 수 있게 해줍니다. 참조 카운트가 0이 되면 값이 해제됩니다.
rust
use std::rc::Rc;
fn main() {
let a = Rc::new(5);
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Rc::clone(&a);
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Rc::clone(&a);
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}Rc::clone은 데이터를 깊게 복사하는 대신 참조 카운트만 증가시킵니다. 각 클론이 스코프를 벗어날 때마다 카운트가 감소하고, 0이 되면 데이터가 해제됩니다.
RefCell은 내부 가변성 패턴을 제공합니다. 불변 참조를 통해서도 데이터를 변경할 수 있게 해주지만, 빌림 규칙은 런타임에 검사됩니다.
rust
use std::cell::RefCell;
fn main() {
let data = RefCell::new(5);
// 불변 참조를 통해 가변 빌림을 얻습니다
*data.borrow_mut() += 1;
println!("data = {}", data.borrow());
}RefCell은 빌림 규칙을 컴파일 타임이 아닌 런타임에 검사합니다. 규칙을 위반하면 컴파일 에러 대신 패닉이 발생합니다. 이는 컴파일러가 안전성을 보장할 수 없지만 프로그래머가 안전하다는 것을 알고 있는 경우에 유용합니다.
동시성과 메모리 안전성
Rust의 소유권 시스템은 동시성 프로그래밍에서도 빛을 발합니다. 데이터 경쟁을 컴파일 타임에 방지하므로 안전한 동시성 코드를 작성할 수 있습니다.
스레드 간 데이터 공유는 까다로운 문제입니다. Rust는 Send와 Sync 트레잇을 통해 이를 관리합니다. Send는 타입의 소유권을 스레드 간에 전송할 수 있음을 나타내고, Sync는 타입을 여러 스레드에서 안전하게 참조할 수 있음을 나타냅니다.
대부분의 타입은 Send이지만, Rc는 Send가 아닙니다. 스레드 안전한 참조 카운팅이 필요하면 Arc를 사용해야 합니다.
rust
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());
}Arc는 원자적 참조 카운팅을 제공하는 스레드 안전한 버전의 Rc입니다. Mutex는 상호 배제를 제공하여 한 번에 하나의 스레드만 데이터에 접근할 수 있게 합니다. move 클로저는 캡처한 값의 소유권을 스레드로 이동시킵니다.
컴파일러는 각 스레드가 카운터의 소유권을 가지려고 하지만 Arc를 통해 공유되고 있으며, Mutex를 통해 안전하게 접근되고 있음을 검증합니다. 이 모든 것이 컴파일 타임에 검사되므로 런타임 오버헤드 없이 안전성을 보장합니다.
실전 활용: 메모리 안전한 데이터 구조 구현하기
이론을 실제로 적용해보기 위해 간단한 연결 리스트를 구현해보겠습니다. 이 예제는 지금까지 배운 개념들을 종합적으로 활용합니다.
rust
use std::rc::Rc;
use std::cell::RefCell;
type Link = Option>>;
struct Node {
value: i32,
next: Link,
}
struct LinkedList {
head: Link,
}
impl LinkedList {
fn new() -> Self {
LinkedList { head: None }
}
fn push_front(&mut self, value: i32) {
let new_node = Rc::new(RefCell::new(Node {
value,
next: self.head.clone(),
}));
self.head = Some(new_node);
}
fn pop_front(&mut self) -> Option {
self.head.take().map(|node| {
// RefCell을 통해 가변 빌림을 얻습니다
let mut node_ref = node.borrow_mut();
// 다음 노드를 헤드로 만듭니다
self.head = node_ref.next.clone();
// 값을 반환합니다
node_ref.value
})
}
}
fn main() {
let mut list = LinkedList::new();
list.push_front(1);
list.push_front(2);
list.push_front(3);
println!("{:?}", list.pop_front()); // Some(3)
println!("{:?}", list.pop_front()); // Some(2)
println!("{:?}", list.pop_front()); // Some(1)
println!("{:?}", list.pop_front()); // None
} 이 구현은 Rc를 사용하여 노드를 공유하고, RefCell을 사용하여 내부 가변성을 제공합니다. 각 메서드가 소유권 규칙을 따르면서도 필요한 작업을 수행할 수 있음을 볼 수 있습니다.
성능 고려사항
Rust의 메모리 안전성이 런타임 비용 없이 제공된다는 점은 매우 중요합니다. 소유권 검사, 빌림 검사, 수명 검사는 모두 컴파일 타임에 이루어지므로 실행 중에는 아무런 오버헤드가 없습니다.
참조는 일반 포인터와 정확히 같은 크기이며, 역참조 비용도 동일합니다. 스마트 포인터는 약간의 메모리 오버헤드가 있지만, 제공하는 기능을 고려하면 매우 효율적입니다.
Box는 힙 할당 비용만 있고 추가 런타임 비용은 없습니다. Rc는 참조 카운트를 유지하므로 클론과 드롭 시 카운트 업데이트 비용이 있습니다. Arc는 원자적 연산을 사용하므로 Rc보다 약간 느리지만 스레드 안전성을 제공합니다. RefCell은 런타임 빌림 검사 비용이 있지만, 일반적으로 매우 작습니다.
일반적인 실수와 해결 방법
Rust를 배우는 과정에서 흔히 겪는 문제들이 있습니다. 가장 흔한 것은 빌림 검사기와 싸우는 것입니다. 컴파일러가 거절하는 코드를 작성하려고 할 때입니다.
예를 들어 벡터를 순회하면서 수정하려고 하면 에러가 발생합니다.
rust
fn main() {
let mut v = vec![1, 2, 3];
for i in &v {
v.push(*i + 1); // 컴파일 에러!
}
}이는 &v로 불변 빌림을 하는 동안 v.push()로 가변 빌림을 시도하기 때문입니다. 해결 방법은 인덱스를 사용하거나, 새로운 벡터를 만들거나, 수정할 요소들을 먼저 수집하는 것입니다.
rust
fn main() {
let mut v = vec![1, 2, 3];
let len = v.len();
for i in 0..len {
let value = v[i];
v.push(value + 1);
}
println!("{:?}", v); // [1, 2, 3, 2, 3, 4]
}또 다른 흔한 실수는 소유권 이동 후 값을 사용하려는 것입니다. 함수에 값을 전달한 후 그 값을 다시 사용하려고 할 때 발생합니다.
rust
fn print_string(s: String) {
println!("{}", s);
}
fn main() {
let s = String::from("hello");
print_string(s);
println!("{}", s); // 컴파일 에러!
}해결 방법은 참조를 전달하거나, 값을 클론하거나, 함수가 소유권을 반환하도록 하는 것입니다.
rust
fn print_string(s: &String) {
println!("{}", s);
}
fn main() {
let s = String::from("hello");
print_string(&s);
println!("{}", s); // 정상 작동
}마치며
Rust의 메모리 안전성 시스템은 처음에는 복잡해 보일 수 있지만, 근본적으로 매우 논리적이고 일관성 있는 규칙들의 집합입니다. 소유권, 빌림, 수명은 모두 하나의 목표를 향합니다. 바로 메모리 안전성 버그를 컴파일 타임에 제거하는 것입니다.
이 시스템을 이해하고 나면 버그 없는 안전한 코드를 작성하는 것이 자연스러워집니다. 컴파일러가 잠재적 문제를 미리 잡아주므로, 런타임에 예상치 못한 크래시나 보안 취약점을 겪을 일이 크게 줄어듭니다.
더 나아가 Rust의 메모리 모델은 동시성 프로그래밍을 훨씬 안전하게 만들어줍니다. “두려움 없는 동시성”이라는 Rust의 약속은 소유권 시스템이 있기에 가능합니다.
처음 Rust를 배울 때 빌림 검사기와 씨름하게 될 수 있습니다. 이는 정상적인 학습 과정입니다. 컴파일러 에러 메시지를 주의 깊게 읽고, 왜 그 코드가 안전하지 않은지 이해하려 노력하세요. 시간이 지나면 Rust의 사고방식이 자연스러워지고, 더 안전하고 효율적인 코드를 작성하는 능력이 크게 향상될 것입니다.
Rust는 시스템 프로그래밍에 혁명을 일으키고 있습니다. 운영체제, 웹 브라우저, 게임 엔진, 데이터베이스, 네트워크 서비스 등 성능과 안전성이 모두 중요한 분야에서 Rust가 점점 더 많이 사용되고 있습니다. 이 모든 것의 기반이 바로 우리가 살펴본 메모리 안전성 시스템입니다.
이 가이드가 Rust의 메모리 안전성을 이해하는 데 도움이 되었기를 바랍니다. 직접 코드를 작성하면서 실험해보는 것이 가장 좋은 학습 방법입니다. 컴파일러와 친구가 되어 함께 더 나은 소프트웨어를 만들어가세요.
