C++ 메모리 관리: 깊은 복사(Deep Copy)와 얕은 복사(Shallow Copy)의 차이점

1. 개요 및 핵심 개념

C++에서 객체를 복사할 때 메모리를 어떻게 다루느냐에 따라 얕은 복사(Shallow Copy)깊은 복사(Deep Copy)로 구분됩니다. 이는 특히 클래스 내부에서 동적 메모리 할당을 사용할 때 프로그램의 안정성을 결정짓는 매우 중요한 요소입니다.

  • 얕은 복사 (Shallow Copy): 객체의 멤버 변수 값을 그대로 복사합니다. 만약 멤버가 포인터라면, 포인터가 가리키는 주소 값만 복사됩니다. 결과적으로 두 객체가 동일한 메모리 자원을 공유하게 됩니다.
  • 깊은 복사 (Deep Copy): 포인터가 가리키는 실제 자원을 위한 별도의 메모리를 새로 할당하고, 그 내부의 데이터까지 모두 복사합니다. 두 객체는 서로 독립적인 메모리 공간을 가집니다.

1.1 깊은 복사가 필요한 경우

  1. 클래스 내부에 newmalloc을 통해 할당된 동적 메모리(포인터 멤버)가 있는 경우.
  2. 파일 핸들, 네트워크 소켓, 데이터베이스 연결 등 독점적인 제어가 필요한 외부 자원을 관리할 때.
  3. 복사본의 수정이 원본 객체에 영향을 주지 않아야 하는 독립적인 데이터 관리가 필요할 때.

1.2 얕은 복사로 충분한 경우

  1. 멤버 변수가 기본 타입(int, double, bool 등)으로만 구성된 경우.
  2. std::string, std::vector, std::shared_ptr와 같이 자체적으로 복사 세맨틱이 구현된 표준 라이브러리 객체를 사용하는 경우.
  3. 객체의 복사가 발생하지 않거나, 읽기 전용으로만 공유되는 구조인 경우.

2. 얕은 복사의 위험성

C++ 컴파일러가 기본적으로 제공하는 복사 생성자와 대입 연산자는 얕은 복사를 수행합니다. 동적 메모리를 사용하는 클래스에서 이를 방치하면 다음과 같은 치명적인 문제가 발생합니다.

  • 이중 해제 (Double Free): 두 객체가 동일한 메모리를 가리키고 있을 때, 각각의 소멸자가 호출되면서 이미 해제된 메모리를 다시 해제하려 시도하여 프로그램이 크래시됩니다.
  • 댕글링 포인터 (Dangling Pointer): 한 객체가 소멸되어 메모리를 해제하면, 다른 객체는 이미 해제된 무효한 메모리 주소를 여전히 가리키게 됩니다.
  • 의도치 않은 데이터 오염: 한 객체에서 데이터를 수정하면 공유 중인 다른 객체의 데이터도 함께 변경됩니다.
#include <iostream>

class UnsafeBuffer {
private:
    int* ptr;
public:
    UnsafeBuffer(int val) {
        ptr = new int(val);
    }
    // 컴파일러 기본 생성자가 수행하는 얕은 복사 예시
    UnsafeBuffer(const UnsafeBuffer& source) : ptr(source.ptr) {}

    ~UnsafeBuffer() {
        delete ptr; // 소멸 시 메모리 해제
    }

    void display() const {
        std::cout << "Value: " << *ptr << " (Address: " << ptr << ")" << std::endl;
    }
};

int main() {
    UnsafeBuffer obj1(50);
    UnsafeBuffer obj2 = obj1; // 얕은 복사 발생

    obj1.display();
    obj2.display();
    
    // 프로그램 종료 시 obj1과 obj2가 동일 주소를 두 번 delete 하려 하여 런타임 에러 발생 가능
    return 0;
}

3. 깊은 복사의 구현

깊은 복사를 구현하려면 복사 생성자와 복사 대입 연산자를 직접 정의하여 새로운 메모리를 할당하는 로직을 작성해야 합니다.

#include <iostream>

class SafeBuffer {
private:
    int* data_ptr;
public:
    SafeBuffer(int value) {
        data_ptr = new int(value);
    }

    // 깊은 복사 생성자
    SafeBuffer(const SafeBuffer& src) {
        data_ptr = new int(*src.data_ptr); // 새 메모리 할당 후 값 복사
        std::cout << "깊은 복사 생성자 호출" << std::endl;
    }

    // 깊은 복사 대입 연산자
    SafeBuffer& operator=(const SafeBuffer& src) {
        if (this != &src) { // 자기 대입 방지
            delete data_ptr; // 기존 메모리 해제
            data_ptr = new int(*src.data_ptr); // 새 할당 및 복사
        }
        std::cout << "깊은 복사 대입 연산자 호출" << std::endl;
        return *this;
    }

    ~SafeBuffer() {
        delete data_ptr;
    }

    void setValue(int v) { *data_ptr = v; }
    void print() const { std::cout << "값: " << *data_ptr << std::endl; }
};

int main() {
    SafeBuffer b1(100);
    SafeBuffer b2 = b1; // 깊은 복사
    
    b2.setValue(200);
    
    std::cout << "b1 "; b1.print(); // b1은 100 유지
    std::cout << "b2 "; b2.print(); // b2만 200으로 변경
    
    return 0;
}

4. 실무적인 동적 메모리 관리 예제: Matrix 클래스

아래 예제는 2차원 배열 데이터를 관리하는 클래스에서 깊은 복사를 처리하는 방식을 보여줍니다.

#include <iostream>
#include <algorithm>

class DataGrid {
private:
    int** grid;
    int rows, cols;

    void allocate(int r, int c) {
        rows = r;
        cols = c;
        grid = new int*[rows];
        for (int i = 0; i < rows; ++i) {
            grid[i] = new int[cols]{0};
        }
    }

    void clear() {
        if (grid) {
            for (int i = 0; i < rows; ++i) {
                delete[] grid[i];
            }
            delete[] grid;
            grid = nullptr;
        }
    }

public:
    DataGrid(int r, int c) {
        allocate(r, c);
        std::cout << "Grid 생성: " << r << "x" << c << std::endl;
    }

    // 깊은 복사 생성자
    DataGrid(const DataGrid& other) {
        allocate(other.rows, other.cols);
        for (int i = 0; i < rows; ++i) {
            std::copy(other.grid[i], other.grid[i] + cols, grid[i]);
        }
        std::cout << "Grid 깊은 복사 생성" << std::endl;
    }

    // 깊은 복사 대입 연산자
    DataGrid& operator=(const DataGrid& other) {
        if (this == &other) return *this;

        clear(); // 기존 자원 해제
        allocate(other.rows, other.cols);
        for (int i = 0; i < rows; ++i) {
            std::copy(other.grid[i], other.grid[i] + cols, grid[i]);
        }
        std::cout << "Grid 깊은 복사 대입" << std::endl;
        return *this;
    }

    ~DataGrid() {
        clear();
        std::cout << "Grid 소멸자 호출" << std::endl;
    }

    void set(int r, int c, int val) { grid[r][c] = val; }
    void show() const {
        for (int i = 0; i < rows; ++i) {
            for (int j = 0; j < cols; ++j) {
                std::cout << grid[i][j] << " ";
            }
            std::cout << "\n";
        }
    }
};

int main() {
    DataGrid g1(2, 2);
    g1.set(0, 0, 1);
    g1.set(1, 1, 9);

    DataGrid g2 = g1; // 복사 생성자
    g2.set(0, 0, 5);

    std::cout << "Grid 1:\n"; g1.show();
    std::cout << "Grid 2 (수정됨):\n"; g2.show();

    return 0;
}

5. 개발자를 위한 권장 사항

메모리 관리의 복잡성을 줄이기 위해 다음과 같은 설계 원칙을 준수하는 것이 좋습니다.

  1. Rule of Three/Five/Zero: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 필요하다면 세 가지 모두 정의해야 합니다(C++11 이후에는 이동 생성자/대입을 포함해 다섯 가지). 만약 직접 관리가 필요 없다면 표준 라이브러리를 사용하여 아무것도 정의하지 않는(Rule of Zero) 것이 가장 안전합니다.
  2. 스마트 포인터 활용: newdelete를 직접 사용하는 대신 std::unique_ptrstd::shared_ptr를 사용하여 자원 관리를 자동화하십시오.
  3. RAII 패턴 준수: 자원의 획득은 초기화와 동시에 이루어져야 하며(Resource Acquisition Is Initialization), 자원 해제는 소멸자에서 확실히 처리되도록 설계하십시오.

태그: C++ MemoryManagement RAII DeepCopy ShallowCopy

6월 27일 20:25에 게시됨