Rust의 Copy와 Move 구분
*"Scalar는 복사되고 compound는 이동한다"*는 직관은 절반만 맞다. 진짜 기준은 Copy trait 구현 여부이고, 그 안을 들여다보면 힙 자원 보유 여부가 결정한다.
진짜 기준: Copy trait
Rust의 모든 타입은 두 부류로 나뉜다.
Copytrait을 구현한 타입: 대입/전달 시 복사됨. 원본은 그대로 사용 가능.Copy가 아닌 타입: 대입/전달 시 소유권 이동(move). 원본은 더 이상 사용 불가.
// Copy 타입
let x = 5;
let y = x;
println!("{}", x); // ✅ OK. x는 여전히 유효.
// 비-Copy 타입
let s = String::from("hello");
let t = s;
println!("{}", s); // ❌ 컴파일 에러. s의 소유권이 t로 이동했음.
Scalar type이 거의 다 Copy인 이유
Scalar type은 stack에 있는 작은 고정 크기 데이터라서 Copy다.
i32,i64,u32,u64,f32,f64,usize,isize(모든 정수, 부동소수)boolchar&T(불변 참조)
i32 하나는 4바이트인데, 통째로 복사하는 비용이 거의 0이다. 굳이 소유권을 추적할 이유가 없다.
힙 자원을 가진 타입이 비-Copy인 이유
힙 메모리를 가지고 있는 타입을 Copy로 만들면, 단순 복사 시에 두 변수가 같은 힙 메모리를 가리키게 된다. 그러면 어느 쪽이 그걸 해제해야 할까? 둘 다 해제하면 stack-vs-heap-in-ownership|double free다. 그래서 Rust는 *"힙을 가진 건 소유권이 한 명에게만 있다"*고 강제한다.
힙 자원을 가진 타입들:
String(힙에 문자 데이터)Vec<T>(힙에 요소들)Box<T>(힙에 T)HashMap,BTreeMap
"Scalar vs Compound" 분류가 깨지는 경우
여기가 핵심이다. Compound type 중에도 Copy인 게 있고, scalar에 가까운 것 중에도 Copy가 아닌 게 있다.
Compound지만 Copy인 것들
Tuple — 모든 원소가 Copy면 tuple도 Copy
let t = (1, 2, 3); // (i32, i32, i32) — 모두 Copy → tuple도 Copy
let u = t;
println!("{:?}", t); // ✅ OK
Array — 원소가 Copy면 array도 Copy
let arr = [1, 2, 3, 4, 5]; // [i32; 5] — Copy
let arr2 = arr;
println!("{:?}", arr); // ✅ OK
Struct — 모든 필드가 Copy이고 #[derive(Copy, Clone)]을 붙이면 Copy
#[derive(Copy, Clone)]
struct Point { x: i32, y: i32 }
let p = Point { x: 1, y: 2 };
let q = p;
println!("{}", p.x); // ✅ OK
이 셋은 모두 compound type이지만 Copy다. stack에만 있고, **모든 구성 요소가 Copy**라서 통째로 복사해도 안전하다.
Scalar지만 Copy가 아닌 것
거의 없지만 한 가지 있다: mutable reference &mut T.
let mut x = 5;
let r1 = &mut x;
let r2 = r1; // r1의 소유권이 r2로 이동
println!("{}", r1); // ❌ borrow of moved value
&mut T는 단순 포인터지만 Copy가 아니다. *"mutable 참조는 한 번에 하나만 존재해야 한다"*는 borrowing-rules|빌림 규칙을 지키기 위해서다. Copy로 만들면 mutable 참조가 무한 복제되어 규칙이 깨진다.
Copy의 진짜 판단 기준
*"이 타입이 Copy냐?"*를 판단하는 진짜 기준은 다음 셋이다.
- Stack에만 사는가? (힙 자원을 들고 있지 않은가?)
- 모든 구성 요소가
Copy인가? Drop을 구현하지 않았는가? (Rust는Drop과Copy를 동시에 구현 못 하게 막음 — drop 로직이 있는데 막 복사되면 곤란하니까)
이 셋을 만족하면 Copy가 될 수 있다. (자동으로 되는 건 아니고, #[derive(Copy)]를 명시해야 함.)
정확한 멘탈 모델
힙 자원을 가진 타입 = move (소유권 이동) Stack에만 사는 타입 = copy (값 복사)
| 타입 | 힙 자원? | Copy? | 동작 |
|---|---|---|---|
i32, bool, char | ❌ | ✅ | 복사 |
(i32, bool) | ❌ | ✅ | 복사 (compound지만!) |
[i32; 100] | ❌ | ✅ | 복사 (compound지만!) |
&T | ❌ | ✅ | 복사 |
&mut T | ❌ | ❌ | move (빌림 규칙 때문) |
String | ✅ | ❌ | move |
Vec<T> | ✅ | ❌ | move |
Box<T> | ✅ | ❌ | move |
(String, i32) | ✅ (안에 String 있음) | ❌ | move |
마지막 줄이 중요하다. **tuple 안에 String이 하나라도 있으면 그 tuple 전체가 비-Copy**가 된다. "가장 약한 고리가 전체를 결정한다."
Move의 실제 메커니즘
*"소유권이 이동했다"*가 메모리 차원에서 무슨 의미인지 보면:
let s1 = String::from("hello");
let s2 = s1;
s1의 stack 데이터(ptr + len + cap, 24바이트)가s2로 그대로 복사됨s1은 *"무효(invalid)"*로 표시되어 컴파일 타임에 사용 불가
즉, move는 단순한 비트 복사 + 컴파일러의 무효 표시일 뿐이다. 런타임 비용이 거의 0이다. 이게 *"zero-cost"*인 이유다. C++의 std::move와 비슷한데, Rust는 컴파일러가 강제로 무효화를 막아준다는 차이가 있다.
왜 이렇게 설계했는가
소유권 시스템의 핵심은 *"힙 자원의 수명을 컴파일러가 추적한다"*이다. 그러려면 힙 자원을 가진 타입은 반드시 소유자가 명확해야 하고, 그래서 Copy가 될 수 없다.
반면 stack에만 있는 작은 데이터는 *"누가 소유하든 상관없음"*이다. 어차피 스코프가 끝나면 자동으로 사라지고, 힙 해제 같은 처리도 필요 없다. 그래서 굳이 소유권을 추적하지 않아도 되고, 그게 Copy라는 형태로 표현된다.
**
Copy인지 아닌지는 "이 타입이 힙 자원 관리를 신경 써야 하는가?"의 답이다. Scalar/compound는 그 답과 상관관계가 있지만 완벽히 일치하지는 않는다.
자가 점검 코드
다음 코드들이 컴파일되는지 머릿속으로 판단:
// (1)
let a = 5;
let b = a;
println!("{}", a);
// (2)
let a = (1, 2, 3);
let b = a;
println!("{:?}", a);
// (3)
let a = (1, String::from("hi"));
let b = a;
println!("{:?}", a);
// (4)
let a = [1; 1000];
let b = a;
println!("{}", a[0]);
답: (1) ✅, (2) ✅, (3) ❌, (4) ✅
(3)이 핵심이다. tuple이지만 String이 들어 있어 Copy가 아니라 move가 일어난다. (4)는 1000개짜리 array지만 i32 array라 Copy라서 통째로 복사된다(큰 array를 함부로 복사하면 성능 이슈가 될 수 있음).
한 줄 요약
진짜 기준은 scalar vs compound가 아니라
Copytrait 구현 여부다. 힙 자원을 가지면 비-Copy(move), **stack에만 살고 모든 구성요소가Copy면Copy(복사)**다. Tuple/array/struct ofCopy같은 compound도Copy가 될 수 있고,&mut T처럼 scalar에 가깝지만Copy가 아닌 것도 있다.