Rust 학습 노트 (3) - Rust는 매 순간이 소유권 분쟁이다.

Rust 학습 노트 (3) - Rust는 매 순간이 소유권 분쟁이다.

태그
Rust
최종 수정일
Last updated May 7, 2026
Slug
rust-study-3
Date
May 6, 2026
Published
Published
notion image
이 글은 let a = b;라는 한 줄을 Rust 컴파일러가 막아선 경험을 정리합니다. 이전 글에서 다룬 컴파일러의 친절함이 더 깊은 곳에서 어떻게 작동하는지, 변수와 값의 관계를 다시 들여다본 기록입니다. Rust 학습 노트 시리즈의 세 번째 글이며, 이번에는 소유권 중에서도 이동(move)에 집중합니다.
이전 글에서는 Rust 컴파일러가 "값이 있는가, 없는가" 같은 가능성을 타입으로 끌어올린다는 점을 보았습니다. 이번에는 한 층 아래로 내려갑니다. 변수에 값을 할당하는, 가장 기본적인 동작에서부터 컴파일러의 친절함이 작동하기 시작합니다.

let a = b가 부른 잔소리

실습 중에 만난 코드는 단순했습니다.
다음 코드는 문자열을 한 변수에서 다른 변수로 옮기고, 다시 원래 변수를 사용해보려 한 예시입니다.
let s1 = String::from("hello"); let s2 = s1; println!("{}", s1);
세 줄짜리 코드였습니다. 그런데 컴파일러는 경고와 에러를 한꺼번에 쏟아냈습니다.
warning: unused variable: `s2` --> main.rs:3:9 | 3 | let s2 = s1; | ^^ help: if this is intentional, prefix it with an underscore: `_s2` error[E0382]: borrow of moved value: `s1` --> main.rs:4:20 | 2 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, | which does not implement the `Copy` trait 3 | let s2 = s1; | -- value moved here 4 | println!("{}", s1); | ^^ value borrowed here after move | help: consider cloning the value if the performance cost is acceptable | 3 | let s2 = s1.clone(); | ++++++++
세 가지 단어가 낯설었습니다. move, Copy trait, borrow. s1은 분명 첫 줄에서 멀쩡하게 살아 있었는데, 두 번째 줄을 지나는 순간 "move가 일어났다"며 사라진 것처럼 다뤄졌습니다. 거기에 clone()이라는 해결책까지 친절하게 제안하고 있었습니다.
위쪽의 경고도 결이 같습니다. s2를 만들기만 하고 어디에도 쓰지 않았으니, "쓸 거면 쓰고 그게 아니면 _s2로 적어 의도를 분명히 하라"고 알려줍니다. 이전 글에서 다룬 let mut의 잔소리와 같은 종류, 즉 표면의 친절함입니다. 이 글에서는 그보다 한 층 깊은 곳, 에러 쪽으로 들어갑니다.
JavaScript나 TypeScript에서 같은 일을 해본 적이 없으니 이 에러 메시지가 무엇을 말하는지 가늠이 가지 않았습니다. 이 글은 그 메시지를 한 단어씩 풀어간 기록입니다.

TypeScript에서는 일어나지 않는 일

같은 흐름을 TypeScript로 옮겨보면 아무 일도 일어나지 않습니다.
다음 코드는 위와 동일한 의도의 TypeScript 코드입니다.
const s1 = "hello"; const s2 = s1; console.log(s1); // "hello"
실행도 되고, s1도 멀쩡합니다. const a = b로 할당한 뒤 b를 사용하는 일은 너무 평범해서, 거기에 무슨 결정이 있다는 생각조차 하지 않았습니다.
차이의 출발점은 변수가 무엇인지에 대한 두 언어의 정의가 다르다는 점입니다. JavaScript에서 변수는 값을 가리키는 라벨에 가깝습니다. s2 = s1s1이 가리키던 곳을 s2도 함께 가리키게 만드는 동작입니다. 둘은 같은 값을 동시에 참조합니다. 그래서 두 변수 모두 멀쩡합니다.
Rust에서 변수는 라벨이 아니라 주인입니다. 변수가 값을 가지고 있다는 표현이 단순한 비유가 아니라 실제 책임 관계를 의미합니다. 그리고 그 책임은 한 번에 한 명에게만 속합니다. let s2 = s1;이 실행되는 순간 책임이 s1에서 s2로 넘어가고, s1은 더 이상 그 값을 책임질 자격이 없는 상태가 됩니다.
이 차이가 왜 생겼는지를 이해하려면, 메모리까지 한 층 아래로 내려가야 합니다.

누가 메모리를 해제할 것인가

String::from("hello")가 만들어내는 값은 stack에 다 담기지 않습니다.
String은 길이가 변할 수 있는 문자열입니다. 컴파일 시점에는 이 문자열이 다섯 글자가 될지, 50글자가 될지 알 수 없습니다. 그래서 실제 문자 데이터는 heap이라는 별도 메모리 영역에 저장됩니다. 변수 s1이 stack에서 들고 있는 것은 그 heap 위치를 가리키는 포인터, 길이, 그리고 할당된 용량 같은 메타 정보뿐입니다.
stack: heap: s1 ┌──────────┐ ┌──────┐ │ ptr │ ────────────► │ hello│ │ len: 5 │ └──────┘ │ cap: 5 │ └──────────┘
이 구조에서 가장 중요한 질문이 하나 있습니다. 누가 heap의 메모리를 해제할 것인가.
C/C++은 이 책임을 개발자에게 통째로 넘깁니다. malloc으로 잡았다면 free로 풀어야 하고, 그 짝을 맞추는 일은 사람의 몫입니다. 잊으면 메모리 누수가 나고, 두 번 풀면 프로그램이 죽습니다. JavaScript와 Java는 정반대로 갑니다. 가비지 컬렉터(garbage collector)라는 런타임 도구가 사용 중이지 않은 메모리를 자동으로 회수합니다. 편하지만 런타임에 GC가 도는 비용이 따라옵니다.
Rust는 제3의 길을 택했습니다. 메모리는 그것을 소유한 변수가 scope를 벗어나는 순간 자동으로 해제됩니다. GC도 없고, 개발자가 free를 호출하지도 않습니다. 변수의 수명이 곧 메모리의 수명입니다.
이 약속이 성립하려면 한 가지 조건이 필요합니다. 메모리에는 주인이 한 명뿐이어야 합니다. 만약 s1s2가 같은 heap 메모리를 공동으로 소유한다면, s1이 scope를 벗어날 때 한 번 해제되고, s2가 scope를 벗어날 때 또 한 번 해제됩니다. 같은 메모리를 두 번 해제하는 동작(double free)은 정의되지 않은 동작이며, 보안 취약점의 단골 원인입니다.
그래서 Rust는 let s2 = s1;을 만났을 때 두 변수가 같은 heap 메모리를 공유하게 두지 않습니다. 책임을 s1에서 s2로 통째로 옮기고, s1은 그 메모리에 더 이상 접근할 수 없는 상태로 만들어둡니다. 이것이 컴파일러가 "move occurs"라고 적은 동작입니다.

move는 책임의 이전을 의미한다.

이제 처음의 에러 메시지를 다시 읽어볼 수 있습니다.
let s2 = s1; -- value moved here
s2 = s1이라는 코드는 값을 복제하는 동작이 아닙니다. heap에 담긴 "hello"라는 문자 데이터는 그대로 있습니다. 옮겨가는 것은 그 데이터에 대한 소유권, 즉 책임입니다. 책임이 옮겨갔으니 이전 주인인 s1은 더 이상 그 데이터에 접근할 자격이 없습니다.
println!("{}", s1); ^^ value borrowed here after move
세 번째 줄에서 s1을 사용하려는 시도는 이미 책임이 떠난 변수를 다시 들여다보려는 행동입니다. 컴파일러가 막아선 이유가 여기 있습니다. 만약 이것을 허용했다면, s2가 scope를 벗어날 때 heap이 해제된 뒤 s1을 통해 그 해제된 메모리를 들여다보는 use-after-free가 발생할 수 있습니다.
같은 일은 함수 호출에서도 일어납니다.
다음 코드는 함수에 String을 넘긴 뒤 다시 사용하려 한 예시입니다.
fn print_message(s: String) { println!("{}", s); } let message = String::from("hello"); print_message(message); println!("{}", message); // 컴파일 에러
print_message(message)라는 호출은 message의 소유권을 함수의 매개변수 s에게 넘기는 동작입니다. 함수가 끝나면 s가 scope를 벗어나면서 heap이 해제됩니다. 그래서 함수 호출 이후의 message는 이미 책임을 잃은 변수가 됩니다.
이 동작은 처음에는 불편하게 느껴졌습니다. 함수에 값을 넘긴 뒤 호출자가 그 값을 다시 쓰지 못한다는 규칙은, 다른 언어에서는 당연했던 패턴을 막아섭니다. 그래서 Rust에는 이 불편을 풀기 위한 또 다른 장치가 있는데, 그것이 빌려쓰기(borrow)입니다. 다음 글의 주제입니다.

Copy 타입은 왜 예외인가

여기서 한 가지 의문이 남습니다. 똑같이 let a = b;인데 어떤 코드는 통과하고 어떤 코드는 막힙니다.
다음 코드는 정수에 대해 같은 패턴을 쓴 예시입니다.
let x = 5; let y = x; println!("{}", x); // 컴파일 통과, 5
--> main.rs:6:9 | 6 | let y = x; | ^ help: if this is intentional, prefix it with an underscore: `_y` | = note: `#[warn(unused_variables)]` on by default
String에서는 막혔던 코드가 i32에서는 멀쩡합니다. 물론 “_y로 써라”라는 경고는 줍니다. 두 변수가 같은 값에 동시에 접근하는 것이 허용된 셈입니다. 컴파일러가 일관성이 없는 것이 아닙니다. 이 차이를 설명하는 것이 Copy trait입니다.
i32처럼 stack에 전부 담기는 단순한 값은 Copy라는 표시가 붙어 있습니다. Copy가 붙은 타입에 대해서는 let y = x;가 책임을 옮기는 동작이 아니라, 비트 패턴을 그대로 복제하는 동작이 됩니다. 두 변수는 각자 독립된 4바이트를 가지고, 둘 다 멀쩡히 살아남습니다. heap을 다루지 않으므로 누가 메모리를 해제할 것인가라는 질문 자체가 발생하지 않습니다.
String에는 Copy가 붙어 있지 않습니다. 첫 에러 메시지에 적혀 있던 그 문장입니다.
move occurs because `s1` has type `String`, which does not implement the `Copy` trait
이 한 줄이 모든 것을 설명합니다. String은 heap을 가리키므로, 비트 패턴만 복제하면 두 변수가 같은 heap을 가리키게 됩니다. 그것을 허용하는 순간 double free의 위험이 열립니다. 그래서 String에는 Copy를 붙일 수 없고, let s2 = s1;은 복제가 아니라 이동이 되어야 합니다.
Copy가 붙는 타입과 안 붙는 타입의 경계는 결국 같은 질문으로 돌아옵니다. 비트 패턴만 복제했을 때 안전한가, 아니면 누가 책임지고 정리할 것인지를 정해야 하는가. heap을 가리키는 모든 타입은 후자입니다.
그렇다면 String처럼 Copy가 붙지 않은 타입을 굳이 두 변수가 동시에 들고 있어야 한다면 어떻게 할까요. 처음 에러 메시지의 마지막 줄이 그 답을 미리 적어두고 있었습니다.
help: consider cloning the value if the performance cost is acceptable | 3 | let s2 = s1.clone(); | ++++++++
clone()은 heap에 담긴 데이터를 통째로 복제해 새로운 heap 영역을 잡아주는 메서드입니다. 비트 패턴을 그대로 베끼는 Copy와 달리, clone()은 새 메모리를 할당하고 그곳에 데이터를 옮겨 적습니다. 그래서 s1s2는 각자 독립된 heap을 가지게 되고, 둘 다 자기 메모리를 책임집니다. double free가 일어날 일이 없으므로 컴파일러도 통과시킵니다.
다만 clone()은 명시적으로 호출해야 합니다. 컴파일러가 권하긴 하지만 자동으로 끼워넣지는 않습니다. heap 할당과 복제는 비용이 따르는 동작이고, 그 비용을 치를지 말지는 작성자가 결정해야 한다고 보기 때문입니다. 도움말 메시지에 적힌 "if the performance cost is acceptable"이 그 뉘앙스를 그대로 담고 있습니다.
이전 글에서 "Rust 컴파일러는 빠져나갈 구멍을 막는 정직함으로 친절하다"고 적었는데, 이 메시지는 그 친절함의 또 다른 면을 보여줍니다. 막기만 하는 것이 아니라 해결책의 후보까지 제시하되, 그 후보를 받아들일지 말지는 작성자에게 맡깁니다.

마무리 — 변수의 주인은 단 한 명

let a = b;라는 한 줄이 부른 잔소리의 정체는 이렇게 정리됩니다. Rust에서 변수는 값을 가리키는 라벨이 아니라 값을 책임지는 주인이며, heap에 담긴 데이터의 주인은 한 번에 한 명뿐이어야 합니다. 주인이 둘이 되면 같은 메모리를 두 번 해제하게 되고, 그것을 막기 위해 컴파일러는 let a = b;를 책임의 이전으로 다룹니다. 책임을 잃은 변수에 다시 접근하려는 시도는 컴파일 시점에서 차단됩니다.
이전 글의 마무리에서 던진 두 질문 중 첫 번째, "이 값을 지금 누가 쓰고 있는가"의 답이 이 글에 있었습니다. 답은 단순합니다. 한 번에 한 명이 씁니다. 그리고 다음 변수에게 책임을 넘기는 순간 이전 변수는 그 값을 다시 들여다볼 수 없습니다.
이 규칙만으로 Rust 코드를 짜기는 답답합니다. 함수에 값을 한 번 넘기면 호출자는 다시 그 값을 쓸 수 없습니다. 그러나, 두 변수가 같은 데이터를 동시에 들여다볼 일은 일반적으로 너무나 필요합니다. 그래서 Rust는 소유권을 옮기지 않고 잠시 빌려쓰는 방법을 마련해두었습니다. 다음 학습 노트에서는 그 빌려쓰기, 즉 borrow로 넘어가보려 합니다.

참고 자료