C++ Rule of Five 직접 구현하기: copy-and-swap, 자기 대입, noexcept 이동 연산

이 글의 핵심

C++에서 클래스가 자원을 직접 관리할 때 필요한 다섯 개 특수 멤버 함수(소멸자, 복사/이동 생성자, 복사/이동 대입)를 언제, 왜 정의해야 하는지 실전 예제로 정리합니다. Rule of Zero, copy-and-swap, noexcept 트레이드오프까지 다룹니다.

Rule of Five란?

클래스가 메모리, 파일, 소켓 같은 자원을 직접 관리한다면 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자 다섯 개를 함께 검토해야 한다는 지침입니다.

C++ 컴파일러는 클래스가 이 다섯 개 중 아무것도 직접 선언하지 않으면 다섯 개 모두를 암묵적으로 만들어 줍니다. 문제는 그 암묵적 버전이 “멤버별 복사/이동”이라는 단순한 규칙만 따른다는 점입니다 — 원시 포인터 멤버가 있는 클래스에서는 이 단순한 규칙이 틀린 동작(포인터 값만 복사되고 가리키는 메모리는 공유됨)을 만들어냅니다. 게다가 다섯 개는 서로 얽혀 있습니다: 소멸자를 직접 정의하는 순간 컴파일러는 “이 클래스가 특별한 자원 관리가 필요하다”고 판단해 암묵적 복사 생성자·복사 대입 생성을 더 이상 권장하지 않게 되고(deprecated), 복사 생성자를 직접 정의하면 이동 생성자는 아예 암묵적으로 생성되지 않습니다. Rule of Five는 이런 상호 의존성 때문에 “자원을 직접 관리하는 클래스라면 다섯 개를 개별적으로 검토하고 의식적으로 결정하라”는 지침입니다.

class MyClass {
public:
    // 1. 소멸자
    ~MyClass();

    // 2. 복사 생성자
    MyClass(const MyClass& other);

    // 3. 복사 대입 연산자
    MyClass& operator=(const MyClass& other);

    // 4. 이동 생성자 (C++11)
    MyClass(MyClass&& other) noexcept;

    // 5. 이동 대입 연산자 (C++11)
    MyClass& operator=(MyClass&& other) noexcept;
};

왜 필요한가?

Buffer가 소멸자만 직접 정의하고 복사 생성자·복사 대입은 컴파일러가 만들어 준 기본 버전에 맡겨 둔 것이 이 코드의 문제입니다. 소멸자는 delete[] data로 명확히 “이 클래스는 data가 가리키는 메모리를 소유한다”고 선언하고 있는데, 암묵적 복사 생성자는 그 사실을 모른 채 data 포인터 값만 복사합니다. 그 결과 b2 = b1 이후 두 객체가 같은 메모리 블록을 가리키게 되고, b1과 b2가 각자 스코프를 벗어나며 소멸자를 두 번 호출하면 같은 메모리에 대해 delete[]가 두 번 실행되는 double free가 발생합니다. 이 예제가 정확히 “소멸자만 정의하고 나머지를 방치하면 왜 위험한가”를 보여주는 최소 재현 코드입니다.

class Buffer {
private:
    int* data;
    size_t size;

public:
    Buffer(size_t s) : size(s) {
        data = new int[size];
    }

    // ❌ 소멸자만 정의 (위험!)
    ~Buffer() {
        delete[] data;
    }

    // 복사 시 얕은 복사 (double free!)
};

int main() {
    Buffer b1(10);
    Buffer b2 = b1;  // 같은 메모리 가리킴
    // 소멸 시 double free!
}

실전 예시

네 예시는 서로 다른 자원(동적 배열, 파일 핸들, 문자열 버퍼, 힙 포인터)을 다루지만 구조는 같습니다 — 각 예시가 “무엇을 소유하는가”와 “그 소유권을 복사할 때 무엇을 해야 하는가”를 어떻게 다르게 결정하는지 비교하며 읽으면 Rule of Five가 실제로 요구하는 것이 판박이 코드가 아니라 타입별 판단이라는 점이 드러납니다.

동적 배열 클래스

각 특수 멤버 함수에 로그를 남긴 것은 의도적입니다 — main의 각 줄이 다섯 개 중 정확히 어떤 함수를 호출하는지 직접 실행해 눈으로 확인할 수 있게 하기 위함입니다. arr2 = arr1은 이미 생성된 객체에 값을 대입하므로 복사 대입(기존 data를 delete하고 새로 할당), DynamicArray arr2 = arr1처럼 선언과 동시에 초기화하는 경우는 복사 생성자(처음부터 새로 만들어짐)가 호출된다는 차이를 코드로 직접 구분해 보는 것이 Rule of Five를 체화하는 가장 빠른 방법입니다.

class DynamicArray {
private:
    int* data;
    size_t size;

public:
    // 생성자
    DynamicArray(size_t s) : size(s) {
        data = new int[size];
        std::cout << "생성자: " << size << "개 할당" << std::endl;
    }

    // 1. 소멸자
    ~DynamicArray() {
        delete[] data;
        std::cout << "소멸자: 메모리 해제" << std::endl;
    }

    // 2. 복사 생성자
    DynamicArray(const DynamicArray& other) : size(other.size) {
        data = new int[size];
        std::copy(other.data, other.data + size, data);
        std::cout << "복사 생성자" << std::endl;
    }

    // 3. 복사 대입 연산자
    DynamicArray& operator=(const DynamicArray& other) {
        if (this != &other) {
            delete[] data;

            size = other.size;
            data = new int[size];
            std::copy(other.data, other.data + size, data);
            std::cout << "복사 대입" << std::endl;
        }
        return *this;
    }

    // 4. 이동 생성자
    DynamicArray(DynamicArray&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
        std::cout << "이동 생성자" << std::endl;
    }

    // 5. 이동 대입 연산자
    DynamicArray& operator=(DynamicArray&& other) noexcept {
        if (this != &other) {
            delete[] data;

            data = other.data;
            size = other.size;

            other.data = nullptr;
            other.size = 0;
            std::cout << "이동 대입" << std::endl;
        }
        return *this;
    }

    int& operator[](size_t index) {
        return data[index];
    }
};

int main() {
    DynamicArray arr1(10);
    DynamicArray arr2 = arr1;          // 복사 생성자
    DynamicArray arr3(5);
    arr3 = arr1;                       // 복사 대입
    DynamicArray arr4 = std::move(arr1);  // 이동 생성자
    arr3 = std::move(arr2);            // 이동 대입
}

파일 핸들 클래스

이 예시가 흥미로운 이유는 “복사”의 의미 자체를 재정의하고 있기 때문입니다 — 파일 디스크립터는 원칙적으로 복제할 수 없는 OS 자원이므로, 복사 생성자는 같은 파일을 다시 열어 새 핸들을 만들고 ftell/fseek로 읽기 위치까지 맞춰 “논리적으로 동등한 새 핸들”을 흉내 냅니다. 이는 동적 배열의 깊은 복사(메모리를 그대로 복제)와는 다른 종류의 “복사”이며, 자원의 성격에 따라 복사 연산의 의미 자체가 달라진다는 것을 보여줍니다. 또한 각 생성자·대입 연산자에서 fopen이 실패할 수 있으므로 예외를 던지도록 처리한 점도 주목할 만합니다 — 파일 시스템처럼 실패할 수 있는 외부 자원을 다루는 클래스는 특수 멤버 함수 안에서도 실패 경로를 반드시 고려해야 합니다.

그런데 이 코드의 복사 대입에는 바로 그 실패 경로에 버그가 있습니다. 기존 file을 fclose한 뒤 fopen이 실패해 예외를 던지면, file 멤버는 이미 닫힌 FILE*를 그대로 가리킵니다. 이후 이 객체가 소멸될 때 소멸자가 같은 포인터로 fclose를 다시 호출하므로 미정의 동작입니다. 아래 “문제 2: 예외 안전성”과 같은 유형으로, 새 파일을 먼저 연 뒤 성공했을 때만 기존 파일을 닫는 순서로 바꾸거나 copy-and-swap을 쓰면 해결됩니다. 또 이동된 other는 file == nullptr이므로, 이동된 객체를 복사 원본으로 쓰면 ftell(nullptr)이 호출됩니다.

솔직히 말하면 실무에서 파일 핸들 클래스에 이런 “다시 열기 복사”를 넣는 경우는 드뭅니다. 파일은 그사이 삭제되거나 바뀔 수 있어 “같은 파일을 다시 연다”는 복사의 의미가 보장되지 않고, 쓰기 모드라면 두 핸들이 같은 파일을 덮어쓰게 됩니다. 제가 이런 클래스를 설계한다면 예시 4처럼 복사를 = delete하고 이동만 허용하는 쪽을 택할 것입니다. 이 예제는 “복사의 의미는 자원마다 다르다”는 점을 보여 주는 용도로 읽는 것이 좋습니다.

class FileHandle {
private:
    FILE* file;
    std::string filename;

public:
    FileHandle(const std::string& name) : filename(name) {
        file = fopen(filename.c_str(), "r");
        if (!file) {
            throw std::runtime_error("파일 열기 실패");
        }
    }

    ~FileHandle() {
        if (file) {
            fclose(file);
            std::cout << "파일 닫음: " << filename << std::endl;
        }
    }

    // 복사 생성자 (새 파일 핸들)
    FileHandle(const FileHandle& other) : filename(other.filename) {
        file = fopen(filename.c_str(), "r");
        if (!file) {
            throw std::runtime_error("파일 열기 실패");
        }
        // 같은 위치로 이동
        fseek(file, ftell(other.file), SEEK_SET);
    }

    // 복사 대입
    FileHandle& operator=(const FileHandle& other) {
        if (this != &other) {
            if (file) {
                fclose(file);
            }

            filename = other.filename;
            file = fopen(filename.c_str(), "r");
            if (!file) {
                throw std::runtime_error("파일 열기 실패");
            }
            fseek(file, ftell(other.file), SEEK_SET);
        }
        return *this;
    }

    // 이동 생성자
    FileHandle(FileHandle&& other) noexcept
        : file(other.file), filename(std::move(other.filename)) {
        other.file = nullptr;
    }

    // 이동 대입
    FileHandle& operator=(FileHandle&& other) noexcept {
        if (this != &other) {
            if (file) {
                fclose(file);
            }

            file = other.file;
            filename = std::move(other.filename);

            other.file = nullptr;
        }
        return *this;
    }

    std::string readLine() {
        char buffer[256];
        if (fgets(buffer, sizeof(buffer), file)) {
            return std::string(buffer);
        }
        return "";
    }
};

문자열 클래스

복사 대입 연산자가 다른 예시들과 다르게 if (this != &other) 자기 대입 검사 대신 copy-and-swap 관용구를 쓰고 있다는 점이 핵심입니다. String temp(other)로 먼저 완전한 사본을 만든 뒤 std::swap으로 내부 포인터만 맞바꾸면, 자기 대입(s = s)이 들어와도 temp가 other(즉 *this)의 복사본이므로 스왑 후에도 데이터가 그대로 유지되어 별도의 this != &other 검사가 필요 없습니다. 더 중요한 이점은 예외 안전성입니다 — String temp(other)의 new char[]가 실패해 예외를 던지더라도 그 시점에는 아직 *this의 어떤 멤버도 건드리지 않았으므로 *this는 대입 시도 이전 상태 그대로 남습니다. 이는 뒤에 나올 “문제 2: 예외 안전성”에서 다시 다루는, delete를 먼저 하고 new를 나중에 하는 순진한 구현과 근본적으로 다른 안전성 수준입니다. 코드에 남아 있는 if (this != &other)는 정확성에는 필요 없고, 드문 자기 대입에서 불필요한 할당을 피하는 최적화일 뿐입니다.

이동 생성자는 원본의 data를 nullptr로 만들기 때문에, main에서 std::move(s1) 이후 s1.c_str()를 출력하면 널 포인터를 std::cout에 넘기게 되어 미정의 동작입니다. 표준 라이브러리 타입은 이동된 객체를 “유효하지만 지정되지 않은 상태”로 남기는데, std::string은 보통 빈 문자열이 됩니다. 직접 만든 클래스도 이동 후 최소한 소멸, 대입, 그리고 가능하면 조회 정도는 안전하도록 설계하는 편이 좋습니다. 여기서는 data를 nullptr 대신 빈 문자열 버퍼로 두거나, c_str()가 data ? 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() {
        delete[] data;
    }

    String(const String& other) : length(other.length) {
        data = new char[length + 1];
        strcpy(data, other.data);
    }

    String& operator=(const String& other) {
        if (this != &other) {
            // Copy-and-swap idiom
            String temp(other);
            std::swap(data, temp.data);
            std::swap(length, temp.length);
        }
        return *this;
    }

    String(String&& other) noexcept
        : data(other.data), length(other.length) {
        other.data = nullptr;
        other.length = 0;
    }

    String& operator=(String&& other) noexcept {
        if (this != &other) {
            delete[] data;

            data = other.data;
            length = other.length;

            other.data = nullptr;
            other.length = 0;
        }
        return *this;
    }

    const char* c_str() const {
        return data;
    }
};

int main() {
    String s1("Hello");
    String s2 = s1;              // 복사
    String s3 = std::move(s1);   // 이동

    std::cout << s2.c_str() << std::endl;
    std::cout << s3.c_str() << std::endl;
}

스마트 포인터 (간단한 버전)

앞의 세 예시가 모두 “복사를 어떻게 올바르게 구현할까”를 다뤘다면, 이 예시는 “애초에 복사가 말이 되지 않는 자원은 복사 자체를 막는다”는 세 번째 선택지를 보여줍니다. 힙에 할당된 객체 하나를 배타적으로 소유한다는 것이 UniquePtr의 의미이므로, 복사를 허용하면 두 UniquePtr이 같은 포인터를 각자 delete하려 드는 상황이 필연적으로 발생합니다 — 이는 파일 핸들처럼 “다시 열어서” 복사를 흉내 낼 수도 없는 경우입니다. = delete로 복사 생성자·대입을 명시적으로 막고 이동만 허용하는 이 패턴이 바로 std::unique_ptr의 실제 설계이며, “자원을 복제할 방법이 없거나 복제가 의미 없다면, 복사를 금지하고 이동만 허용하라”는 것이 Rule of Five가 실무에서 가장 자주 도달하는 결론입니다.

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* get() const { return ptr; }
    T& operator*() const { return *ptr; }
    T* operator->() const { return ptr; }
};

int main() {
    UniquePtr<int> p1(new int(42));
    // UniquePtr<int> p2 = p1;  // 에러: 복사 불가
    UniquePtr<int> p2 = std::move(p1);  // OK: 이동

    std::cout << *p2 << std::endl;
}

Rule of Zero

Rule of Zero는 Rule of Five의 반대말이 아니라 그 자연스러운 귀결입니다 — 원시 포인터로 자원을 직접 관리하는 대신 std::unique_ptr<int[]> 같은 이미 Rule of Five를 올바르게 구현해 둔 타입에 소유권을 위임하면, Buffer 자신은 더 이상 특수 멤버 함수를 하나도 직접 작성할 필요가 없어집니다. 컴파일러가 생성하는 암묵적 소멸자·복사·이동은 각 멤버(data, size)에 대해 그 멤버 타입의 소멸자·복사·이동을 그대로 호출하며, unique_ptr는 이미 올바른 소유권 이전 로직을 갖고 있으므로 결과적으로 Buffer도 올바르게 동작합니다. 실무에서는 이 편이 압도적으로 선호됩니다 — 직접 작성한 코드가 한 줄도 없으므로 자기 대입, 예외 안전성, noexcept 같은 이 글의 나머지 절에서 다루는 함정들을 원천적으로 마주칠 일이 없기 때문입니다.

단, 위임하는 멤버의 복사 의미가 그대로 클래스의 복사 의미가 된다는 점을 기억해야 합니다. std::unique_ptr<int[]>는 복사가 삭제된 타입이므로 아래 Buffer도 자동으로 복사 불가, 이동만 가능한 타입이 됩니다. Buffer b2 = b1;을 쓰면 use of deleted function 'Buffer::Buffer(const Buffer&)' 오류가 납니다. 원래 예제처럼 복사가 필요하다면 std::vector<int>에 맡기는 것이 정답이고, 여러 객체가 같은 버퍼를 공유해야 한다면 std::shared_ptr이 맞습니다. Rule of Zero는 “아무 생각 없이 기본값을 쓴다”가 아니라 “원하는 복사·이동 의미를 가진 멤버 타입을 고른다”는 뜻입니다.

// ✅ 스마트 포인터 사용 (Rule of Zero)
class Buffer {
private:
    std::unique_ptr<int[]> data;
    size_t size;

public:
    Buffer(size_t s) : data(std::make_unique<int[]>(s)), size(s) {}

    // 특수 멤버 함수 불필요 (컴파일러 자동 생성)
};

자주 발생하는 문제

자기 대입

obj = obj처럼 명시적으로 자기 자신을 대입하는 코드는 드물지만, 참조나 포인터를 경유해 간접적으로 같은 객체를 가리키는 경우(*p1 = *p2인데 p1 == p2)는 실무에서 실제로 발생합니다. this != &other 검사 없이 delete[] data를 먼저 실행하면, other가 곧 *this이므로 other.size를 읽는 다음 줄에서 이미 해제된 객체의 멤버를 읽게 되어 정의되지 않은 동작이 발생합니다. 앞서 문자열 예시에서 다룬 copy-and-swap 관용구는 이 검사 자체를 불필요하게 만드는 더 견고한 대안이지만, 직접 delete-new 순서로 구현할 때는 반드시 이 검사가 필요합니다.

// ❌ 자기 대입 미처리
MyClass& operator=(const MyClass& other) {
    delete[] data;  // 자기 대입 시 문제!
    data = new int[other.size];
    // ...
}

// ✅ 자기 대입 체크
MyClass& operator=(const MyClass& other) {
    if (this != &other) {
        delete[] data;
        data = new int[other.size];
        // ...
    }
    return *this;
}

예외 안전성

delete[] data로 기존 메모리를 먼저 해제한 뒤 new int[other.size]를 시도하는 순서가 문제입니다 — 메모리 부족 등으로 new가 std::bad_alloc을 던지면, data는 이미 해제된 포인터를 여전히 가리키고 있는 상태로 함수가 예외를 던지며 종료됩니다. 이후 이 객체가 (호출자가 예외를 잡고 계속 사용하려 하거나, 소멸자가 호출될 때) data에 접근하면 해제된 메모리에 접근하는 셈이 됩니다. copy-and-swap 관용구가 이 문제를 해결하는 방식은 앞서 문자열 예시에서 본 그대로입니다 — 실패할 수 있는 연산(new를 포함한 temp 생성)을 기존 상태를 건드리기 전에 먼저 끝내고, 그 연산이 확실히 성공한 뒤에야(swap은 예외를 던지지 않는 연산이므로) 실제 교체를 수행합니다.

// ❌ 예외 안전하지 않음
MyClass& operator=(const MyClass& other) {
    delete[] data;
    data = new int[other.size];  // 예외 발생 시 data는 댕글링
    // ...
}

// ✅ Copy-and-swap idiom
MyClass& operator=(const MyClass& other) {
    MyClass temp(other);
    std::swap(data, temp.data);
    std::swap(size, temp.size);
    return *this;
}

noexcept 누락

std::vector가 용량 초과로 재할당할 때, 기존 원소들을 새 메모리로 옮기는 과정에서 예외가 발생하면 강한 예외 보장(재할당 실패 시에도 원래 상태를 유지)을 지킬 수 없습니다. 이동 생성자에 noexcept가 없으면 vector는 “이 타입의 이동이 절대 예외를 던지지 않는다”는 보장을 컴파일 타임에 확인할 수 없으므로, 안전을 위해 이동 대신 (예외가 나도 원본이 온전히 남는) 복사를 선택합니다. 즉 애써 O(1) 이동 생성자를 작성해도 noexcept를 빠뜨리면 vector 재할당 시 매번 O(n) 복사가 일어나는 조용한 성능 저하로 이어집니다 — 컴파일 에러도, 런타임 크래시도 없이 그저 느려질 뿐이라 프로파일링 없이는 발견하기 어렵습니다.

// ❌ noexcept 없음 (성능 저하)
MyClass(MyClass&& other) {
    // ...
}

// ✅ noexcept 추가
MyClass(MyClass&& other) noexcept {
    // ...
}

이동 후 상태

이동 생성자의 계약은 “원본에서 자원을 훔쳐오되, 원본이 안전하게 소멸될 수 있는 상태로 남긴다”는 것입니다. other.data를 nullptr로 설정하지 않으면, 새 객체와 other 둘 다 같은 메모리를 가리키게 되어 두 객체가 각자 소멸될 때 같은 메모리에 대해 delete가 두 번 호출되는 double free가 발생합니다 — 결과적으로 이동 연산이 얕은 복사와 동일한 버그를 만들어내는 셈입니다. other.data = nullptr은 단순한 정리가 아니라, “이 포인터는 더 이상 아무것도 소유하지 않는다”는 것을 other의 소멸자에게 알리는 필수 신호입니다.

// ❌ 이동 후 유효하지 않은 상태
MyClass(MyClass&& other) noexcept : data(other.data) {
    // other.data는 여전히 유효한 포인터 (위험!)
}

// ✅ 이동 후 nullptr
MyClass(MyClass&& other) noexcept : data(other.data) {
    other.data = nullptr;
}

Rule of Three vs Rule of Five

Rule of Three는 이동 시맨틱이 없던 C++98/03 시절의 규칙으로, 자원을 관리하는 클래스는 소멸자·복사 생성자·복사 대입 세 개만 신경 쓰면 됐습니다. C++11이 이동 시맨틱과 rvalue 참조(&&)를 도입하면서 두 개(이동 생성자, 이동 대입)가 추가된 것이 Rule of Five입니다. 이동 연산이 없어도 프로그램은 여전히 올바르게 동작합니다 — 이동이 필요한 자리에서는 그냥 복사가 대신 수행될 뿐입니다. 다만 그 경우 std::move(obj)를 명시적으로 써도 실제로는 깊은 복사가 일어나므로, 큰 자원을 다루는 클래스에서 이동 연산자를 빠뜨리면 “코드는 이동을 의도했지만 실제로는 매번 복사 비용을 지불하는” 조용한 성능 문제가 생깁니다. C++11 이후로 새로 작성하는 자원 관리 클래스라면 Rule of Three가 아니라 Rule of Five를 기본으로 삼아야 하는 이유입니다.

// C++98: Rule of Three
class MyClass {
public:
    ~MyClass();
    MyClass(const MyClass&);
    MyClass& operator=(const MyClass&);
};

// C++11: Rule of Five
class MyClass {
public:
    ~MyClass();
    MyClass(const MyClass&);
    MyClass& operator=(const MyClass&);
    MyClass(MyClass&&) noexcept;
    MyClass& operator=(MyClass&&) noexcept;
};

= default와 = delete

= default와 = delete는 Rule of Five를 “의식적으로 결정했다”는 것을 코드 자체에 남기는 문법입니다. = default는 “컴파일러가 만들어 줄 기본 구현으로 충분하다고 판단했다”는 명시적 선언이며, 특히 다른 특수 멤버 함수를 직접 정의해 암묵적 생성이 억제된 상황(예: 소멸자를 직접 작성해 이동 생성자가 암묵적으로 만들어지지 않는 경우)에서 원래의 기본 동작을 다시 살리고 싶을 때 유용합니다. = delete는 반대로 “이 연산은 이 타입에서 의미가 없으므로 아예 금지한다”는 컴파일 타임 강제이며, 앞서 UniquePtr 예시에서 본 것처럼 이 둘을 조합하면 “복사는 금지, 이동은 기본 구현으로 허용” 같은 세밀한 정책을 코드 몇 줄로 표현할 수 있습니다. 두 키워드 모두를 쓰지 않고 방치하면, 다른 특수 멤버 함수의 선언 여부에 따라 암묵적 생성 규칙이 미묘하게 달라지므로(이 글 서두에서 설명한 상호 의존성), 의도를 명시적으로 남기는 것 자체가 코드 리뷰에서 실수를 줄이는 습관입니다.

// 기본 구현을 명시적으로 사용
class Copyable {
public:
    virtual ~Copyable() = default;
    Copyable(const Copyable&) = default;
    Copyable& operator=(const Copyable&) = default;
    Copyable(Copyable&&) = default;
    Copyable& operator=(Copyable&&) = default;
};

// 복사는 금지하고 이동만 허용
class MoveOnly {
public:
    MoveOnly(const MoveOnly&) = delete;
    MoveOnly& operator=(const MoveOnly&) = delete;
    MoveOnly(MoveOnly&&) = default;
    MoveOnly& operator=(MoveOnly&&) = default;
};

Copyable에서 다섯 개를 모두 = default로 적은 데는 이유가 있습니다. 다형성 기반 클래스에 virtual ~Copyable() = default; 하나만 선언하면, 사용자 선언 소멸자 때문에 이동 연산이 암묵적으로 생성되지 않습니다. 컴파일 오류는 없지만 이 클래스를 상속한 모든 타입의 “이동”이 조용히 복사로 바뀝니다. 기반 클래스에 가상 소멸자를 넣을 때 나머지 네 개도 = default로 적어 두는 것이 이 함정을 피하는 관용구이며, C++ Core Guidelines(C.21)가 “하나를 정의하거나 = delete하면 전부 정의하거나 = delete하라”고 권하는 이유이기도 합니다.

= default로 선언한 이동 생성자가 항상 noexcept인 것도 아닙니다. 컴파일러는 모든 멤버의 이동이 noexcept일 때만 기본 이동을 noexcept로 만듭니다. 멤버 중 하나라도 noexcept가 아닌 이동 생성자를 가지면 클래스 전체의 이동도 noexcept가 아니게 되어, 앞의 “문제 3”처럼 vector 재할당 시 복사로 떨어집니다. static_assert(std::is_nothrow_move_constructible_v<MyClass>);를 클래스 정의 뒤에 두면 이런 변화를 컴파일 타임에 잡을 수 있습니다.

FAQ

Q1: 다섯 개를 직접 작성해야 하는 클래스는 실제로 얼마나 되나요?

A: 생각보다 적습니다. 새 코드에서 Rule of Five를 직접 구현하는 것은 unique_ptr로 감싸기 어려운 OS 핸들 래퍼, 커스텀 컨테이너, 메모리 풀처럼 자원 하나를 관리하는 것 자체가 목적인 작은 클래스뿐인 경우가 대부분입니다. 그 밖의 비즈니스 클래스는 이런 래퍼나 표준 타입을 멤버로 가지고 Rule of Zero를 따르는 것이 좋습니다. 자원 관리 코드를 작은 클래스 하나에 가둬 두면, 까다로운 특수 멤버를 검증해야 하는 곳도 그 하나로 줄어듭니다.

Q2: copy-and-swap은 항상 최선인가요?

A: 예외 안전성과 코드 간결성 면에서는 뛰어나지만, 항상 새로 할당한다는 비용이 있습니다. 예를 들어 DynamicArray에서 대상과 원본의 크기가 같다면 기존 버퍼에 그대로 복사하면 할당이 필요 없는데, copy-and-swap은 매번 임시 사본을 만듭니다. std::vector의 복사 대입이 copy-and-swap을 쓰지 않는 것도 이 때문입니다. 강한 예외 보장이 꼭 필요한지, 대입이 핫 패스에 있는지에 따라 선택합니다.

Q3: 이동된 객체는 다시 써도 되나요?

A: 소멸과 새 값 대입은 항상 안전해야 하고, 그 외의 연산은 클래스가 약속하는 범위까지만 안전합니다. 표준 라이브러리 타입은 “유효하지만 지정되지 않은 상태”를 보장하므로 clear()나 대입 후에는 다시 쓸 수 있습니다. 직접 만든 클래스라면 이동 후 상태를 문서화하고, clang-tidy의 bugprone-use-after-move 검사로 이동 후 사용을 찾아내는 것이 좋습니다.


같이 보면 좋은 글