C++ Move 시맨틱스 | "복사 vs 이동" 완벽 이해
이 글의 핵심
C++ Move 시맨틱스에 대한 실전 가이드입니다.
들어가며
Move 시맨틱스는 C++11에서 도입된 기능으로, 객체의 리소스를 복사하지 않고 이동할 수 있게 합니다. 복사 비용이 큰 객체(벡터, 문자열 등)를 효율적으로 전달할 수 있습니다.
왜 필요한가?:
- 성능: 복사 대신 이동으로 성능 향상
- 소유권 이전: 리소스 소유권을 명시적으로 이전
- 복사 불가 타입:
unique_ptr같은 복사 불가 타입 전달 - 임시 객체 최적화: 임시 객체의 리소스 재사용
// ❌ 복사: 느림 (깊은 복사)
std::vector<int> v1(1000000); // 100만 개 요소 할당
std::vector<int> v2 = v1; // 100만 개 요소 전부 복사
// 동작:
// 1. v2용 새 메모리 할당 (100만 * sizeof(int))
// 2. v1의 모든 요소를 v2로 복사
// 3. v1, v2 모두 독립적인 메모리 소유
// 비용: O(n) 시간, O(n) 메모리
// ✅ 이동: 빠름 (얕은 복사)
std::vector<int> v1(1000000); // 100만 개 요소 할당
std::vector<int> v2 = std::move(v1); // 포인터만 복사 (내부 버퍼 이동)
// 동작:
// 1. v1의 내부 포인터를 v2로 복사 (포인터 3개: data, size, capacity)
// 2. v1의 포인터를 nullptr로 설정 (무효화)
// 3. v2가 v1의 메모리를 소유
// 비용: O(1) 시간, 추가 메모리 없음
1. Lvalue vs Rvalue
기본 개념
int x = 10; // x는 lvalue (이름이 있고, 주소를 가짐)
// 여러 번 참조 가능, 메모리 위치 확정
int y = x + 5; // x+5는 rvalue (임시값, 주소 없음)
// 표현식 평가 후 바로 사라짐, 한 번만 사용
int* ptr = &x; // ✅ OK: lvalue의 주소를 가져올 수 있음
// x는 메모리에 저장되어 있음
// int* ptr2 = &(x+5); // ❌ 에러: rvalue의 주소를 가져올 수 없음
// x+5는 임시 레지스터 값, 메모리 주소 없음
핵심:
- lvalue: 이름이 있고, 여러 번 사용 가능, 주소를 가짐
- rvalue: 임시값, 표현식 끝나면 사라짐, 주소를 가질 수 없음
lvalue와 rvalue 예시
#include <string>
#include <iostream>
std::string getName() {
return "Alice";
}
int main() {
int x = 10; // x: lvalue
int y = x + 5; // x+5: rvalue
int z = std::move(x); // std::move(x): rvalue
std::string s1 = "hello";
std::string s2 = s1; // s1: lvalue
std::string s3 = s1 + " world"; // s1 + " world": rvalue
std::string name = getName(); // getName(): rvalue
return 0;
}
rvalue 참조
int x = 10;
// lvalue 참조 (T&): lvalue만 바인딩 가능
int& ref1 = x; // ✅ OK: x는 lvalue
// int& ref2 = 10; // ❌ 에러: 10은 rvalue (임시값)
// lvalue 참조는 메모리 주소가 있는 값만 가능
// rvalue 참조 (T&&): rvalue만 바인딩 가능
// int&& ref3 = x; // ❌ 에러: x는 lvalue
// rvalue 참조는 임시값만 가능
int&& ref4 = 10; // ✅ OK: 10은 rvalue (임시값)
// rvalue 참조는 임시값의 수명을 연장
// const lvalue 참조 (const T&): 모두 가능 (특별 규칙)
const int& ref5 = x; // ✅ OK: lvalue 바인딩
const int& ref6 = 10; // ✅ OK: rvalue 바인딩 (임시값 수명 연장)
// const 참조는 임시값을 받을 수 있는 유일한 lvalue 참조
2. 복사 vs 이동
복사 (비효율적)
#include <iostream>
#include <cstring>
class String {
private:
char* data;
size_t size;
public:
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
std::cout << "생성자" << std::endl;
}
~String() {
delete[] data;
std::cout << "소멸자" << std::endl;
}
// 복사 생성자
String(const String& other) {
size = other.size;
data = new char[size + 1];
strcpy(data, other.data); // 깊은 복사
std::cout << "복사 생성자" << std::endl;
}
void print() const {
std::cout << data << std::endl;
}
};
int main() {
String s1("Hello");
String s2 = s1; // 복사 발생 (메모리 할당 + 복사)
s2.print();
return 0;
}
출력:
생성자
복사 생성자
Hello
소멸자
소멸자
이동 (효율적)
#include <iostream>
#include <cstring>
class String {
private:
char* data;
size_t size;
public:
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
std::cout << "생성자" << std::endl;
}
~String() {
delete[] data;
std::cout << "소멸자" << std::endl;
}
// 복사 생성자
String(const String& other) {
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
std::cout << "복사 생성자" << std::endl;
}
// 이동 생성자: rvalue 참조(String&&)를 받음
// noexcept: 예외를 던지지 않음을 보장 (성능 최적화)
String(String&& other) noexcept {
// 1. 포인터만 복사 (얕은 복사)
data = other.data; // other의 메모리를 가져옴
size = other.size;
// 2. 원본 무효화 (중요!)
// other의 소멸자가 호출될 때 delete하지 않도록
other.data = nullptr; // 원본 포인터를 nullptr로
other.size = 0;
std::cout << "이동 생성자" << std::endl;
// 결과: 메모리 할당 없음, 포인터만 이동 (매우 빠름)
}
void print() const {
if (data) std::cout << data << std::endl;
else std::cout << "(empty)" << std::endl;
}
};
int main() {
String s1("Hello");
String s2 = std::move(s1); // 이동 (포인터만 복사, 빠름!)
s2.print(); // Hello
s1.print(); // (empty) - s1은 이제 사용 불가
return 0;
}
출력:
생성자
이동 생성자
Hello
(empty)
소멸자
소멸자
3. std::move
기본 사용
#include <utility>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v1 = {1, 2, 3, 4, 5};
// 복사: 모든 요소를 새 메모리에 복사
std::vector<int> v2 = v1; // v1의 모든 요소 복사
// v1과 v2는 독립적인 메모리 소유
std::cout << "v1 크기: " << v1.size() << std::endl; // 5 (유지)
std::cout << "v2 크기: " << v2.size() << std::endl; // 5 (새로 할당)
// 이동: 내부 버퍼 포인터만 이동
// std::move(v1): v1을 rvalue로 캐스팅
// 이동 생성자 호출 → v1의 내부 버퍼를 v3로 이전
std::vector<int> v3 = std::move(v1); // v1의 내부 버퍼를 v3로 이동
// v1은 유효하지만 비어있는 상태 (moved-from state)
std::cout << "v1 크기: " << v1.size() << std::endl; // 0 (비어있음)
std::cout << "v3 크기: " << v3.size() << std::endl; // 5 (v1의 버퍼 소유)
// 주의: v1은 더 이상 사용하면 안됨 (소멸자 호출은 안전)
return 0;
}
주의: std::move는 실제로 이동하지 않고, rvalue로 캐스팅만 합니다!
std::move 구현
// std::move의 개념적 구현
// 실제로는 단순한 캐스팅만 수행 (이동하지 않음!)
template<typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept {
// std::remove_reference<T>::type: T에서 참조 제거
// T가 int&이면 → int
// T가 int&&이면 → int
//
// static_cast<...&&>: rvalue 참조로 캐스팅
// 이 캐스팅이 이동 생성자를 호출하게 만듦
//
// 핵심: std::move는 이동하지 않고, "이동 가능"하다고 표시만 함
// 실제 이동은 이동 생성자/대입 연산자가 수행
return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
4. 5가지 규칙 (Rule of Five)
#include <iostream>
class Resource {
private:
int* data;
public:
// 1. 생성자
Resource(int value) : data(new int(value)) {
std::cout << "생성자: " << *data << std::endl;
}
// 2. 소멸자
~Resource() {
std::cout << "소멸자: " << (data ? std::to_string(*data) : "null") << std::endl;
delete data;
}
// 3. 복사 생성자
Resource(const Resource& other) {
data = new int(*other.data);
std::cout << "복사 생성자: " << *data << std::endl;
}
// 4. 복사 대입 연산자
Resource& operator=(const Resource& other) {
if (this != &other) {
delete data;
data = new int(*other.data);
std::cout << "복사 대입: " << *data << std::endl;
}
return *this;
}
// 5. 이동 생성자
Resource(Resource&& other) noexcept {
data = other.data;
other.data = nullptr;
std::cout << "이동 생성자" << std::endl;
}
// 6. 이동 대입 연산자
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete data;
data = other.data;
other.data = nullptr;
std::cout << "이동 대입" << std::endl;
}
return *this;
}
int get() const { return data ? *data : 0; }
};
int main() {
Resource r1(10);
Resource r2 = r1; // 복사 생성자
Resource r3 = std::move(r1); // 이동 생성자
Resource r4(20);
r4 = r2; // 복사 대입
Resource r5(30);
r5 = std::move(r4); // 이동 대입
return 0;
}
출력:
생성자: 10
복사 생성자: 10
이동 생성자
생성자: 20
복사 대입: 10
생성자: 30
이동 대입
소멸자: 10
소멸자: 10
소멸자: null
소멸자: 10
소멸자: null
5. 실전 예제
예제 1: 벡터 최적화
#include <vector>
#include <iostream>
class BigObject {
private:
std::vector<int> data;
public:
BigObject(int size) : data(size, 0) {
std::cout << "생성자: " << size << "개 요소" << std::endl;
}
BigObject(const BigObject& other) : data(other.data) {
std::cout << "복사 생성자: " << data.size() << "개 요소" << std::endl;
}
BigObject(BigObject&& other) noexcept : data(std::move(other.data)) {
std::cout << "이동 생성자: " << data.size() << "개 요소" << std::endl;
}
};
std::vector<BigObject> createObjects() {
std::vector<BigObject> result;
result.push_back(BigObject(1000)); // 이동 생성자 호출
result.push_back(BigObject(2000));
return result; // RVO 또는 이동
}
int main() {
auto objects = createObjects();
std::cout << "완료" << std::endl;
return 0;
}
예제 2: 스마트 포인터 이동
#include <memory>
#include <iostream>
void process(std::unique_ptr<int> ptr) {
std::cout << "값: " << *ptr << std::endl;
}
int main() {
auto ptr1 = std::make_unique<int>(42);
// process(ptr1); // 에러: unique_ptr은 복사 불가
process(std::move(ptr1)); // OK: 이동
// ptr1은 이제 nullptr
if (!ptr1) {
std::cout << "ptr1은 비어있음" << std::endl;
}
return 0;
}
출력:
값: 42
ptr1은 비어있음
예제 3: 완벽 전달 (Perfect Forwarding)
#include <iostream>
#include <utility>
void process(int& x) {
std::cout << "lvalue 버전: " << x << std::endl;
}
void process(int&& x) {
std::cout << "rvalue 버전: " << x << std::endl;
}
template<typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // 완벽 전달
}
int main() {
int x = 10;
wrapper(x); // lvalue 버전 호출
wrapper(20); // rvalue 버전 호출
return 0;
}
출력:
lvalue 버전: 10
rvalue 버전: 20
6. 자주 발생하는 문제
문제 1: move 후 사용
#include <vector>
#include <iostream>
int main() {
// ❌ 위험한 코드
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);
std::cout << v1.size() << std::endl; // 0 (또는 정의되지 않은 동작)
// v1[0]; // 정의되지 않은 동작!
// ✅ 올바른 코드
std::vector<int> v3 = {4, 5, 6};
std::vector<int> v4 = std::move(v3);
// v3은 더 이상 사용하지 않음
return 0;
}
문제 2: const 객체는 이동 불가
#include <vector>
#include <iostream>
int main() {
// ❌ 이동 안됨
const std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // 복사됨!
std::cout << "v1 크기: " << v1.size() << std::endl; // 3 (여전히 유효)
// ✅ const 없이
std::vector<int> v3 = {1, 2, 3};
std::vector<int> v4 = std::move(v3); // 이동됨
std::cout << "v3 크기: " << v3.size() << std::endl; // 0
return 0;
}
문제 3: 이동 생성자에서 예외
#include <stdexcept>
#include <vector>
// ❌ 위험: noexcept 없음
class Bad {
public:
Bad(Bad&& other) { // noexcept 없음
throw std::runtime_error("에러");
}
};
// ✅ noexcept 추가
class Good {
std::vector<int> data;
public:
Good(Good&& other) noexcept : data(std::move(other.data)) {
// 예외 안던짐
}
};
int main() {
std::vector<Good> v;
v.reserve(10); // noexcept 이동 생성자 사용
return 0;
}
이유: std::vector는 reserve() 시 noexcept 이동 생성자가 있으면 이동, 없으면 복사합니다.
문제 4: 반환 시 std::move 사용
#include <vector>
// ❌ move 사용: RVO 방해
std::vector<int> createVector1() {
std::vector<int> v = {1, 2, 3};
return std::move(v); // RVO 방해
}
// ✅ 그냥 반환: RVO 적용
std::vector<int> createVector2() {
std::vector<int> v = {1, 2, 3};
return v; // RVO
}
int main() {
auto v1 = createVector1();
auto v2 = createVector2();
return 0;
}
7. 성능 비교
#include <chrono>
#include <vector>
#include <iostream>
void testCopy() {
std::vector<int> v1(1000000, 42);
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> v2 = v1; // 복사
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "복사: " << duration.count() << "μs" << std::endl;
}
void testMove() {
std::vector<int> v1(1000000, 42);
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> v2 = std::move(v1); // 이동
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "이동: " << duration.count() << "μs" << std::endl;
}
int main() {
testCopy(); // ~1000μs
testMove(); // ~1μs
return 0;
}
결과:
- 복사: ~1000μs (메모리 할당 + 복사)
- 이동: ~1μs (포인터만 복사)
8. 실무 패턴
패턴 1: 팩토리 함수
#include <vector>
#include <string>
class Database {
std::vector<std::string> data_;
public:
Database(std::vector<std::string> data)
: data_(std::move(data)) {} // 이동
size_t size() const { return data_.size(); }
};
// 팩토리 함수
Database createDatabase() {
std::vector<std::string> data;
data.push_back("record1");
data.push_back("record2");
data.push_back("record3");
return Database(std::move(data)); // 이동
}
int main() {
Database db = createDatabase(); // RVO 또는 이동
return 0;
}
패턴 2: 컨테이너 최적화
#include <vector>
#include <string>
int main() {
std::vector<std::string> names;
// ❌ 복사
std::string name1 = "Alice";
names.push_back(name1); // 복사
// ✅ 이동
std::string name2 = "Bob";
names.push_back(std::move(name2)); // 이동
// ✅ emplace_back (더 좋음)
names.emplace_back("Charlie"); // 직접 생성
return 0;
}
패턴 3: 스왑 최적화
#include <vector>
#include <utility>
class Buffer {
std::vector<char> data_;
public:
Buffer(size_t size) : data_(size) {}
// 이동 기반 스왑
void swap(Buffer& other) noexcept {
data_.swap(other.data_); // O(1)
}
// 또는 std::swap 사용
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
}
};
int main() {
Buffer b1(1000), b2(2000);
swap(b1, b2); // 빠른 이동
return 0;
}
9. 실전 예제: 리소스 관리자
#include <iostream>
#include <vector>
#include <memory>
#include <string>
class ResourceManager {
std::vector<std::unique_ptr<std::string>> resources_;
public:
// 리소스 추가
void add(std::unique_ptr<std::string> resource) {
resources_.push_back(std::move(resource)); // 이동
}
// 리소스 가져오기
std::unique_ptr<std::string> take(size_t index) {
if (index >= resources_.size()) return nullptr;
auto resource = std::move(resources_[index]); // 이동
resources_.erase(resources_.begin() + index);
return resource;
}
// 리소스 개수
size_t count() const {
return resources_.size();
}
// 리소스 출력
void print() const {
std::cout << "Resources (" << resources_.size() << "):" << std::endl;
for (size_t i = 0; i < resources_.size(); ++i) {
if (resources_[i]) {
std::cout << " [" << i << "]: " << *resources_[i] << std::endl;
} else {
std::cout << " [" << i << "]: (moved)" << std::endl;
}
}
}
};
int main() {
ResourceManager mgr;
// 리소스 추가
mgr.add(std::make_unique<std::string>("Resource 1"));
mgr.add(std::make_unique<std::string>("Resource 2"));
mgr.add(std::make_unique<std::string>("Resource 3"));
mgr.print();
// 리소스 가져오기
auto r = mgr.take(1);
std::cout << "\n가져온 리소스: " << *r << std::endl;
std::cout << "\n남은 리소스:" << std::endl;
mgr.print();
return 0;
}
출력:
Resources (3):
[0]: Resource 1
[1]: Resource 2
[2]: Resource 3
가져온 리소스: Resource 2
남은 리소스:
Resources (2):
[0]: Resource 1
[1]: Resource 3
정리
핵심 요약
- Move 시맨틱스: 리소스를 복사 없이 이동
- rvalue 참조:
T&&로 임시 객체 바인딩 - std::move: rvalue로 캐스팅 (실제 이동 아님)
- noexcept: 이동 생성자/대입에 필수
- Rule of Five: 소멸자, 복사, 이동 모두 구현
복사 vs 이동
| 특징 | 복사 | 이동 |
|---|---|---|
| 비용 | 높음 (메모리 할당 + 복사) | 낮음 (포인터만) |
| 원본 | 유지 | 무효화 |
| 문법 | T a = b; | T a = std::move(b); |
| 생성자 | 복사 생성자 | 이동 생성자 |
| 대입 | 복사 대입 | 이동 대입 |
| 용도 | 원본 유지 필요 | 원본 불필요 |
실전 팁
사용 원칙:
- 객체를 더 이상 사용하지 않을 때
- 함수 반환 시 (RVO 안될 때)
- 컨테이너 삽입 시
- 소유권 이전 시 (
unique_ptr)
성능:
- 동적 메모리 객체에서 효과적
- 기본 타입은 효과 없음
- RVO가 move보다 빠름
- 벤치마크로 확인
주의사항:
- move 후 객체 사용 금지
- const 객체는 이동 불가
- 이동 생성자에 noexcept 필수
- 반환 시 std::move 사용 금지
다음 단계
- C++ Rvalue vs Lvalue
- C++ Perfect Forwarding
- C++ Copy Elision
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Rvalue vs Lvalue | “값 범주” 가이드
- C++ 완벽 전달 | “Perfect Forwarding” 가이드
- C++ Init Capture | “초기화 캡처” 가이드
관련 글
- C++ Rvalue vs Lvalue |
- C++ Algorithm Copy |
- C++ std::function vs 함수 포인터 |
- C++ move 에러 |
- C++ RVO·NRVO |