C++에서 **복사생성자(Copy Constructor)**는 객체가 다른 객체의 복사본으로 초기화될 때 호출되는 특별한 생성자입니다. 이는 객체 지향 프로그래밍에서 객체의 상태를 안전하고 정확하게 복제하는 데 필수적인 요소입니다. 특히 메모리 관리가 중요한 C++에서 복사생성자의 올바른 이해는 메모리 누수나 이중 해제와 같은 심각한 오류를 방지하는 핵심입니다. 2024년에도 중요한 개념이었으며, 2025년 최신 C++ 표준(C++20 이후)에서도 객체의 값 복사 의미론을 정의하는 데 있어 그 중요성은 변함이 없습니다. 이 글에서는 복사생성자의 기본 개념부터, 프로그래머가 반드시 이해해야 할 ‘얕은 복사(Shallow Copy)’와 ‘깊은 복사(Deep Copy)’의 차이점 및 구체적인 활용 사례를 자세히 다룹니다.
📚 함께 읽으면 좋은 글
복사생성자는 보통 다음과 같은 세 가지 경우에 호출됩니다.
- 새 객체가 기존 객체를 사용하여 초기화될 때 (예:
MyClass obj2 = obj1;또는MyClass obj2(obj1);) - 함수에 객체가 값으로 전달될 때 (Pass-by-value)
- 함수가 객체를 값으로 반환할 때
이러한 동작 방식은 프로그램의 안정성과 성능에 직접적인 영향을 미치므로, 객체가 동적 메모리를 포함하고 있는지에 따라 적절한 복사 방법을 구현해야 합니다.
C++ 기본 복사생성자(Default Copy Constructor)의 작동 원리 확인하기
사용자가 복사생성자를 명시적으로 정의하지 않으면 C++ 컴파일러는 기본 복사생성자(Default Copy Constructor)를 자동으로 생성합니다. 이 기본 복사생성자는 멤버별 복사(Member-wise Copy) 방식으로 동작합니다. 즉, 객체의 모든 멤버 변수를 순서대로 복사본 객체로 복사합니다. 이는 단순한 데이터 타입(예: int, double 등)이나 다른 객체의 복사생성자가 정의되어 있는 경우에는 문제없이 작동합니다.
그러나 문제는 객체가 동적으로 할당된 메모리를 가리키는 포인터 멤버를 가지고 있을 때 발생합니다. 기본 복사생성자는 단순히 포인터의 ‘값'(메모리 주소)만을 복사합니다. 그 결과, 원본 객체와 복사본 객체의 포인터가 같은 메모리 공간을 가리키게 됩니다. 이것이 바로 얕은 복사의 본질이며, 예상치 못한 부작용을 초래합니다.
얕은 복사 문제점과 해결책 상세 더보기
얕은 복사(Shallow Copy)의 가장 큰 문제점은 ‘이중 해제(Double Deletion)’입니다. 원본 객체와 복사본 객체가 소멸될 때, 각각의 소멸자는 포인터가 가리키는 동일한 메모리 영역에 대해 delete 연산을 두 번 시도하게 됩니다. 첫 번째 delete는 성공적으로 메모리를 해제하지만, 두 번째 delete는 이미 해제된 메모리를 건드려 프로그램 충돌(Crash)이나 정의되지 않은 행동(Undefined Behavior)을 유발합니다. 또한, 한 객체에서 포인터가 가리키는 데이터를 수정하면 다른 객체에서도 데이터가 함께 변경되어 데이터 무결성 문제가 발생합니다.
이러한 문제를 해결하는 유일한 방법은 **깊은 복사(Deep Copy)**를 구현하는 것입니다. 깊은 복사를 위해서는 프로그래머가 직접 복사생성자를 정의해야 합니다. 깊은 복사는 단순히 포인터의 주소만 복사하는 것이 아니라, 포인터가 가리키는 데이터까지 새로운 메모리 공간에 할당하고 복사하여, 원본 객체와 복사본 객체가 완전히 독립된 메모리 영역을 소유하도록 만듭니다.
깊은 복사(Deep Copy) 구현 방법과 코드 예제 확인하기
깊은 복사(Deep Copy)를 구현하는 복사생성자는 다음의 구조를 따라야 합니다. 이 구현은 동적 메모리를 사용하는 클래스에 있어 가장 중요한 부분입니다.
구체적인 구현 단계:
- 새로운 객체가 복사할 동적 메모리 영역을 새롭게 할당합니다 (
new사용). - 원본 객체의 포인터가 가리키는 데이터를 새로 할당된 메모리 영역으로 복사합니다.
- 새 객체의 포인터 멤버가 새로 할당된 이 메모리 공간을 가리키도록 설정합니다.
다음은 깊은 복사생성자의 일반적인 C++ 코드 예제입니다.
class MyDynamicArray { private: int* data; size_t size; public: // 일반 생성자 MyDynamicArray(size_t s) : size(s) { data = new int[size]; }
// **깊은 복사생성자**
MyDynamicArray(const MyDynamicArray& other) : size(other.size) {
// 1. 새로운 메모리 할당
data = new int[size];
// 2. 데이터 복사
std::copy(other.data, other.data + size, data);
}
// 소멸자 (메모리 해제)
~MyDynamicArray() {
delete[] data;
}
// 복사 할당 연산자도 깊은 복사로 구현해야 합니다 (삼 법칙, Rule of Three)
// ...
};
이처럼 깊은 복사생성자를 정의하면, 두 객체는 서로 완전히 독립된 데이터 사본을 가지게 되어 한 객체의 변경이 다른 객체에 영향을 미치지 않으며, 소멸 시에도 안전하게 메모리가 한 번씩만 해제됩니다. 복사 할당 연산자(Copy Assignment Operator) 또한 같은 원리로 깊은 복사를 구현해야 하며, 이를 ‘삼 법칙(Rule of Three)’ 또는 최신 C++에서는 ‘다섯 법칙(Rule of Five)’이라고 부릅니다.
C++에서 복사생성자, 복사 할당, 소멸자의 삼 법칙 상세 더보기
C++ 프로그래밍에서 동적으로 할당된 자원(메모리, 파일 핸들, 네트워크 연결 등)을 클래스가 소유하고 있을 때, 프로그래머는 클래스의 안정성을 보장하기 위해 다음 세 가지 멤버 함수를 명시적으로 정의해야 합니다. 이것이 전통적인 ‘삼 법칙(Rule of Three)’입니다. 이 법칙은 2024년에도 C++ 개발자들에게 여전히 중요한 원칙으로 남아있습니다.
- 소멸자 (~Destructor): 객체가 소멸될 때 동적으로 할당된 자원을 해제합니다.
- 복사생성자 (Copy Constructor): 객체를 복사할 때 깊은 복사를 수행하여 독립적인 사본을 만듭니다.
- 복사 할당 연산자 (Copy Assignment Operator,
operator=): 객체를 할당할 때(objA = objB) 깊은 복사를 수행합니다.
만약 이 세 가지 중 하나라도 정의해야 한다면, 일반적으로 나머지 두 가지도 함께 정의해야 합니다. 그렇지 않으면 컴파일러가 생성하는 기본(얕은 복사 기반) 구현 때문에 자원 누수나 이중 해제와 같은 문제가 발생할 수 있습니다. 예를 들어, 소멸자를 정의하여 동적 메모리를 해제하기 시작했다면, 복사생성자와 복사 할당 연산자도 반드시 깊은 복사를 구현해야 합니다. 특히, C++11 이후에는 ‘이동 생성자(Move Constructor)’와 ‘이동 할당 연산자(Move Assignment Operator)’가 추가되어 ‘다섯 법칙(Rule of Five)’으로 확장되었습니다.
복사생성자를 사용하지 않아야 할 경우와 대안 보기
모든 상황에서 복사생성자를 허용하는 것이 최선은 아닙니다. 일부 객체는 그 특성상 복사가 허용되어서는 안 됩니다. 예를 들어, 고유 자원(Unique Resource)을 관리하는 클래스(예: 스마트 포인터의 std::unique_ptr)나, ID나 연결 상태 등 고유한 상태를 가져야 하는 클래스(예: DB 연결 클래스)는 복사 자체가 프로그램의 논리적 오류를 유발할 수 있습니다. 이러한 경우, 개발자는 복사 동작을 의도적으로 금지해야 합니다.
C++11 이후에는 복사 동작을 명시적으로 금지하는 가장 좋은 방법은 **= delete**를 사용하는 것입니다.
class NonCopyableResource { public: // ... 일반 멤버 함수
// 복사생성자 금지
NonCopyableResource(const NonCopyableResource& other) = delete;
// 복사 할당 연산자 금지
NonCopyableResource& operator=(const NonCopyableResource& other) = delete;
// ...
};
복사를 금지하는 것은 객체의 수명과 소유권 관리를 단순화하고, 잠재적인 오류를 줄일 수 있습니다. 복사 대신 객체를 전달해야 할 때는 **참조(reference)**를 사용하여 복사 비용을 없애거나, **이동 의미론(Move Semantics)**을 사용하여 자원의 소유권을 효율적으로 이전하는 것이 현대 C++의 표준적인 접근 방식입니다.
2025년 시점에서 C++ 프로그래밍은 RAII(Resource Acquisition Is Initialization) 원칙과 스마트 포인터를 적극적으로 사용하여 수동적인 자원 관리의 필요성을 줄이고 있습니다. 그럼에도 불구하고, 사용자 정의 클래스에서 동적 메모리를 직접 관리해야 하는 경우 복사생성자와 깊은 복사의 개념은 C++의 기본을 이루는 중요한 지식입니다. 안전하고 효율적인 객체 복사는 고성능 C++ 애플리케이션 개발의 초석이 됩니다.
📌 추가로 참고할 만한 글
자주 묻는 질문 (FAQ)
A: 가장 큰 차이점은 동적 메모리 처리 방식입니다. 얕은 복사는 포인터 멤버의 값(메모리 주소)만 복사하여 두 객체가 같은 메모리 공간을 공유하게 만듭니다. 반면, 깊은 복사는 포인터가 가리키는 실제 데이터까지 새로운 메모리 공간에 복사하여 두 객체가 완전히 독립된 데이터 사본을 가지게 만듭니다. 이로 인해 깊은 복사는 메모리 이중 해제 문제를 방지합니다.
A: 클래스에 동적으로 할당된 자원(new로 할당된 메모리를 가리키는 포인터 등)이 포함되어 있지 않다면, 컴파일러가 생성하는 기본 복사생성자를 사용해도 안전합니다. 즉, 모든 멤버 변수가 스택에 저장되는 기본 타입(int, double)이나 동적 메모리를 스스로 안전하게 관리하는 클래스(예: std::string, std::vector)의 객체로만 구성되어 있을 때입니다.
A: 다섯 법칙은 ‘삼 법칙(소멸자, 복사생성자, 복사 할당 연산자)’에 **이동 생성자(Move Constructor)**와 이동 할당 연산자(Move Assignment Operator) 두 가지를 추가한 것입니다. 이 두 가지 이동 함수는 객체의 자원 소유권을 효율적으로 이전(복사 대신)하는 역할을 하며, 복사 동작이 비효율적이거나 불필요할 때 프로그램의 성능을 크게 향상시킵니다. 동적 자원을 관리하는 클래스는 이 다섯 가지 함수를 모두 고려해야 합니다.
A: 복사생성자의 매개변수는 반드시 상수 참조(const MyClass& other)로 받아야 합니다. 만약 값(Pass-by-value)으로 받게 되면, 함수 호출 시 매개변수를 초기화하기 위해 또 다시 복사생성자가 호출되는 무한 재귀 호출이 발생하게 됩니다. 참조를 사용하면 객체를 복사하지 않고도 원본 객체에 접근할 수 있어 이 문제를 해결합니다.