콘텐츠로 이동

Rust 소유권

소유권(ownership)은 Rust를 Rust답게 만드는 핵심 개념이에요. 가비지 컬렉터 없이도, 그리고 개발자가 직접 메모리를 해제하지 않아도 메모리 안전성을 보장하는 방법이 바로 소유권이에요. 처음에는 낯설게 느껴지는 게 당연해요. 누구나 그래요. 하지만 핵심 아이디어는 단순하니까, 천천히 하나씩 쌓아 올려 볼게요.

프로그램이 실행되면 데이터를 메모리에 저장해요. 그 메모리는 언젠가 정리해야 하고, 그러지 않으면 메모리가 새다가(메모리 누수) 결국 프로그램이 멈춰 버려요. 언어들은 보통 이 문제를 두 가지 방법으로 다뤄요.

  • 수동 관리(C/C++): 개발자가 직접 메모리를 해제해요. 깜빡하면 메모리가 새고, 너무 일찍 해제하면 프로그램이 죽거나 보안 버그가 생겨요.
  • 가비지 컬렉터(Java, Go, Python): 뒤에서 돌아가는 프로세스가 대신 정리해 줘요. 편하지만 성능과 예측 가능성을 어느 정도 포기해야 해요.

Rust는 세 번째 길을 택했어요. 소유권에 관한 몇 가지 규칙을 바탕으로, 각 값을 정확히 언제 정리할지 컴파일할 때 결정해요. 직접 해제할 필요도 없고, 가비지 컬렉터도 없어요.

소유권 시스템은 이 세 가지가 전부예요. 나머지는 모두 여기서 나와요.

  1. 각 값에는 소유자(owner)가 하나 있어요. 값을 “소유하는” 변수예요.
  2. 소유자는 한 번에 하나만 있을 수 있어요.
  3. 소유자가 스코프를 벗어나면 값은 자동으로 정리돼요.

규칙 하나하나가 실제로 어떻게 동작하는지 볼게요.

규칙 3: 스코프가 끝나면 값이 해제돼요

섹션 제목: “규칙 3: 스코프가 끝나면 값이 해제돼요”

스코프(scope)는 {와 } 사이의 영역이에요. 변수의 스코프가 끝나면 Rust가 그 값을 자동으로 해제해요. free() 같은 걸 직접 쓸 일이 없어요.

fn main() {
{
let message = String::from("안녕하세요"); // 여기서 message는 유효해요
println!("{message}");
} // <- 스코프가 끝나고, `message`는 자동으로 정리돼요
// println!("{message}"); // ❌ 에러: 여기서는 message가 더 이상 존재하지 않아요
}
▶ 직접 실행해 보기

아무것도 해제하지 않았는데도, message가 스코프를 벗어나는 순간 Rust가 알아서 정리해 줬어요.

이제부터가 재미있는 부분이에요. 변수 하나를 다른 변수에 대입하면 어떤 일이 일어나는지 보세요.

fn main() {
let s1 = String::from("안녕하세요");
let s2 = s1; // 소유권이 s1에서 s2로 이동해요
println!("{s2}"); // ✅ 잘 동작해요
// println!("{s1}"); // ❌ 에러: s1은 이미 이동됐어요
}
▶ 직접 실행해 보기

let s2 = s1이라고 쓰면 Rust는 문자열을 복사하지 않아요. 소유권을 s1에서 s2로 이동(move)시켜요. 규칙 2에 따라 소유자는 하나뿐이어야 하니까, 이제 s1은 쓸 수 없어요. 그래도 쓰려고 하면 이런 에러가 나요.

error[E0382]: borrow of moved value: `s1`

“이미 이동된 값 s1을 대여하려고 한다”는 뜻이에요. 컴파일러 메시지는 영어로 나오지만, 무엇이 언제 이동했는지 정확하게 알려 줘요.

정말로 독립된 복사본이 하나 더 필요하다면 .clone()을 호출하세요.

fn main() {
let s1 = String::from("안녕하세요");
let s2 = s1.clone(); // 완전한 복사본을 만들어요
println!("s1 = {s1}, s2 = {s2}"); // ✅ 이제 둘 다 쓸 수 있어요
}
▶ 직접 실행해 보기

clone()을 굳이 직접 쓰게 만든 데는 이유가 있어요. 복사는 비용이 클 수 있으니까, Rust는 여러분이 명시적으로 요청하도록 해요.

단순한 타입은 이동하지 않고 복사돼요

섹션 제목: “단순한 타입은 이동하지 않고 복사돼요”

그런데 이 코드는 왜 에러가 안 나는지 궁금할 수 있어요.

fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}"); // ✅ 둘 다 쓸 수 있어요
}

정수, 부동소수점 수, 불리언, char처럼 크기가 작고 고정된 값은 복사하는 비용이 아주 적어서, Rust는 이동 대신 그냥 복사해요. 처음 배울 때는 이렇게 기억하면 돼요.

  • 단순한 값(숫자, bool, char) → 복사돼요. 원래 변수도 그대로 쓸 수 있어요.
  • 데이터를 소유하는 값(String 등) → 이동해요. 원래 변수는 더 이상 쓸 수 없어요.

값을 함수에 넘겨도 이동이 일어나요. 변수에 대입할 때와 똑같아요.

fn take_ownership(s: String) {
println!("이제 내 거예요: {s}");
} // 여기서 s가 해제돼요
fn main() {
let text = String::from("안녕하세요");
take_ownership(text);
// println!("{text}"); // ❌ 에러: text는 함수 안으로 이동했어요
}

바로 이 문제 때문에 다음 주제인 대여(borrowing)가 필요해요. 대여를 쓰면 함수가 소유권을 가져가지 않고도 값을 사용할 수 있어서, 호출한 뒤에도 값을 계속 쓸 수 있어요.

먼저 Rust Playground나 내 컴퓨터의 프로젝트에서 직접 풀어 보세요. 중간에 컴파일 에러가 나는 건 자연스러운 일이에요. 에러 메시지를 읽어 보세요. 대부분 이동이 일어난 곳을 정확히 짚어 줘요.

이 프로그램은 컴파일되지 않아요. 두 변수 중 어느 것도 지우지 말고, 초안 -> 초안 v2가 출력되도록 고쳐 보세요.

fn main() {
let original = String::from("초안");
let mut edited = original;
edited.push_str(" v2");
println!("{original} -> {edited}");
}
error[E0382]: borrow of moved value: `original`
--> src/main.rs:5:16
|
2 | let original = String::from("초안");
| -------- move occurs because `original` has type `String`, which does not implement the `Copy` trait
3 | let mut edited = original;
| -------- value moved here
4 | edited.push_str(" v2");
5 | println!("{original} -> {edited}");
| ^^^^^^^^ value borrowed here after move

original은 Copy 트레이트를 구현하지 않은 String이라서 3번째 줄에서 이동했고, 5번째 줄에서 이동한 뒤에 다시 쓰였다는 뜻이에요.

힌트: 하나를 바꿔도 다른 하나에는 영향이 없는, 서로 독립된 문자열 두 개가 필요해요.

정답 보기
fn main() {
let original = String::from("초안");
let mut edited = original.clone(); // 완전히 별개인 복사본
edited.push_str(" v2");
println!("{original} -> {edited}");
}

실행 결과:

초안 -> 초안 v2
▶ 직접 실행해 보기

let mut edited = original;은 문자열을 이동시키기 때문에 original은 더 이상 유효하지 않아요. .clone()은 자기만의 메모리를 가진 두 번째 문자열을 만들어요. 이제 push_str은 edited만 바꾸고, original은 여전히 초안이에요.

String의 소유권을 가져와서 끝에 " -- Ferris"를 붙인 다음, 그 문자열을 돌려주는 함수 add_signature를 작성해 보세요. main에서 호출하고 결과를 출력하세요.

힌트: 함수는 전달받은 값을 그대로 반환할 수 있어요. 시그니처에 -> String을 쓰고, 값을 바꿀 수 있도록 매개변수를 mut text: String으로 받으세요.

정답 보기
fn add_signature(mut text: String) -> String {
text.push_str(" -- Ferris");
text // 소유권이 호출한 쪽으로 다시 이동해요
}
fn main() {
let letter = String::from("곧 만나요!");
let letter = add_signature(letter);
println!("{letter}");
}

실행 결과:

곧 만나요! -- Ferris
▶ 직접 실행해 보기

소유권은 함수를 호출할 때 함수 안으로 이동하고, 반환값과 함께 밖으로 다시 이동해요. 그걸 새 변수 letter로 받았어요(이전 letter를 섀도잉해요). 이렇게 해도 동작은 하지만, 값을 매번 넘겼다가 돌려받는 건 꽤 번거로워요. 바로 이 문제를 대여가 해결해 줘요.

이 프로그램은 컴파일되지 않아요. Ana님이 목록에 들어왔어요를 출력하고, 이어서 ["Ana"]를 출력하도록 고쳐 보세요.

fn main() {
let mut names = Vec::new();
let name = String::from("Ana");
names.push(name);
println!("{name}님이 목록에 들어왔어요");
println!("{:?}", names);
}
error[E0382]: borrow of moved value: `name`
--> src/main.rs:5:16
|
3 | let name = String::from("Ana");
| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
4 | names.push(name);
| ---- value moved here
5 | println!("{name}님이 목록에 들어왔어요");
| ^^^^ value borrowed here after move

힌트: push도 함수 호출처럼 소유권을 가져가요. 4번째 줄이 끝난 뒤 문자열의 주인은 누구일까요? 이동하기 전에 name을 쓰거나, 새 주인에게 물어보세요.

정답 보기
fn main() {
let mut names = Vec::new();
let name = String::from("Ana");
println!("{name}님이 목록에 들어왔어요"); // 아직 소유하고 있을 때 써요
names.push(name); // 이제 벡터가 주인이에요
println!("{:?}", names);
}

실행 결과:

Ana님이 목록에 들어왔어요
["Ana"]
▶ 직접 실행해 보기

names.push(name) 다음부터는 벡터가 주인이라서 name을 더 이상 쓸 수 없어요. 먼저 출력하는 게 한 가지 방법이에요. 원래 순서를 그대로 두고 names[0]을 출력하는 방법도 있어요. 벡터가 아직 그 문자열을 갖고 있으니까요. 복제해도 동작하지만, 여기서는 굳이 복사할 필요가 없어요.

  • 소유권 덕분에 Rust는 가비지 컬렉터 없이도 메모리를 안전하게 관리해요.
  • 세 가지 규칙: 값마다 소유자는 하나, 한 번에 소유자도 하나, 소유자가 스코프를 벗어나면 값이 해제돼요.
  • 데이터를 소유하는 값(String 등)을 대입하면 이동해요. 원래 변수는 더 이상 쓸 수 없어요.
  • 독립된 복사본이 필요하면 .clone()을 쓰세요. 단순한 타입(숫자, bool, char)은 자동으로 복사돼요.
  • 값을 함수에 넘겨도 이동이 일어나요. 그래서 다음 주제인 대여가 필요해요.

이해도 점검

1. Rust에서 하나의 값은 한 번에 몇 개의 소유자를 가질 수 있나요?

2. String을 이동시키지 않고 독립된 복사본을 만들려면 어떤 메서드를 호출하나요?

3. 다른 변수에 대입할 때 이동하지 않고 자동으로 복사되는 타입은 무엇인가요?

4. String을 값으로(참조자가 아니라) 받는 함수에 넘기면 어떻게 되나요?

5. 이 페이지에 따르면 Rust는 언제 값을 자동으로 정리(drop)하나요?

점수: 0 / 5