값 vs 참조 의미론 — 대입하면 복사냐 공유냐 이동이냐
변수를 대입하거나 함수에 넘길 때 값이 복사되는가(copy), 같은 대상을 공유하게 되는가(reference/share), 소유권이 이전되는가(move). C++을 발판으로 Go의 값 복사와 참조적 타입, Rust의 이동·Copy·빌림 차이를 정리한다.
난이도 중급 · 선행 스택/힙, 메모리 관리 모델을 봤으면 더 좋다.
🗺️ 프로그래밍 언어 개념 로드맵의 한 편
한 줄 요약
b = a처럼 대입하거나 함수에 인자를 넘길 때 독립된 값이 생기는지, 같은 대상을 공유하는지, 소유권이 이전되는지가 언어마다 다르다. C++은 복사·참조·이동을 타입과 표현식으로 선택하고, Go는 인자와 대입을 값 복사로 정의하며, Rust는 Copy가 아닌 값에 대해 기본적으로 이동 의미론을 적용한다.
어떤 문제를 푸는가
1
2
3
Buffer a = load();
Buffer b = a;
b.modify();
여기서 궁금한 것은 세 가지다.
1
2
3
b가 a와 독립된 값인가?
같은 내부 자원을 공유하는가?
a의 소유권이 b로 넘어갔는가?
이런 차이를 설명하는 것이 값·참조·이동 의미론이다.
C++에서 출발 — 표현식과 타입이 의미를 정한다
1
2
3
Buffer b = a; // 복사 생성
Buffer& r = a; // 참조 별칭
Buffer c = std::move(a); // rvalue로 캐스팅해 이동 생성자를 선택할 기회를 줌
여기서 중요한 점은 std::move 자체가 소유권을 옮기는 연산은 아니라는 것이다.
1
2
3
4
std::move(a)
→ a를 rvalue로 취급하도록 캐스팅
→ 이동 생성자/이동 대입 연산자가 선택될 수 있음
→ 실제로 어떤 상태가 되는지는 타입의 이동 연산 구현이 결정
따라서 C++의 이동과 Rust의 이동은 비슷한 문제를 다루지만 같은 의미론은 아니다.
Go — 전달과 대입은 값 복사
Go에서 인자 전달과 대입은 값 복사다.
1
2
3
b := a
modify(a)
modify(&a)
a가 일반 struct라면 b := a는 struct 값을 복사한다. 함수 인자도 마찬가지다. 원본을 직접 바꾸려면 포인터를 넘긴다.
하지만 복사되는 값 안에 다른 데이터를 가리키는 핸들이 들어 있을 수 있다. slice·map·channel·pointer가 대표적이다.
1
2
3
slice 값 복사
→ slice header는 복사
→ backing array는 공유될 수 있음
그래서 “Go는 복사 기본”은 맞지만, 복사된 값의 내부가 완전히 독립된다는 뜻은 아니다. 값 의미론과 깊은 복사는 같은 말이 아니다.
Rust — Copy가 아니면 이동
Rust에서 Copy가 아닌 값은 대입이나 함수 전달 시 기본적으로 이동한다.
1
2
3
let a = String::from("hello");
let b = a;
// println!("{a}"); // 컴파일 에러: a는 이동됨
여기서 이동은 C++의 std::move와 역할이 비슷해 보이지만 중요한 차이가 있다.
1
2
3
4
5
6
7
8
9
10
11
C++
std::move(a)
→ rvalue로 캐스팅
→ 이동 생성자 선택 가능
→ a는 여전히 존재하며 post-move 상태는 타입 계약에 따름
Rust
let b = a
→ 언어 의미론상 소유권 이동
→ a 바인딩은 더 이상 사용할 수 없음
→ 컴파일러가 사용을 금지
즉 Rust 이동을 std::move가 자동으로 붙은 것이라고 기억하면 출발점은 잡기 쉽지만, 정확한 정의는 아니다. Rust의 이동은 언어 차원의 소유권 이전이고, C++ std::move는 이동 연산을 허용하기 위한 캐스팅이다.
Copy 트레이트가 구현된 값은 예외적으로 복사된다.
1
2
3
let x = 10;
let y = x;
println!("{x}"); // i32는 Copy라 사용 가능
공유는 어떻게 표현하나
세 언어 모두 “값을 새로 만들지 않고 같은 대상을 본다”는 요구가 있지만 방식이 다르다.
| 언어 | 대표적인 공유 방식 |
|---|---|
| C++ | 참조(T&, const T&), 포인터, 스마트 포인터 |
| Go | 포인터, slice·map·channel 같은 참조적 값을 포함한 핸들 |
| Rust | 빌림(&T, &mut T), 필요하면 Rc/Arc 같은 공유 소유 타입 |
Rust의 &는 단순한 주소 전달만이 아니라 빌림 규칙과 aliasing 제약까지 포함한다.
한눈에 비교
| 대입·전달의 기본 | 원본 상태 | 공유 접근 | |
|---|---|---|---|
| C++ | 타입과 표현식에 따라 복사/이동 | 이동 후에도 객체는 존재, 상태는 타입 계약에 따름 | 참조·포인터·스마트 포인터 |
| Go | 값 복사 | 원본 유지 | 포인터 또는 내부 공유 구조 |
| Rust | Copy면 복사, 아니면 이동 | 이동한 바인딩은 사용 불가 | 빌림(&/&mut) 또는 공유 소유 타입 |
C++을 발판으로 볼 때 주의할 점
C++을 알고 있으면 Go와 Rust를 비교하기 쉽다. 다만 다음처럼 대응 관계와 동일 관계를 구분해야 한다.
1
2
3
4
5
6
7
8
9
10
11
Go 값 전달
≈ C++ pass-by-value와 비슷한 출발점
≠ 항상 깊은 복사
Rust move
≈ C++ 이동 의미론과 같은 문제를 다룸
≠ std::move의 암묵적 삽입
Rust &T / &mut T
≈ C++ 참조와 비슷한 문법적 역할
≠ aliasing 규칙까지 같은 것은 아님
기억 흐름
1
2
3
4
5
6
7
8
9
대입·전달 시 무엇이 일어나는가?
↓
독립된 값 복사?
같은 대상 공유?
소유권 이전?
↓
Go: 값은 복사되지만 내부 공유 가능
C++: 타입·표현식에 따라 복사/참조/이동
Rust: Copy 아니면 이동, 공유는 빌림
스스로 점검
1. Rust의 이동은 C++ std::move와 같은가?
답
아니다. 둘 다 불필요한 복사를 피하고 자원 이전을 다룬다는 점은 비슷하지만, C++ std::move는 rvalue 캐스팅이고 실제 이동은 타입의 이동 연산이 수행한다. Rust의 이동은 언어 차원의 소유권 이전이며 이동한 바인딩의 재사용을 컴파일러가 금지한다.
2. Go에서 값 복사는 항상 깊은 복사인가?
답
아니다. struct 자체는 복사되지만 그 안의 slice·map·pointer 같은 필드가 같은 내부 데이터를 가리킬 수 있다.
3. C++ moved-from 객체는 항상 사용하면 안 되는가?
답
아니다. 객체는 여전히 존재한다. 표준 라이브러리 타입은 특별한 보장이 없으면 보통 valid but unspecified 상태이며, 사용자 타입은 해당 타입이 정의한 postcondition을 따른다.