1. 개요 및 핵심 개념
C++에서 객체를 복사할 때 메모리를 어떻게 다루느냐에 따라 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)로 구분됩니다. 이는 특히 클래스 내부에서 동적 메모리 할당을 사용할 때 프로그램의 안정성을 결정짓는 매우 중요한 요소입니다.
- 얕은 복사 (Shallow Copy): 객체의 멤버 변수 값을 그대로 복사합니다. 만약 멤버가 포인터라면, 포인터가 가리키는 주소 값만 복사됩니다. 결과적으로 두 객체가 동일한 메모리 자원을 공유하게 됩니다.
- 깊은 복사 (Deep Copy): 포인터가 가리키는 실제 자원을 위한 별도의 메모리를 새로 할당하고, 그 내부의 데이터까지 모두 복사합니다. 두 객체는 서로 독립적인 메모리 공간을 가집니다.
1.1 깊은 복사가 필요한 경우
- 클래스 내부에
new나malloc을 통해 할당된 동적 메모리(포인터 멤버)가 있는 경우. - 파일 핸들, 네트워크 소켓, 데이터베이스 연결 등 독점적인 제어가 필요한 외부 자원을 관리할 때.
- 복사본의 수정이 원본 객체에 영향을 주지 않아야 하는 독립적인 데이터 관리가 필요할 때.
1.2 얕은 복사로 충분한 경우
- 멤버 변수가 기본 타입(int, double, bool 등)으로만 구성된 경우.
std::string,std::vector,std::shared_ptr와 같이 자체적으로 복사 세맨틱이 구현된 표준 라이브러리 객체를 사용하는 경우.- 객체의 복사가 발생하지 않거나, 읽기 전용으로만 공유되는 구조인 경우.
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. 개발자를 위한 권장 사항
메모리 관리의 복잡성을 줄이기 위해 다음과 같은 설계 원칙을 준수하는 것이 좋습니다.
- Rule of Three/Five/Zero: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 필요하다면 세 가지 모두 정의해야 합니다(C++11 이후에는 이동 생성자/대입을 포함해 다섯 가지). 만약 직접 관리가 필요 없다면 표준 라이브러리를 사용하여 아무것도 정의하지 않는(Rule of Zero) 것이 가장 안전합니다.
- 스마트 포인터 활용:
new와delete를 직접 사용하는 대신std::unique_ptr나std::shared_ptr를 사용하여 자원 관리를 자동화하십시오. - RAII 패턴 준수: 자원의 획득은 초기화와 동시에 이루어져야 하며(Resource Acquisition Is Initialization), 자원 해제는 소멸자에서 확실히 처리되도록 설계하십시오.