C++ Move 시맨틱스 | '복사 vs 이동' 완벽 이해
이 글의 핵심
C++ Move 시맨틱스: "복사 vs 이동" 완벽 이해. Lvalue vs Rvalue·복사 vs 이동.
들어가며
Move 시맨틱스는 C++11에서 도입된 기능으로, 객체의 리소스를 복사하지 않고 이동할 수 있게 합니다. 복사 비용이 큰 객체(벡터, 문자열 등)를 효율적으로 전달할 수 있습니다.
왜 필요한가?:
- 성능: 복사 대신 이동으로 성능 향상
- 소유권 이전: 리소스 소유권을 명시적으로 이전
- 복사 불가 타입:
unique_ptr같은 복사 불가 타입 전달 - 임시 객체 최적화: 임시 객체의 리소스 재사용
C/C++ 예제 코드입니다.
// ❌ 복사: 느림 (깊은 복사)
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
기본 개념
C/C++ 예제 코드입니다.
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 참조
C/C++ 예제 코드입니다.
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
소멸자
소멸자
이동 (효율적)
print 함수의 구현 예제입니다.
#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);
}
심화: rvalue 참조 축소(Reference collapsing)
rvalue 참조 축소는 “참조가 두 겹 쌓였을 때 최종적으로 어떤 한 겹 참조가 되는가”를 정하는 언어 규칙입니다. C++11 이후 T&&가 템플릿·별칭·일부 문맥에서 어떻게 실제 매개변수의 값 범주(lvalue / rvalue)와 결합하는지 이해하려면 이 규칙이 필수입니다.
네 가지 축소 규칙
임의의 타입 T에 대해, 다음과 같이 정리됩니다(가장 오른이 “바깥” 참조에 해당한다고 읽어도 됩니다).
| 결합 | 결과 |
|---|---|
T& & | T& |
T& && | T& |
T&& & | T& |
T&& && | T&& |
한 줄로 기억하면 “lvalue 참조(&)가 하나라도 끼면 최종은 lvalue 참조(T&), 둘 다 rvalue 참조(&&)일 때만 T&&”입니다. 이 규칙은 typedef/using 별칭, decltype에 의해 만들어진 참조 타입, 템플릿 인자 치환 등에서 동일하게 적용됩니다.
왜 std::move는 “항상 rvalue 참조”로 보이게 만드는가
앞 절의 std::move는 remove_reference로 참조를 한 겹 벗긴 뒤 static_cast<U&&>만 합니다. 여기서 최종 타입이 U&&가 되는 것은 “이름 있는 객체를 rvalue로 대우하라”는 표식이며, 축소 규칙과 함께 벌어지는 일입니다. 예를 들어 템플릿 매개변수가 T&로 추론된 경우, T&&가 아니라 T&로 남는 것이 축소의 직접적인 결과입니다.
템플릿에서의 T&&는 “항상 rvalue 참조”가 아니다
아래처럼 타입이 템플릿 매개변수로 주어지고, 그 타입 추론이 일어나는 T&&는 전달 참조(forwarding reference)라고 부릅니다(구 표기: universal reference).
template <typename T>
void foo(T&& arg); // T가 추론되면 전달 참조 — rvalue 참조만이 아님
template <typename T>
class Bar {
void baz(T&& arg); // T가 고정된 클래스 매개변수면 일반 rvalue 참조
};
foo(x)처럼 lvalue를 넘기면T는int&처럼 참조로 추론되고,T&&는int& &&→int&로 축소되어arg는 lvalue 참조가 됩니다.foo(42)처럼 rvalue를 넘기면T는int,T&&는int&&로 남습니다.
즉 같은 T&& 문법이라도 “추론되는 T&&”인지 “고정된 T의 T&&”인지에 따라 의미가 갈립니다. 이 구분 없이 move 시맨틱스를 논하면 현장에서 설명이 자주 어긋납니다.
심화: 완벽 전달(Perfect forwarding) 메커니즘
완벽 전달은 호출자가 넘긴 인자의 값 범주(lvalue / rvalue)와 constness를 잃지 않으며, 내부에서 다른 함수(또는 생성자)로 그대로 넘기는 기법입니다. 이동 시맨틱스와 직결되는 이유는, “임시를 살려 이동 생성자까지 보낼 것인가, 이름 있는 객체를 복사 생성자로 보낼 것인가”를 한 번의 템플릿으로 표현할 수 있기 때문입니다.
std::forward의 역할: std::move와 대비
std::move(T&): 무조건 rvalue로 캐스팅 → 이동 후보로 만듦(소유권 이전 의도가 분명할 때).std::forward<T>(arg): 전달 참조로 받은arg가 원래 lvalue였으면 lvalue로, rvalue였으면 rvalue로 유지합니다. 구현은 대개 “조건부static_cast”이며, 인자를 두 번 넘기는 중간 계층에서 값 범주를 보존하는 데 사용됩니다.
#include <utility>
#include <iostream>
void sink(int&) { std::cout << "lvalue\n"; }
void sink(int&&) { std::cout << "rvalue\n"; }
template <typename T>
void wrap(T&& arg) {
sink(std::forward<T>(arg)); // 호출자가 넘긴 범주 유지
}
int main() {
int x = 1;
wrap(x); // T: int& → forward 후 lvalue
wrap(2); // T: int → forward 후 rvalue
}
팩토리에서의 전형적 패턴
template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
여기서 Args&&...는 각 인자에 대해 전달 참조이으며, forward로 해당 인자가 원래 임시였는지가 T의 이동/복사 생성자 선택에 반영됩니다. std::move(args)...만 쓰면 lvalue 인자까지 전부 rvalue로 바꿔버려 의도와 다른 오버로드가 선택될 수 있습니다.
컴파일러 관점에서 한 줄
전달 참조 + 참조 축소로 T가 추론되고, std::forward<T>가 그 추론 결과에 맞춰 참조 타입 한 겹을 복원해 줍니다. 완벽 전달은 “문법 설탕”이 아니라 오버로드 결정과 이동/복사 분기를 템플릿 한 줄로 합성하는 메커니즘입니다.
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
심화: 이동 전용 타입(Move-only type) 설계
이동 전용 타입은 복사 생성자와 복사 대입 연산자를 삭제하거나 비가시적으로 막아 두며, 이동만 허용하는 타입입니다. std::unique_ptr, std::thread, std::ofstream 등이 대표적입니다. “소유권이 하나뿐인 리소스”를 표현할 때 복사 대신 이동만으로 의미를 유지하려는 설계입니다.
선언 패턴
struct Handle {
Handle() = default;
~Handle() = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&&) noexcept = default;
Handle& operator=(Handle&&) noexcept = default;
};
- 복사 삭제: 동일 리소스를 둘이 나누어 갖는 모델을 컴파일 타임에 차단합니다.
- 이동 기본: 멤버가 이동 가능하면
= default로 충분한 경우가 많습니다. 수동으로 소유 포인터를 옮길 때는 Rule of Five를 이동 쪽에만 맞춥니다.
API 설계에서의 이점
- 함수가 소유권 이전만 표현하면 됨:
void consume(std::unique_ptr<Foo> p)— 호출자는std::move로 의도를 드러냄. - 컨테이너에 넣을 때 복사 비용 없이 이동으로만 처리(복사 불가 타입은 이동 또는 emplace).
- 다만 복사가 필요한 값 의미 모델과는 맞지 않으므로, “공유”가 필요하면
shared_ptr·내부 공유 버퍼 등 별도 모델을 택해야 합니다.
이동만으로 충분한지 검토할 질문
- 두 인스턴스가 동시에 같은 외부 리소스를 가리키면 안 되는가? → 이동 전용 후보.
- 값 복제가 도메인 의미인가? → 복사 허용 필요.
- 예외 안전과 noexcept 이동 요구가 있는가? →
vector재할당 등과 연계해 검토.
심화: 이동 대입과 자기 대입(Self-assignment)
복사 대입에서는 if (this == &other) 검사가 상식이지만, 이동 대입에서도 self-move는 현실적으로 발생합니다. 예: v = std::move(v), 또는 같은 객체를 참조하는 참조가 꼬인 경우.
왜 위험한가
이동 대입을 다음처럼 쓴다고 합니다(개념 코드).
Resource& operator=(Resource&& other) noexcept {
delete data; // 1. 기존 리소스 해제
data = other.data; // 2. 상대방 포인터를 가져옴
other.data = nullptr; // 3. 상대를 비움
return *this;
}
other가 곧 *this이면, 1번에서 자기 메모리를 먼저 해제해 버리고 2번에서 이미 해제된 포인터를 읽는 형태가 될 수 있어 정의되지 않은 동작입니다. 따라서 이동 대입에도 자기 대입 가드가 필요합니다.
표준 라이브러리의 메시지
std::vector 등 많은 표준 타입은 a = std::move(a) 이후 유효하지만 값이 미지정일 수 있다고 문서화합니다. 즉 “반드시 비어 있다”는 보장은 없으며, 소멸·재대입은 안전하다는 수준입니다. 사용자 정의 타입은 이보다 강한 불변식을 약속하려면, 자기 이동 대입을 명시적으로 설계해야 합니다.
권장 패턴: 동일 객체면 no-op, 또는 이동 후 std::swap
포인터만 갖는 단순 타입은 if (this != &other) 가드로 같은 객체면 대입 본문을 건너뛰기만 해도 됩니다.
copy-and-swap 스타일로 자기 대입까지 포함해 안전하게 쓰려면 다음처럼 임시로 이동한 뒤 포인터를 맞바꿀 수 있습니다(tmp가 기존 data를 소멸 시 정리).
#include <utility>
// Resource는 int* data; 를 갖는다고 가정
Resource& operator=(Resource&& other) noexcept {
if (this == &other) {
return *this;
}
Resource tmp(std::move(other));
std::swap(data, tmp.data);
return *this;
}
중요한 점은 “이동 대입의 상대가 항상 다른 객체는 아니다”를 전제에 넣는 것입니다.
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;
}
패턴 4: 프로덕션 — Sink 인자와 이동 생성자 연계
API에서 “소유권을 받는다”는 의도를 분명히 하려면 값으로 받고 멤버에 이동하는 패턴이 자주 사용됩니다. 호출자는 필요 시 std::move로 비용을 한 번만 지불하며, 구현부는 복사/이동 오버로드를 나누지 않아도 됩니다.
class Session {
std::string label_;
public:
explicit Session(std::string label) : label_(std::move(label)) {}
};
컨테이너에 넣을 때는 emplace로 임시 생성까지 합치면 추가 이동 한 번을 줄일 수 있습니다.
패턴 5: 프로덕션 — noexcept 이동과 vector 재할당
std::vector는 재할당 시 요소를 이동할 수 있으면 이동을 택합니다. 이때 이동 생성자/이동 대입이 noexcept가 아니면 강한 예외 안전을 위해 복사로 되돌아가는 구현이 흔합니다. 즉 프로덕션에서 이동이 “실제로 호출되지 않는” 이유가 여기에 있습니다. 이동 연산은 예외를 던지지 않는다는 계약을 가능하면 noexcept로 밝히는 것이 실무적으로 중요합니다.
패턴 6: 프로덕션 — 이동 후 상태와 std::exchange
이동 후 객체를 명시적으로 비우고 싶을 때 멤버 단위로 std::exchange를 쓰면, “옛 값 가져오기 + 상대를 안전한 값으로 설정”을 한 번에 쓸 수 있습니다.
#include <utility>
// 개념 예: 핸들 이동
struct Owned {
int* p = nullptr;
Owned(Owned&& o) noexcept : p(std::exchange(o.p, nullptr)) {}
};
문서화할 때는 “moved-from 객체에 어떤 연산을 허용하는가”(소멸만? 재대입 가능?)를 팀 규약으로 정하는 것이 좋습니다.
패턴 7: 프로덕션 — 예외 안전한 업데이트(copy-and-swap)
강한 예외 보장이 필요한 대입에서는 임시를 만들고 swap으로 커밋하는 방식이 여전히 유효합니다. 이동 시맨틱스 시대에는 이동 생성자로 임시를 만든 뒤 스왑하는 형태로, 대형 버퍼를 한 번의 이동으로 옮기고 기존 상태는 임시 소멸에 맡깁니다.
패턴 8: 프로덕션 — 로깅·디버그에서의 주의
릴리스 빌드가 아닐 때 이동 생성자에 로그를 넣으면 호출 빈도가 드러나 유용하지만, 이동이 예외를 던지는 것처럼 보이게 만드는 실수(숨은 할당 등)는 피해야 합니다. 또한 이동 후 객체를 읽는 어설션은 “미지정 상태” 전제를 깨지 않도록, 검사 대상을 불변식의 최소 집합으로 제한하는 편이 안전합니다.
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&&로 임시 객체 바인딩 — 단, 템플릿 추론 시 전달 참조와 참조 축소로T&로 남을 수 있음 - std::move: rvalue로 캐스팅 (실제 이동 아님)
- 완벽 전달:
std::forward로 인자의 값 범주를 보존해 이동/복사 오버로드를 올바르게 선택 - 이동 전용 타입: 복사 삭제 + 이동으로 소유권 단일성을 컴파일 타임에 강제
- 이동 대입: 자기 이동 대입(
x = std::move(x))을 대비해 가드 또는 copy-and-swap - 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++ Init Capture | “초기화 캡처” 가이드
관련 글
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Everything about C++ Move 시맨틱스 : from basic concepts to practical applications.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, move, rvalue, 이동시맨틱스, 성능 등으로 검색하시면 이 글이 도움이 됩니다.