C++ 복사 생성자와 이동 생성자: 호출 시점, Rule of Five와 Rule of Zero
이 글의 핵심
복사 생성자와 이동 생성자가 각각 언제 호출되는지, 자원을 직접 관리하는 클래스에서 Rule of Five를 지켜야 하는 이유를 정리합니다. 복사 생략(Copy Elision)으로 생성자 호출 자체가 사라지는 경우와 noexcept 누락, 자기 대입 같은 자주 발생하는 문제도 다룹니다.
Rule of Five
“Rule of Five”라는 이름이 붙은 이유는 이 다섯 개(소멸자, 복사 생성자, 복사 대입, 이동 생성자, 이동 대입)가 서로 독립적이지 않기 때문입니다. C++는 클래스가 이 중 하나라도 직접 정의하면 나머지 특수 멤버 함수들의 암묵적 생성 규칙을 바꿉니다 — 예를 들어 소멸자를 직접 정의하는 순간(리소스를 관리한다는 신호이므로) 컴파일러가 만들어주는 기본 복사 생성자는 대부분의 경우 잘못된 동작(포인터만 복사하는 얕은 복사)을 하게 되는데, 이는 “이 클래스가 리소스를 소유하고 있다면 기본 멤버별 복사가 안전하다는 보장이 없다”는 언어 차원의 신호입니다. 이동 생성자와 이동 대입이 C++11에서 추가로 필요해진 이유도 마찬가지입니다 — 복사 생성자를 직접 정의하면 컴파일러는 이동 생성자를 암묵적으로 생성해 주지 않으므로, 이동 시맨틱의 성능 이점(깊은 복사 대신 포인터만 넘기는 O(1) 연산)을 누리려면 명시적으로 작성해야 합니다. 결국 Rule of Five는 “다섯 개 중 하나라도 직접 관리가 필요하다고 판단되면, 다섯 개를 전부 의식적으로 검토하라”는 규칙이지, 다섯 개를 항상 다 써야 한다는 뜻은 아닙니다.
생성 규칙을 조금 더 정확히 정리하면 다음과 같습니다. 소멸자, 복사 생성자, 복사 대입 중 하나라도 사용자가 선언하면 이동 생성자와 이동 대입은 암시적으로 선언되지 않습니다. 반대로 이동 연산 중 하나라도 선언하면 복사 생성자와 복사 대입은 삭제된(deleted) 것으로 정의됩니다. 첫 번째 규칙이 특히 조용한 함정입니다. 디버깅용 로그를 찍으려고 소멸자 하나만 추가했을 뿐인데, 그 순간부터 std::move(obj)가 이동이 아니라 복사로 처리됩니다. 이동 생성자가 없으면 rvalue도 const T&를 받는 복사 생성자에 바인딩될 수 있기 때문에 컴파일 에러도 나지 않습니다. 그래서 특수 멤버를 하나 손댈 때는 나머지를 = default로라도 명시해 두는 것이 안전합니다.
아래 Resource에서 생성자에는 번호를 붙이지 않았습니다. 일반 생성자는 다섯 개에 포함되지 않기 때문입니다. 또 멤버 초기화 리스트는 쓴 순서가 아니라 멤버가 선언된 순서(여기서는 data → size)대로 실행됩니다. 이 예제는 data를 초기화할 때 매개변수 s만 쓰므로 문제가 없지만, data(new int[size])처럼 아직 초기화되지 않은 size 멤버를 읽었다면 쓰레기 값으로 할당했을 것입니다. GCC·Clang의 -Wreorder(-Wall에 포함)가 이 순서 불일치를 경고합니다.
class Resource {
private:
int* data;
size_t size;
public:
// 일반 생성자 (다섯 개에 포함되지 않음)
Resource(size_t s) : size(s), data(new int[s]) {}
// 1. 소멸자
~Resource() {
delete[] data;
}
// 2. 복사 생성자
Resource(const Resource& other)
: size(other.size), data(new int[other.size]) {
copy(other.data, other.data + size, data);
}
// 3. 복사 대입 연산자
Resource& operator=(const Resource& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
copy(other.data, other.data + size, data);
}
return *this;
}
// 4. 이동 생성자
Resource(Resource&& other) noexcept
: size(other.size), data(other.data) {
other.data = nullptr;
other.size = 0;
}
// 5. 이동 대입 연산자
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
복사 생성자
복사 생성자가 필요한 근본 이유는 data가 힙에 할당된 원시 포인터라는 데 있습니다. 컴파일러가 자동으로 만들어주는 기본 복사 생성자는 멤버별로 값을 그대로 복사하는데, 포인터 멤버의 경우 “가리키는 주소값”만 복사되고 그 주소가 가리키는 메모리 블록 자체는 복사되지 않습니다. 그 결과 s1과 s2가 같은 data 블록을 가리키게 되고, 둘 중 하나가 먼저 소멸되어 delete[]를 호출하면 나머지 하나는 이미 해제된 메모리를 가리키는 댕글링 포인터가 됩니다 — 이후 그 객체가 소멸될 때 같은 메모리를 다시 해제하려 시도하면서 double free가 발생합니다. 아래 String의 복사 생성자가 new char[]로 별도 메모리를 할당하고 strcpy로 내용을 복사하는 것은 바로 이 문제, 즉 “얕은 복사”를 “깊은 복사”로 바꾸기 위함입니다.
// 타입 정의
class String {
private:
char* data;
size_t length;
public:
String(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
// 복사 생성자
String(const String& other) {
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
cout << "복사 생성자 호출" << endl;
}
~String() {
delete[] data;
}
};
int main() {
String s1("Hello");
String s2 = s1; // 복사 생성자 호출
String s3(s1); // 복사 생성자 호출
}
String s2 = s1;은 = 기호가 있지만 대입이 아니라 복사 초기화이므로 복사 대입 연산자가 아니라 복사 생성자가 호출됩니다. 대입 연산자는 이미 만들어진 객체에 값을 덮어쓸 때(s2 = s1;처럼 선언 없이 쓸 때)만 호출됩니다. 이 예제의 String은 설명을 위해 복사 생성자만 정의했기 때문에, s2 = s1;을 추가하면 컴파일러가 만든 복사 대입이 포인터만 복사하고 s2가 원래 갖고 있던 버퍼는 누수되며 소멸 시 이중 해제가 납니다. 복사 생성자를 직접 써야 하는 클래스라면 복사 대입과 소멸자도 함께 필요하다는 “Rule of Three”가 바로 이 상황을 가리킵니다.
이동 생성자
복사 생성자가 “완전한 사본”을 만든다면, 이동 생성자는 “소유권 이전”을 합니다 — other.data를 새 객체가 그대로 넘겨받고, other.data는 nullptr로 만들어 두 객체가 같은 메모리를 이중으로 소유하지 않도록 합니다. 이 연산이 빠른 이유는 명확합니다: 실제 데이터를 한 바이트도 복사하지 않고 포인터 값 하나만 옮기기 때문에, 데이터 크기와 무관하게 O(1)입니다. other.data = nullptr이 반드시 필요한 이유도 짚을 만합니다 — 이 줄을 빠뜨리면 other가 소멸될 때 이미 새 객체가 소유하게 된 바로 그 메모리를 또 delete[]하게 되어, 이동 생성자를 쓴 의도와 정반대로 double free가 발생합니다. move(s1)을 호출한 뒤 s1을 “비어있지만 유효한 상태”로 남겨두는 것이 이동 시맨틱의 계약이며, s1.data를 다시 역참조하지 않는 한 안전합니다.
class String {
private:
char* data;
size_t length;
public:
String(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
// 이동 생성자
String(String&& other) noexcept {
data = other.data;
length = other.length;
other.data = nullptr;
other.length = 0;
cout << "이동 생성자 호출" << endl;
}
~String() {
delete[] data;
}
};
int main() {
String s1("Hello");
String s2 = move(s1); // 이동 생성자 호출
// s1은 이제 비어있음
}
실전 예시
아래 세 예시는 리소스 관리 클래스가 실무에서 취하는 세 가지 전형적인 정책을 보여줍니다: 복사와 이동을 모두 지원하는 값 타입(DynamicArray), 복사는 의미가 없어 금지하고 이동만 허용하는 독점 리소스(FileHandle), 그리고 표준 라이브러리의 unique_ptr가 실제로 어떤 원리로 동작하는지 보여주는 최소 구현(UniquePtr)입니다.
동적 배열: 복사와 이동을 모두 지원하는 값 타입
std::vector를 직접 구현한다고 생각하면 이해가 쉽습니다. 복사 생성자·대입은 size만큼의 요소를 새로 할당한 메모리에 깊은 복사하므로 O(n)이고, 이동 생성자·대입은 포인터 세 개(data, size, capacity)만 옮기므로 O(1)입니다. arr2 = arr1(복사)과 arr3 = move(arr1)(이동)의 실행 비용 차이는 요소 개수가 커질수록 극적으로 벌어지며, 이것이 바로 “불필요한 복사를 피하고 이동을 활용하라”는 현대 C++ 성능 조언의 근거입니다. 컨테이너에 값을 담아 함수 반환값으로 넘기거나 다른 컨테이너로 옮길 때, 컴파일러가 이동 생성자를 쓸 수 있는지(즉 이동 생성자가 존재하고 noexcept인지)가 실질적인 성능 차이를 만듭니다.
template<typename T>
class DynamicArray {
private:
T* data;
size_t size;
size_t capacity;
public:
DynamicArray(size_t cap = 10)
: size(0), capacity(cap), data(new T[cap]) {}
~DynamicArray() {
delete[] data;
}
// 복사 생성자
DynamicArray(const DynamicArray& other)
: size(other.size), capacity(other.capacity),
data(new T[other.capacity]) {
copy(other.data, other.data + size, data);
}
// 복사 대입
DynamicArray& operator=(const DynamicArray& other) {
if (this != &other) {
delete[] data;
size = other.size;
capacity = other.capacity;
data = new T[capacity];
copy(other.data, other.data + size, data);
}
return *this;
}
// 이동 생성자
DynamicArray(DynamicArray&& other) noexcept
: size(other.size), capacity(other.capacity), data(other.data) {
other.data = nullptr;
other.size = 0;
other.capacity = 0;
}
// 이동 대입
DynamicArray& operator=(DynamicArray&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
capacity = other.capacity;
other.data = nullptr;
other.size = 0;
other.capacity = 0;
}
return *this;
}
void push_back(const T& value) {
if (size == capacity) {
resize();
}
data[size++] = value;
}
T& operator[](size_t index) {
return data[index];
}
size_t getSize() const {
return size;
}
private:
void resize() {
capacity *= 2;
T* newData = new T[capacity];
copy(data, data + size, newData);
delete[] data;
data = newData;
}
};
int main() {
DynamicArray<int> arr1;
arr1.push_back(1);
arr1.push_back(2);
DynamicArray<int> arr2 = arr1; // 복사
DynamicArray<int> arr3 = move(arr1); // 이동
}
이 구현은 교육용으로 단순화되어 있어 실제 std::vector와 몇 가지가 다릅니다. new T[cap]은 용량만큼 T를 기본 생성하므로, 기본 생성자가 없는 타입은 담을 수 없고 무거운 타입이면 쓰지도 않을 객체를 미리 만들어 둡니다. 실제 vector는 원시 메모리를 할당해 두고 push_back 때 placement new로 하나씩 생성합니다. 또 resize()가 copy로 원소를 옮기기 때문에, 원소 타입의 이동 생성자가 있어도 재할당 때는 항상 복사가 일어납니다. 이 부분을 std::move(data, data + size, newData)로 바꾸면 이동을 쓰게 되지만, 그러면 옮기는 도중 예외가 났을 때 원본이 이미 일부 비워져 있어 되돌릴 수 없습니다. 표준 vector가 move_if_noexcept로 “이동이 예외를 던지지 않을 때만 이동한다”는 절충을 택한 이유가 이것이며, 아래 noexcept 절과 이어지는 이야기입니다.
파일 핸들러: 복사 금지, 이동만 허용
FILE*은 복사할 수 없는 자원입니다 — 두 FileHandle 객체가 같은 FILE*을 각자 fclose하려 들면 둘 중 하나는 이미 닫힌 파일 포인터에 접근하게 됩니다. 그렇다고 깊은 복사(파일을 다시 열어 새 핸들 발급)를 시도할 수도 있지만, 그건 “복사”의 일반적인 기대(같은 상태의 독립적인 사본)와 다른 의미를 가지므로 오히려 혼란을 낳습니다. 그래서 = delete로 복사 자체를 컴파일 타임 에러로 만들어 “이 타입은 복사라는 개념이 없다”는 것을 타입 시스템 수준에서 강제하고, 대신 이동만 허용해 소유권이 한 곳에만 존재하도록 보장합니다. 이 패턴 — 복사 금지 + 이동 허용 — 은 파일, 소켓, 뮤텍스, GPU 핸들처럼 “복사에 의미가 없는 단일 소유 리소스”를 감싸는 모든 RAII 래퍼의 표준 설계입니다.
class FileHandle {
private:
FILE* file;
string filename;
public:
FileHandle(const string& name) : filename(name) {
file = fopen(name.c_str(), "r");
if (!file) {
throw runtime_error("파일 열기 실패");
}
}
~FileHandle() {
if (file) {
fclose(file);
}
}
// 복사 금지
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// 이동 허용
FileHandle(FileHandle&& other) noexcept
: file(other.file), filename(move(other.filename)) {
other.file = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (file) {
fclose(file);
}
file = other.file;
filename = move(other.filename);
other.file = nullptr;
}
return *this;
}
string read() {
if (!file) return "";
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
string content(size, '\0');
fread(&content[0], 1, size, file);
return content;
}
};
int main() {
FileHandle fh1("test.txt");
// FileHandle fh2 = fh1; // 에러: 복사 금지
FileHandle fh2 = move(fh1); // OK: 이동
}
간단한 스마트 포인터
std::unique_ptr가 내부적으로 하는 일이 정확히 이것입니다: 복사 금지 + 이동 허용이라는 앞의 FileHandle 패턴을, “파일”이 아니라 “임의의 힙 포인터 하나”에 일반화한 것입니다. release()가 있는 이유도 실전에서 중요합니다 — 소유권을 포기하되 메모리는 해제하지 않고 원시 포인터로 돌려받아야 하는 경우(예: C API에 소유권을 넘길 때)를 위한 탈출구입니다. 이 최소 구현이 실제 std::unique_ptr와 다른 점은 커스텀 삭제자(Deleter) 지원, 배열 특수화, reset()·swap()·explicit operator bool, 파생 클래스 포인터에서의 변환 이동 등이며, 핵심 소유권 이전 메커니즘 자체는 동일합니다 — 즉 표준 라이브러리의 스마트 포인터가 “마법”이 아니라 여기서 다룬 이동 생성자/대입 패턴의 정제된 버전이라는 것을 이 예시가 보여줍니다.
template<typename T>
class UniquePtr {
private:
T* ptr;
public:
explicit UniquePtr(T* p = nullptr) : ptr(p) {}
~UniquePtr() {
delete ptr;
}
// 복사 금지
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
// 이동 허용
UniquePtr(UniquePtr&& other) noexcept : ptr(other.ptr) {
other.ptr = nullptr;
}
UniquePtr& operator=(UniquePtr&& other) noexcept {
if (this != &other) {
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
}
return *this;
}
T& operator*() const {
return *ptr;
}
T* operator->() const {
return ptr;
}
T* get() const {
return ptr;
}
T* release() {
T* temp = ptr;
ptr = nullptr;
return temp;
}
};
int main() {
UniquePtr<int> p1(new int(42));
// UniquePtr<int> p2 = p1; // 에러: 복사 금지
UniquePtr<int> p2 = move(p1); // OK: 이동
cout << *p2 << endl; // 42
}
복사 생략 (Copy Elision)
createString()이 지역 변수 String("Hello")를 반환할 때, 이론적으로는 임시 객체를 만들고 그것을 다시 s로 복사(또는 이동)해야 할 것 같지만, C++17부터는 이런 형태의 반환값 최적화(RVO)가 표준에 의해 보장됩니다 — 컴파일러의 선택 사항이 아니라 언어 규칙입니다. 이 말은 String의 복사 생성자와 이동 생성자가 모두 이 경우 단 한 번도 호출되지 않는다는 뜻이며, 심지어 그 생성자들에 cout으로 로그를 찍어 확인해 봐도 아무것도 출력되지 않습니다. C++17 이전(C++11/14)에서는 같은 코드가 컴파일러의 최적화(비보장 RVO)에 의존했으므로, 디버그 빌드에서는 실제로 이동 생성자가 호출되는 것을 볼 수도 있었습니다 — 이것이 “Rule of Five를 지켰는데 왜 최적화 여부에 따라 동작이 달라 보이는가”라는 흔한 혼란의 원인이었습니다.
String createString() {
return String("Hello"); // 복사/이동 생략 가능
}
int main() {
String s = createString(); // 복사/이동 없음 (C++17)
}
자주 발생하는 문제
얕은 복사
이것이 앞서 “복사 생성자” 절에서 설명한 문제의 실제 결과 화면입니다. BadString은 복사 생성자를 직접 정의하지 않았으므로 컴파일러가 기본 복사 생성자(포인터를 값 그대로 복사)를 생성하고, s2 = s1 시점에는 겉보기에 정상적으로 동작하는 것처럼 보입니다 — 컴파일 에러도, 즉각적인 크래시도 없습니다. 문제는 두 객체 중 하나가 스코프를 벗어나 소멸자가 호출되는 시점에 터집니다. 이런 지연된 실패가 얕은 복사 버그를 특히 찾기 어렵게 만드는데, 크래시 지점(소멸자, 또는 그보다 나중에 댕글링 포인터를 역참조하는 코드)이 실제 원인(복사 생성자 부재)과 코드상 멀리 떨어져 있기 때문입니다. 원시 포인터 멤버가 있는 클래스를 작성할 때는 “복사 생성자를 정의했는가”를 항상 먼저 확인하는 습관이 필요합니다.
// ❌ 얕은 복사 (기본 복사 생성자)
class BadString {
private:
char* data;
public:
BadString(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
~BadString() {
delete[] data;
}
// 기본 복사 생성자: 포인터만 복사
};
BadString s1("Hello");
BadString s2 = s1; // 같은 메모리 가리킴
// 소멸 시 double free!
// ✅ 깊은 복사
class GoodString {
private:
char* data;
public:
GoodString(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
GoodString(const GoodString& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
~GoodString() {
delete[] data;
}
};
자기 대입과 예외 안전성
obj = obj 같은 코드를 직접 쓰는 경우는 드물지만, 포인터나 참조를 통해 간접적으로 자기 자신을 대입하는 상황(*ptr1 = *ptr2인데 ptr1 == ptr2인 경우, 또는 컨테이너 내부에서 원소를 재정렬하다 같은 원소를 대입하는 경우)은 실제로 발생합니다. this != &other 검사 없이 delete[] data를 먼저 실행하면, other가 사실 *this와 같은 객체이므로 other.data 역시 이미 해제된 포인터가 되어 그 뒤의 new int[size]와 copy(other.data, ...)가 이미 해제된 메모리를 읽으려 시도합니다. 이 검사 하나가 빠지면 자기 대입이라는 드문 경로에서만 발생하는 use-after-free 버그가 생기는데, 일반적인 테스트 케이스에서는 잘 드러나지 않아 프로덕션에서야 발견되는 경우가 많습니다.
// ❌ 자기 대입 미처리
Resource& operator=(const Resource& other) {
delete[] data; // 자기 대입 시 문제!
size = other.size;
data = new int[size];
copy(other.data, other.data + size, data);
return *this;
}
// ✅ 자기 대입 체크
Resource& operator=(const Resource& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
copy(other.data, other.data + size, data);
}
return *this;
}
자기 대입 검사를 넣은 두 번째 버전에도 아직 구멍이 하나 남아 있습니다. delete[] data를 먼저 하고 new int[size]를 나중에 하므로, new가 std::bad_alloc을 던지면 data는 이미 해제된 메모리를 가리킨 채 남습니다. 예외를 잡아 계속 실행하더라도 이 객체가 소멸할 때 같은 메모리를 다시 delete[]하게 됩니다. 해결책은 새 자원을 먼저 확보하고, 성공한 뒤에 옛 자원을 버리는 순서로 바꾸는 것이고, 그 순서를 가장 간결하게 표현하는 방법이 copy-and-swap 관용구입니다.
// ✅ copy-and-swap: 자기 대입 검사 없이도 안전하고, 강한 예외 보장
friend void swap(Resource& a, Resource& b) noexcept {
using std::swap;
swap(a.data, b.data);
swap(a.size, b.size);
}
// 기존 복사 대입·이동 대입 두 개를 이 하나로 대체한다
Resource& operator=(Resource other) { // 값으로 받음: 복사(또는 이동)가 여기서 일어남
swap(*this, other); // 실패할 수 없는 교환
return *this; // other가 소멸하며 옛 자원 해제
}
매개변수를 값으로 받기 때문에, 복사가 실패하면 함수 본문에 들어오기 전에 예외가 나서 *this는 전혀 변하지 않습니다. 자기 대입이면 사본과 교환할 뿐이라 검사도 필요 없고, rvalue가 넘어오면 매개변수가 이동 생성되므로 이 하나의 연산자가 복사 대입과 이동 대입을 모두 처리합니다. 대가는 자기 대입이나 같은 크기 버퍼 재사용 같은 최적화 기회를 포기하고 항상 새로 할당한다는 점이어서, std::vector처럼 성능이 중요한 컨테이너는 기존 용량이 충분하면 재할당 없이 덮어쓰는 방식을 씁니다.
noexcept 누락
이 항목은 컴파일 에러도, 런타임 크래시도 아닌 “조용한 성능 저하”를 유발하기 때문에 가장 놓치기 쉽습니다. std::vector가 재할당(용량 초과로 더 큰 메모리 블록으로 옮겨야 할 때)을 수행할 때, 기존 원소들을 새 메모리로 옮기는 과정에서 예외가 발생하면 강한 예외 보장(작업이 실패해도 원래 상태를 그대로 유지)을 지킬 수 없는 상황이 생깁니다. 이동 생성자가 noexcept로 선언되어 있지 않으면 vector는 “이 이동이 예외를 던지지 않는다”는 보장을 받을 수 없으므로, 안전을 위해 이동 대신 복사를 선택합니다 — 즉 개발자가 애써 작성한 O(1) 이동 생성자가 있어도, noexcept가 없다는 이유만으로 vector는 재할당 때마다 원소 하나하나를 깊은 복사하게 됩니다. 정확히는 std::move_if_noexcept가 “이동이 noexcept이거나, 타입이 복사 불가능할 때” 이동을 고릅니다. 그래서 FileHandle처럼 복사가 삭제된 타입은 noexcept가 없어도 이동되지만, 그 대신 재할당 중 예외가 나면 vector는 강한 보장을 포기합니다. 이동 생성자와 이동 대입 연산자를 작성할 때 noexcept를 붙이는 습관은 단순한 문서화가 아니라, 표준 컨테이너가 실제로 이동을 사용할지 복사로 대체할지를 결정하는 컴파일 타임 계약입니다.
// ❌ noexcept 없음
Resource(Resource&& other) {
// ...
}
// ✅ noexcept 추가
Resource(Resource&& other) noexcept {
// ...
}
// 이유: vector 등에서 이동 생성자가 noexcept여야 사용
직접 확인하는 가장 빠른 방법은 static_assert(std::is_nothrow_move_constructible_v<Resource>);를 클래스 정의 뒤에 두는 것입니다. 멤버 중 하나가 noexcept 이동을 지원하지 않으면 = default로 만든 이동 생성자도 noexcept가 아니게 되는데, 이 한 줄이 그 변화를 컴파일 타임에 잡아 줍니다.
이동한 뒤 원본을 비우지 않음
이동 생성자에서 가장 흔한 실수는 포인터를 넘겨받고 원본 쪽 포인터를 그대로 두는 것입니다. 그러면 두 객체가 같은 메모리를 가리키고, 둘 중 하나의 소멸자가 먼저 delete[]를 호출하는 순간 다른 쪽은 이미 해제된 메모리를 들고 있게 됩니다. 얕은 복사 버그와 증상이 같고, 크래시가 원인과 먼 곳에서 늦게 나타나는 것도 같습니다. 자원을 넘겨받는 것과 원본을 비우는 것은 항상 한 세트로 작성합니다.
// ❌ 원본이 여전히 같은 버퍼를 가리킴 → 이중 해제
Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {}
// ✅ 넘겨받고 원본을 비움 (std::exchange를 쓰면 한 줄로)
Buffer(Buffer&& other) noexcept
: data(std::exchange(other.data, nullptr)), size(std::exchange(other.size, 0)) {}
표준 라이브러리 타입의 이동된(moved-from) 객체는 “유효하지만 지정되지 않은 상태”입니다. 소멸시키거나 새 값을 대입하는 것은 안전하지만, std::string이 이동 후 반드시 빈 문자열이라고 가정하고 값을 읽는 코드는 표준이 보장하지 않습니다. 직접 만든 타입도 이동 후에는 최소한 소멸자와 대입이 안전하게 동작하도록 원본을 정리해 두어야 합니다.
지역 변수를 return std::move(x);로 반환
지역 변수를 반환할 때 std::move를 붙이면 오히려 손해입니다. return x;는 NRVO(이름 있는 반환값 최적화) 대상이 되어 복사도 이동도 없이 호출부의 객체를 바로 만들 수 있고, NRVO가 적용되지 않더라도 C++11부터 지역 변수 반환은 자동으로 이동을 먼저 시도합니다. 반면 return std::move(x);는 반환식이 변수 이름이 아니게 되어 NRVO 자격을 잃고, 항상 이동 생성자가 한 번 호출됩니다. GCC와 Clang은 이 경우 -Wpessimizing-move 경고(-Wall에 포함)를 냅니다.
Buffer make() {
Buffer b(100);
return b; // ✅ NRVO 또는 자동 이동
// return std::move(b); // ❌ NRVO 불가, 이동 생성자 강제 호출
}
Rule of Zero: 애초에 쓰지 않는 선택
이 글의 예제들은 원리를 보이기 위해 원시 포인터를 직접 다뤘지만, 실무에서 가장 좋은 복사/이동 생성자는 직접 쓰지 않은 것인 경우가 많습니다. int* data 대신 std::vector<int>, char* 대신 std::string, FILE* 대신 커스텀 삭제자를 가진 std::unique_ptr<FILE, decltype(&fclose)>를 멤버로 두면, 컴파일러가 생성하는 다섯 개 특수 멤버가 멤버별 복사·이동을 수행하면서 자동으로 올바르게 동작합니다. 이것이 Rule of Zero입니다.
직접 작성한 특수 멤버는 멤버를 하나 추가할 때마다 다섯 곳을 모두 고쳐야 하고, 한 곳이라도 빠뜨리면 새 멤버가 복사되지 않거나 이동 후에도 원본에 남는 버그가 생깁니다. 처음 Rule of Five를 배운 뒤 모든 클래스에 다섯 개를 손으로 써 넣다가, 필드 추가 후 복사 대입 한 곳만 갱신을 빠뜨려 “복사한 객체의 일부 필드만 옛 값”인 증상을 겪는 것이 흔한 경로입니다. 원시 자원을 직접 감싸는 작은 RAII 래퍼 하나에만 Rule of Five를 적용하고, 나머지 클래스는 그 래퍼를 멤버로 써서 Rule of Zero로 두는 구성이 유지보수에 가장 유리합니다.