본문으로 건너뛰기

안동민 개발노트

본문 시작

Rule of Three, Five, Zero 실전

동적 자원의 얕은 복사와 이중 해제를 확인하고 복사·이동 특별 멤버 및 Rule of Zero로 소유권을 안전하게 설계합니다.

9장에서 생성자/소멸자와 함께 Rule of Three/Five/Zero를 소개했습니다.

이번 절에서는 “왜 필요한지”를 실제 코드 문제로 확인하고, 구현 패턴을 단계적으로 정리하겠습니다.

특별 멤버 정책은 직접 소유 여부에서 갈린다

FLOWCHART · OWNERSHIP POLICY

특별 멤버 정책은 직접 소유 여부에서 갈린다

소멸자 개수를 세기 전에 누가 자원을 소유하고 복사·이동을 허용할지 결정한다.

특별 멤버 정책은 직접 소유 여부에서 갈린다 표준 RAII 멤버는 Rule of Zero로, 직접 자원 소유는 복사와 이동 허용 여부에 따라 구현 또는 삭제로 간다. QUESTION 자원을 직접 소유하는가? 소유권 계약부터 확인 NO Rule of Zero 표준 RAII 멤버에 위임 COPY 깊은 복사 구현 독립 자원을 새로 확보 MOVE 이동 구현 handle 이전 + noexcept 검토 DELETE 복사·이동 삭제 의미 없는 연산을 금지
  1. 소유권 질문

    표준 타입이 자원을 소유하면 Rule of Zero를 택한다.

  2. 복사 허용

    독립 자원을 만드는 깊은 복사를 구현한다.

  3. 이동 허용

    원본 계약과 noexcept를 함께 설계한다.

  4. 허용하지 않음

    = delete로 정책을 명시한다.

직접 소유가 필요하지 않다면 검증된 RAII 타입으로 돌아가는 Rule of Zero가 기본값이다.


왜 필요한가: 얕은 복사(Shallow Copy) 문제

클래스가 동적 자원을 직접 소유할 때, 컴파일러 기본 복사는 포인터 값만 복사하는 얕은 복사가 될 수 있습니다.

이 경우 두 객체가 같은 메모리를 가리키게 되어 이중 해제(double delete) 같은 치명적 오류가 발생합니다.

얕은 복사로 인한 위험한 코드
class Buffer {
private:
    int* data;
    int size;

public:
    Buffer(int n) : data(new int[n]), size(n) {}
    ~Buffer() { delete[] data; }
};

// 복사 생성자/복사 대입을 직접 정의하지 않으면
// Buffer b2 = b1; 에서 data 포인터만 복사됨 (얕은 복사)

Rule of Three: 소멸자, 복사 생성자, 복사 대입

자원을 직접 소유하면 보통 아래 3개를 같이 설계해야 합니다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자
Rule of Three 구현 예시
#include <algorithm>
#include <iostream>

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

public:
    Buffer(int n = 0) : data(n > 0 ? new int[n] : nullptr), size(n) {}

    ~Buffer() {
        delete[] data;
    }

    // 복사 생성자 (깊은 복사)
    Buffer(const Buffer& other) : data(other.size > 0 ? new int[other.size] : nullptr), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }

    // 복사 대입 연산자 (깊은 복사 + 자기 대입 방어)
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;

        int* newData = other.size > 0 ? new int[other.size] : nullptr;
        std::copy(other.data, other.data + other.size, newData);

        delete[] data;
        data = newData;
        size = other.size;
        return *this;
    }
};
복사는 새 자원을 만들고 이동은 소유자를 바꾼다

SEQUENCE · RESOURCE SEMANTICS

복사는 새 자원을 만들고 이동은 소유자를 바꾼다

깊은 복사는 두 독립 자원을 만들고, 이동은 하나의 자원 handle을 새 소유자에게 전달한다.

복사는 새 자원을 만들고 이동은 소유자를 바꾼다 원본 객체에서 복사 대상은 새 버퍼를 할당해 값을 복제하고 이동 대상은 기존 handle을 넘겨받는다. 현재 buffer 소유 새 buffer B 할당 값 복제 handle A 이전 원본 상태 재설정 OWNERsource RESOURCEbuffer A COPY TARGETindependent MOVE TARGETnew owner
  1. 원본

    source가 buffer A를 소유한다.

  2. 깊은 복사

    copy target은 별도 buffer B를 확보한다.

  3. 값 복제

    A와 B는 독립적으로 변경할 수 있다.

  4. 이동

    move target이 handle A를 가져가고 원본은 재사용 가능 상태가 된다.

복사 대입은 새 자원을 먼저 확보한 뒤 commit하고, 이동 후 원본은 소멸·재대입 가능한 계약을 유지한다.


Rule of Five: 이동 생성자, 이동 대입 추가

C++11 이후에는 이동 시맨틱까지 포함해 5개를 함께 고려합니다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자
  • 이동 생성자
  • 이동 대입 연산자
Rule of Five 구현 핵심
class Buffer {
private:
    int* data;
    int size;

public:
    Buffer(int n = 0) : data(n > 0 ? new int[n] : nullptr), size(n) {}
    ~Buffer() { delete[] data; }

    Buffer(const Buffer& other);              // copy ctor
    Buffer& operator=(const Buffer& other);   // copy assign

    // move ctor
    Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // move assign
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data;
        data = other.data;
        size = other.size;
        other.data = nullptr;
        other.size = 0;
        return *this;
    }
};

이동 연산에 noexcept를 붙이면 표준 컨테이너가 재할당 시 이동을 더 적극적으로 선택할 수 있어 성능과 예외 안전성에 유리합니다.


=default, =delete로 의도 명확히 하기

특별 멤버 함수를 “자동 생성 허용/금지”로 명확히 표현할 수 있습니다.

특별 멤버 함수 제어
class NonCopyableFile {
public:
    NonCopyableFile() = default;
    ~NonCopyableFile() = default;

    NonCopyableFile(const NonCopyableFile&) = delete;
    NonCopyableFile& operator=(const NonCopyableFile&) = delete;

    NonCopyableFile(NonCopyableFile&&) noexcept = default;
    NonCopyableFile& operator=(NonCopyableFile&&) noexcept = default;
};

이 방식은 “복사는 금지하고 이동만 허용” 같은 정책을 코드 수준에서 강하게 보장합니다.


Rule of Zero: 가장 권장되는 현대적 접근

핵심 아이디어는 단순합니다.

  • 직접 new/delete를 다루지 않고
  • 자원 관리를 표준 라이브러리 타입에 맡긴다

예: std::string, std::vector, std::unique_ptr

Rule of Zero 스타일
#include <memory>
#include <string>
#include <vector>

class UserProfile {
private:
    std::string name;
    std::vector<int> scores;
    std::unique_ptr<int> badgeId;

public:
    UserProfile() = default;
    UserProfile(std::string n, std::vector<int> s, int id)
        : name(std::move(n)), scores(std::move(s)), badgeId(std::make_unique<int>(id)) {}
};

이 경우 복사/이동/소멸 대부분을 수동으로 관리할 필요가 크게 줄어듭니다.

아래 선택 기준은 직접 자원 소유 여부에서 시작해 복사 가능 여부, 이동 필요 여부, noexcept, =default/=delete 선언을 한 번에 점검하는 흐름입니다.


연산자 오버로딩과의 연결

대입 연산자 오버로딩(operator=)은 Rule of Three/Five의 일부입니다.

즉, 연산자 오버로딩을 다룰 때도 단순 문법이 아니라 자원 소유 정책과 함께 설계해야 합니다.

관련 내용은 ‘연산자 오버로딩 기초’와 함께 읽으면 좋습니다.


자원 소유 규칙 정리

  • 자원을 직접 소유하면 Rule of Three/Five를 반드시 고려해야 한다.
  • 이동 연산자는 가능하면 noexcept를 붙인다.
  • =default, =delete로 복사/이동 정책을 명시한다.
  • 현대 C++에서는 Rule of Zero(표준 타입 활용)가 가장 안전하고 유지보수에 유리하다.

마지막으로 직접 자원을 소유하는 클래스를 만들 때는 아래 점검표처럼 특별 멤버 함수와 선언 정책을 한 번에 점검하면 실수를 줄일 수 있습니다.

Rule of Three/Five/Zero는 외울 함수 목록이 아니라 자원 소유 여부와 복사·이동 정책을 한 묶음으로 정하는 기준입니다.

가능하면 직접 소유를 표준 RAII 타입에 맡겨 Rule of Zero로 되돌리는 것이 가장 읽기 쉽고 안전한 결론입니다.