4. 소유권: Rust를 Rust답게 만드는 규칙
Rust를 처음 배우면 대개 여기서 한 번 멈칫합니다. 변수도 알고, 함수도 알겠고, if와 loop도 익숙한데 갑자기 컴파일러가 "이 값은 이미 이동했습니다"라고 말하기 시작합니다. 처음엔 낯설지만, 사실 이 장이 Rust의 핵심입니다. 소유권을 이해하지 못하면 Rust는 계속 까다롭게 느껴지고, 소유권을 이해하면 이후의 참조, 슬라이스, 구조체, 에러 처리까지 한꺼번에 훨씬 쉬워집니다.1
이 장에서는 소유권이 무엇인지, 왜 필요한지, 그리고 move, clone, drop이 어떤 역할을 하는지 차근차근 살펴보겠습니다.

왜 Rust는 소유권이라는 규칙을 만들었을까
프로그래밍 언어는 메모리를 어떻게 관리할지 반드시 결정해야 합니다. 어떤 언어는 개발자가 직접 메모리를 해제하게 하고, 어떤 언어는 가비지 컬렉터가 실행 중에 알아서 정리합니다. Rust는 그 둘과 다른 길을 택했습니다. 컴파일할 때 규칙을 검사해서, 실행 중 추가 비용 없이 메모리 안전성을 확보하는 방식입니다.14
이 방식이 노리는 문제는 아주 현실적입니다.
이미 해제한 메모리를 다시 사용하는 실수
같은 메모리를 두 번 해제하는 실수
어디선가 값이 사라졌는데 여전히 접근하려는 실수
Rust는 이런 문제를 "실행하다가 운 나쁘면 터지는 버그"로 두지 않고, 아예 컴파일 단계에서 막아 버립니다. 그래서 처음엔 엄격해 보여도, 익숙해지면 오히려 든든합니다. 마치 코드를 쓰는 동안 아주 꼼꼼한 동료가 옆에서 계속 확인해 주는 느낌입니다.
소유권의 세 가지 규칙
Rust의 소유권 규칙은 아주 짧게 요약할 수 있습니다.12
Rust의 모든 값에는 소유자(owner)가 있다.
한 시점에 소유자는 오직 하나뿐이다.
소유자가 스코프를 벗어나면 값은 버려진다(
drop).
문장은 짧지만 영향은 큽니다. 이 세 규칙만으로 Rust는 많은 메모리 오류를 막아 냅니다.
먼저 스코프부터 이해하자
소유권은 스코프(scope)와 함께 생각해야 합니다. 스코프는 값이 유효한 범위입니다.
fn main() {
{
let message = String::from("hello");
println!("{}", message);
}
// 여기서는 message를 사용할 수 없다.
}중괄호 안에서 message가 만들어지고, 그 블록이 끝나면 message도 끝납니다. 중요한 점은 단순히 "변수를 못 쓴다"가 아닙니다. String이 사용하던 자원도 함께 정리된다는 것입니다.1
이때 Rust는 자동으로 drop을 호출해 메모리를 정리합니다. 개발자가 일일이 free() 같은 함수를 직접 호출하지 않아도 됩니다. 하지만 그렇다고 가비지 컬렉터가 뒤에서 돌아가는 것도 아닙니다. 정리 시점이 스코프로 명확하게 결정되기 때문입니다.4
왜 i32는 괜찮고 String은 조심해야 할까
초보자가 가장 많이 헷갈리는 부분은 이것입니다.
fn main() {
let x = 10;
let y = x;
println!("x = {}, y = {}", x, y);
}이 코드는 잘 됩니다. 그런데 아래 코드는 다릅니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
// println!("{}", s1); // 오류
println!("{}", s2);
}왜 이런 차이가 날까요?
i32 같은 정수 타입은 크기가 작고 고정되어 있어서 복사하기 쉽습니다. 그래서 let y = x;를 하면 x의 값이 그대로 복사됩니다. 반면 String은 실제 문자열 데이터를 힙(heap)에 저장합니다. String 변수 자체에는 그 데이터를 가리키는 정보가 들어 있습니다. 이런 값을 단순히 복사해 두 변수가 모두 같은 자원을 "자기 것"이라고 여기게 만들면, 나중에 둘 다 메모리를 해제하려 들 수 있습니다. 그러면 큰일입니다.2
Rust는 그 위험을 막기 위해 String 같은 타입에서 기본 동작을 "복사"가 아니라 "이동(move)"으로 정합니다.2
move: 소유권이 옮겨 가는 순간
move는 말 그대로 소유권 이전입니다. 값이 다른 변수로 넘어가면, 이전 변수는 더 이상 그 값을 사용할 수 없습니다.2
fn main() {
let s1 = String::from("rust");
let s2 = s1;
println!("{}", s2);
// println!("{}", s1); // 이미 이동되었으므로 사용 불가
}여기서 중요한 포인트는 "문자열 전체를 매번 통째로 복사해서 옮긴다"가 아니라는 점입니다. 보통은 문자열을 관리하는 정보가 새 변수로 넘어가고, 이제 그 자원의 주인은 s2 하나만 남습니다.2
이 규칙 덕분에 Rust는 다음과 같은 위험을 원천 차단합니다.
두 변수가 같은 힙 데이터를 동시에 소유하는 상황
이미 정리된 메모리를 다시 읽는 상황
누가 자원을 해제해야 하는지 불명확한 상황
즉, move는 불편하게 보이지만 사실은 메모리 안전성을 위한 안전벨트입니다.
함수에 값을 넘길 때도 소유권은 움직인다
대입문에서만 move가 일어나는 것은 아닙니다. 함수를 호출할 때도 마찬가지입니다.12
fn main() {
let name = String::from("Rust");
print_name(name);
// println!("{}", name); // 소유권이 함수로 이동해서 사용 불가
}
fn print_name(value: String) {
println!("name = {}", value);
}name의 소유권은 print_name의 매개변수 value로 이동합니다. 그리고 함수가 끝나면 value도 스코프를 벗어나므로 값이 정리됩니다.
반면 정수처럼 복사 가능한 타입은 다르게 동작합니다.
fn main() {
let n = 42;
print_number(n);
println!("n = {}", n);
}
fn print_number(value: i32) {
println!("value = {}", value);
}여기서는 n이 이동되지 않고 복사되므로 호출 뒤에도 계속 사용할 수 있습니다.
초보자 기준으로 지금은 이렇게 기억하면 충분합니다.
String,Vec,Box처럼 힙 데이터를 다루는 타입은 기본적으로 이동을 조심한다.i32,bool,char처럼 작고 단순한 값은 보통 복사된다.
함수에서 값을 반환해도 소유권이 이동한다
함수는 소유권을 받아 갈 수도 있고, 다시 돌려줄 수도 있습니다.
fn main() {
let s = make_message();
println!("{}", s);
}
fn make_message() -> String {
let msg = String::from("hello");
msg
}msg의 소유권은 반환과 함께 호출한 쪽으로 이동합니다. 그래서 main의 s가 그 값을 새로 소유하게 됩니다.1
이 흐름을 이해하면 "Rust에서는 값이 어디로 흘러가는가"를 따라가는 습관이 생깁니다. 이 습관이 정말 중요합니다. Rust 코드를 읽을 때는 단순히 값만 보는 것이 아니라, 지금 누가 이 값을 소유하고 있는가를 같이 봐야 합니다.
clone: 정말 복사본이 필요할 때
가끔은 이동이 아니라 진짜 복사본이 필요합니다. 이럴 때 clone()을 사용합니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}", s1);
println!("s2 = {}", s2);
}이제 s1과 s2는 각각 독립적인 값입니다. 한쪽이 사라져도 다른 쪽은 그대로 남습니다.3
다만 clone()은 공짜가 아닙니다. String의 경우 실제 문자열 데이터까지 새로 복사해야 하므로 비용이 듭니다.3 그래서 Rust는 힙 데이터를 자동으로 마구 복사하지 않고, 개발자가 "여기서는 정말 복제가 필요합니다"라고 명시하도록 설계되어 있습니다.
이 감각은 중요합니다.
그냥 넘겨도 되면
move계속 같이 써야 하면 참조를 고려
정말 독립적인 복사본이 필요하면
clone()
여기서 참조는 다음 장에서 본격적으로 다룹니다. 지금은 clone()이 "이동을 피하는 마법"이 아니라 "비용이 드는 진짜 복사"라는 점만 기억하면 충분합니다.
drop: 값이 스코프를 벗어날 때 일어나는 일
Rust에서 소유자가 스코프를 벗어나면 값은 자동으로 drop됩니다.1 이 말은 메모리만 정리된다는 뜻이 아닙니다. 파일 핸들, 소켓, 락 같은 자원도 같은 방식으로 정리할 수 있습니다. Rust는 "누가 자원을 책임지는가"를 소유권으로 관리하고, 그 책임이 끝나는 시점에 정리 작업을 수행합니다.
fn main() {
let s = String::from("bye");
drop(s);
// println!("{}", s); // 이미 drop 되었으므로 사용 불가
}위 코드처럼 drop(s)를 직접 호출해 더 이른 시점에 값을 정리할 수도 있습니다.3 그리고 한 번 drop된 값은 다시 사용할 수 없습니다. 이것도 역시 "이미 치운 상자를 또 열어 보려는 행동"을 막기 위한 장치입니다.
중요한 점은 Rust가 값을 정확히 한 번만 정리하게 만든다는 것입니다. 이것이 이중 해제(double free)를 막는 핵심입니다.24
메모리 안전성은 이렇게 보장된다
이제 소유권 규칙이 왜 강력한지 연결해서 볼 수 있습니다.
예를 들어 String을 다른 변수에 대입하면 소유권이 이동합니다. 그래서 이전 변수는 사용할 수 없습니다. 이 덕분에 해제된 메모리를 실수로 다시 쓰는 일이 줄어듭니다.24
또한 한 값의 소유자는 하나뿐이므로, 같은 자원을 두 변수가 각각 정리하려는 상황도 막을 수 있습니다. 그래서 이중 해제 문제가 발생하지 않습니다.24
마지막으로 스코프를 벗어날 때 자동으로 drop이 실행되므로, "정리를 깜빡해서 메모리가 새는" 문제도 훨씬 줄어듭니다. 물론 논리적으로 불필요한 데이터를 계속 들고 있으면 메모리를 오래 잡고 있을 수는 있지만, 적어도 자원 해제 시점은 코드 구조 안에서 예측 가능하게 관리됩니다.14
정리하면 Rust의 소유권 시스템은 다음을 가능하게 합니다.
누가 값을 책임지는지 항상 분명하다.
값이 언제 사라지는지 예측할 수 있다.
잘못된 메모리 접근을 컴파일 전에 잡아낸다.
이것이 바로 Rust가 "빠른데도 안전하다"는 평가를 받는 이유입니다.
초보자가 자주 헷갈리는 장면
처음에는 아래 세 가지를 특히 자주 헷갈립니다.
let b = a;가 언제 복사이고 언제 이동인지함수에 값을 넘겼더니 왜 원래 변수를 못 쓰는지
clone()을 써야 할지, 그냥 넘겨야 할지
판단 기준은 의외로 단순합니다. 값이 힙 자원을 소유하고 있다면 기본적으로 이동을 먼저 떠올리면 됩니다. 그리고 복사본이 꼭 필요할 때만 clone()을 고민하세요.
예를 들어 아래 코드는 이동입니다.
let s1 = String::from("abc");
let s2 = s1;반면 아래 코드는 복사입니다.
let x = 7;
let y = x;처음엔 매번 헷갈려도 괜찮습니다. 오히려 컴파일 에러를 통해 감을 빨리 잡게 됩니다. Rust 컴파일러는 대체로 불친절한 심판이 아니라, 엄격하지만 꽤 유능한 코치에 가깝습니다.
손으로 읽어 보는 소유권 흐름
아래 코드를 천천히 따라가 봅시다.
fn main() {
let s1 = String::from("rust");
let s2 = s1;
let s3 = s2.clone();
print_text(s2);
println!("{}", s3);
}
fn print_text(text: String) {
println!("{}", text);
}흐름은 이렇습니다.
먼저 s1이 문자열의 소유자입니다. 다음 줄에서 s2 = s1이 되면서 소유권이 s2로 이동합니다. 이제 s1은 끝입니다. 그다음 s3 = s2.clone()이 실행되면 s2의 독립적인 복사본이 s3로 만들어집니다. 그래서 s2와 s3는 각각 따로 존재합니다. 이후 print_text(s2)를 호출하면 s2의 소유권이 함수로 이동하고, 함수가 끝날 때 text는 drop됩니다. 하지만 s3는 별개의 복사본이므로 마지막 줄에서 정상적으로 출력할 수 있습니다.
이런 식으로 Rust 코드는 "값 자체"보다 "소유권의 흐름"을 따라 읽으면 훨씬 명확해집니다.
이 장의 핵심 정리
소유권은 Rust의 가장 독특한 규칙이지만, 동시에 가장 실용적인 규칙이기도 합니다. 핵심은 복잡하지 않습니다.
값에는 소유자가 하나 있다.
소유권은 대입이나 함수 호출로 이동할 수 있다.
소유자가 사라지면 값도 정리된다.
진짜 복사본이 필요하면
clone()을 쓴다.이 모든 규칙이 메모리 안전성을 컴파일 단계에서 보장해 준다.
지금 당장은 "왜 이렇게까지 엄격하지?"라는 생각이 들 수 있습니다. 하지만 바로 그 엄격함 덕분에, Rust는 실행 중에 잡기 어려운 메모리 버그를 미리 차단합니다. 그리고 다음 장에서 배울 빌림과 참조는, 이 엄격한 소유권 규칙 위에서 코드를 더 유연하게 쓰게 해 주는 도구입니다.
소유권을 이해했다면, 이제 Rust의 문이 드디어 열리기 시작한 것입니다.
참고
1Understanding Ownership - The Rust Programming Language
2Ownership and moves - Rust By Example
3Trait Clone - Rust Standard Library
4Rust Memory Safety: How Ownership & Borrow Checker Actually Work
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.