learn

Rust의 Copy와 Move 구분

Rust의 Copy와 Move 구분

*"Scalar는 복사되고 compound는 이동한다"*는 직관은 절반만 맞다. 진짜 기준은 Copy trait 구현 여부이고, 그 안을 들여다보면 힙 자원 보유 여부가 결정한다.

진짜 기준: Copy trait

Rust의 모든 타입은 두 부류로 나뉜다.

  • Copy trait을 구현한 타입: 대입/전달 시 복사됨. 원본은 그대로 사용 가능.
  • 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 (모든 정수, 부동소수)
  • bool
  • char
  • &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냐?"*를 판단하는 진짜 기준은 다음 셋이다.

  1. Stack에만 사는가? (힙 자원을 들고 있지 않은가?)
  2. 모든 구성 요소가 Copy인가?
  3. Drop을 구현하지 않았는가? (Rust는 DropCopy를 동시에 구현 못 하게 막음 — drop 로직이 있는데 막 복사되면 곤란하니까)

이 셋을 만족하면 Copy가 될 수 있다. (자동으로 되는 건 아니고, #[derive(Copy)]를 명시해야 함.)

정확한 멘탈 모델

힙 자원을 가진 타입 = move (소유권 이동) Stack에만 사는 타입 = copy (값 복사)

타입힙 자원?Copy?동작
i32, bool, char복사
(i32, bool)복사 (compound지만!)
[i32; 100]복사 (compound지만!)
&T복사
&mut Tmove (빌림 규칙 때문)
Stringmove
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가 아니라 Copy trait 구현 여부다. 힙 자원을 가지면 비-Copy (move), **stack에만 살고 모든 구성요소가 CopyCopy (복사)**다. Tuple/array/struct of Copy 같은 compound도 Copy가 될 수 있고, &mut T처럼 scalar에 가깝지만 Copy가 아닌 것도 있다.