한 줄 요약: 얕은 복사는 멤버가 가리키는 주소를 그대로 복사할 수 있지만, 깊은 복사는 복사본이 독립적으로 소유할 자원을 새로 만들며 modern C++에서는 RAII 타입으로 이 책임을 맡기는 편이 안전합니다.
먼저 알아야 할 배경
C++에서 객체를 대입하거나 값으로 전달하면 복사가 일어날 수 있습니다. int나 std::string만 가진 클래스라면 컴파일러가 만들어 주는 멤버별 복사가 대체로 원하는 값 의미를 제공하지만, 직접 할당한 메모리를 원시 포인터로 소유하면 같은 기본 복사가 전혀 다른 결과를 만듭니다.
`copied = original`을 보면 original의 모든 내용이 새로운 공간에 복제된다고 기대하기 쉽습니다. 하지만 기본 복사는 각 멤버의 복사 규칙을 그대로 적용하므로 포인터 멤버에서는 포인터가 저장한 주소 값이 복사됩니다. 두 객체가 같은 주소를 자신의 자원이라고 생각하면 수정 내용이 섞이거나 소멸 시점에 같은 메모리를 두 번 해제할 수 있습니다.
얕은 복사 자체가 항상 버그인 것은 아닙니다. 관찰용 포인터나 공유 소유권처럼 같은 대상을 가리키는 것이 설계 의도일 수도 있습니다. 문제는 소유 관계가 타입과 복사 규칙에 드러나지 않은 채 두 객체가 모두 단독 소유자처럼 행동할 때 발생합니다.
핵심 개념
얕은 복사는 포인터가 가리키는 자원이 아니라 주소를 복사한다
클래스에 `int* data_`가 있고 복사 생성자를 직접 정의하지 않았다면, 암시적으로 생성된 복사 생성자는 data_ 멤버를 포인터의 복사 규칙대로 복사합니다. 새 배열을 만드는 것이 아니라 원본 포인터에 들어 있던 주소를 복사본 포인터에도 넣습니다. 그 결과 두 Buffer 객체의 data_가 같은 배열을 가리킵니다.
두 객체가 같은 자원을 비소유 방식으로 바라보는 설계라면 주소 공유가 의도일 수 있습니다. 하지만 각 소멸자가 `delete[] data_`를 호출하는 소유 클래스라면 둘 다 자신이 배열을 파괴해야 한다고 판단합니다. 먼저 파괴된 객체가 메모리를 해제한 뒤 다른 객체의 포인터는 댕글링 포인터가 되고, 두 번째 소멸자의 delete는 정의되지 않은 동작을 일으킵니다.
따라서 ‘기본 복사는 얕은 복사다’라고 모든 타입에 일반화하면 정확하지 않습니다. `std::vector<int>` 멤버의 기본 복사는 vector 자체의 복사 생성자를 호출해 별도 원소 저장소를 준비합니다. 멤버별 복사의 결과가 안전한 값 복사인지 주소 공유인지는 각 멤버 타입이 제공하는 복사 의미에 달려 있습니다.
깊은 복사는 복사본을 위한 독립 자원을 만든다
깊은 복사를 구현하는 복사 생성자는 먼저 원본과 같은 크기의 새 배열을 할당하고 원소 값을 복사합니다. 원본과 복사본의 data_는 서로 다른 주소를 가지므로 한쪽 원소를 수정해도 다른 쪽에는 영향을 주지 않습니다. 각 객체의 소멸자도 자신이 할당받은 배열만 해제합니다.
복사 대입은 기존 자원을 이미 가진 객체를 다룬다는 점에서 복사 생성보다 까다롭습니다. 이전 메모리를 누수 없이 정리해야 하고, `buffer = buffer` 같은 자기 대입에서도 원본 데이터를 먼저 지워서는 안 되며, 새 할당이 실패해도 객체를 유효한 상태로 유지하는 편이 좋습니다. 예제의 copy-and-swap 방식은 복사본을 먼저 만든 뒤 내부 상태를 교환해 이런 조건을 단순하게 처리합니다.
직접 깊은 복사를 제공하면 클래스는 값처럼 동작하지만 구현 책임도 커집니다. 배열 크기, 예외 안전성, 복사 비용, 이동 가능성까지 클래스가 관리해야 합니다. 자원 관리 자체가 클래스의 핵심 목적이 아니라면 표준 RAII 타입에 위임하는 편이 오류 가능성을 크게 줄입니다.
Rule of Three, Rule of Five, Rule of Zero를 연결해서 보기
C++03 관점의 Rule of Three는 사용자 정의 소멸자, 복사 생성자, 복사 대입 연산자 중 하나가 필요하다면 나머지도 함께 필요할 가능성이 높다는 경험 법칙입니다. 원시 자원을 직접 소유하는 클래스는 파괴 방식과 복제 방식을 일관되게 정의해야 하기 때문입니다. 하나만 구현하면 누수나 double delete가 다른 복사 경로에 남기 쉽습니다.
C++11 이후에는 이동 생성자와 이동 대입 연산자가 추가되어 Rule of Five로 확장됩니다. 큰 버퍼를 복사하는 대신 원본의 포인터를 새 객체로 이동하고 원본을 비어 있는 유효 상태로 만들면 불필요한 할당을 줄일 수 있습니다. 다만 이동 연산도 소유권 이전과 원본 상태를 정확히 구현해야 하므로 자동으로 안전해지는 것은 아닙니다.
Rule of Zero는 한 단계 더 나아가 사용자 정의 소멸자와 복사·이동 연산을 작성하지 않도록 설계하는 방향입니다. `std::vector`, `std::string`, `std::unique_ptr` 같은 RAII 멤버가 자원을 관리하면 바깥 클래스는 기본 연산을 그대로 사용하거나 필요한 복사 정책만 명시할 수 있습니다. 업무 도메인 로직과 저수준 메모리 관리를 분리할 수 있다는 점이 가장 큰 장점입니다.
메모리와 객체 관점에서 이해하기
얕은 복사 뒤에는 객체가 두 개지만 동적 배열은 하나뿐입니다. 두 data_ 멤버에는 동일한 주소가 저장되고 한 객체가 배열을 해제하는 순간 다른 객체는 존재하지 않는 자원을 가리키게 됩니다. 포인터 값이 nullptr이 아니라는 사실만으로는 그 주소의 객체 수명이 끝나지 않았다고 보장할 수 없습니다.
깊은 복사 뒤에는 객체마다 별도 배열이 존재합니다. 복사 생성 과정에서 새 저장소와 새 수명이 시작되므로 수정과 파괴가 독립적입니다. 대신 원소 수에 비례한 할당과 복사 비용이 발생하므로 정말 값 복제가 필요한지, 공유 또는 이동이 더 맞는지를 인터페이스 요구에서 결정해야 합니다.
`std::vector`를 멤버로 사용하면 vector가 배열 주소, 크기, 용량, 소멸을 함께 관리합니다. Buffer의 기본 복사는 vector의 값 복사를 사용하고 기본 이동은 저장소를 효율적으로 이전할 수 있으므로, 바깥 클래스가 raw address의 생명주기를 직접 맞출 필요가 없습니다.
코드로 확인하기
기본 복사로 같은 배열 주소를 공유하는 문제 확인하기
먼저 원시 포인터를 소유하면서 복사 연산을 정의하지 않은 클래스를 살펴보겠습니다. 이 코드는 컴파일될 수 있지만 main이 끝날 때 같은 배열을 두 번 해제하므로 실행하면 안 되는 잘못된 예시입니다.
#include <algorithm>
#include <cstddef>
class Buffer
{
public:
explicit Buffer(std::size_t size)
: size_(size), data_(new int[size]{})
{
}
~Buffer()
{
delete[] data_;
}
// 복사 생성자와 복사 대입 연산자를 정의하지 않았습니다.
// 컴파일러가 만든 기본 복사는 data_의 주소만 복사합니다.
private:
std::size_t size_;
int* data_;
};
int main()
{
Buffer original(3);
Buffer copied = original; // 얕은 복사
} // 두 소멸자가 같은 주소를 delete[] 하므로 정의되지 않은 동작
`Buffer copied = original`은 size_ 값과 data_ 주소를 멤버별로 복사합니다. copied를 위한 새 배열은 만들어지지 않습니다. 소멸 순서에서 copied가 배열을 먼저 해제하면 original.data_가 댕글링 상태가 되고, original의 소멸자가 다시 `delete[]`를 호출하면서 double delete에 해당하는 정의되지 않은 동작이 발생합니다.
Rule of Three로 깊은 복사 구현하기
원시 배열을 직접 소유해야 한다면 소멸자뿐 아니라 복사 생성자와 복사 대입 연산자도 자원 의미에 맞게 정의해야 합니다. 다음 C++17 예제는 새 배열을 만드는 복사 생성자와 copy-and-swap 대입을 사용합니다.
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer
{
public:
explicit Buffer(std::size_t size)
: size_(size), data_(new int[size]{})
{
}
~Buffer()
{
delete[] data_;
}
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_])
{
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(Buffer other)
{
swap(other);
return *this;
}
void swap(Buffer& other) noexcept
{
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
private:
std::size_t size_;
int* data_;
};
int main()
{
Buffer original(3);
Buffer copied = original;
Buffer assigned(1);
assigned = original;
}
복사 생성자는 other와 같은 크기의 배열을 할당한 뒤 원소를 복사합니다. 복사 대입은 매개변수 other가 성공적으로 만들어진 뒤 상태를 교환하므로 자기 대입과 할당 실패를 별도 분기로 다루지 않아도 됩니다. 이 예제는 Rule of Three에 집중했으며, 실제 자원 클래스라면 이동 생성자와 이동 대입까지 제공하거나 이동 가능 여부를 명시해 Rule of Five도 검토해야 합니다.
std::vector로 Rule of Zero에 가까운 값 타입 만들기
대부분의 애플리케이션 클래스는 배열 관리 자체가 목적이 아닙니다. 원시 포인터 대신 `std::vector<int>`를 멤버로 두면 복사와 파괴 규칙을 표준 컨테이너에 맡길 수 있습니다.
#include <cstddef>
#include <vector>
class Buffer
{
public:
explicit Buffer(std::size_t size) : data_(size, 0) {}
int& operator[](std::size_t index)
{
return data_[index];
}
const int& operator[](std::size_t index) const
{
return data_[index];
}
private:
std::vector<int> data_;
};
int main()
{
Buffer original(3);
original[0] = 10;
Buffer copied = original;
copied[0] = 20;
return original[0] == 10 && copied[0] == 20 ? 0 : 1;
}
Buffer가 소멸자나 복사 연산을 직접 작성하지 않아도 vector가 자원을 정리하고 복사본을 위한 독립 저장소를 관리합니다. copied의 첫 원소를 20으로 바꿔도 original은 10을 유지합니다. 이처럼 소유 자원을 RAII 멤버로 표현하면 double delete뿐 아니라 예외 경로의 누수와 불완전한 대입 구현도 줄일 수 있습니다.
자주 하는 실수
실수 1. 소멸자만 작성하고 복사 연산은 기본값으로 두기
원시 포인터를 해제하는 소멸자를 작성했다는 것은 클래스가 자원을 소유한다는 신호입니다. 이때 기본 복사를 그대로 두면 소유 주소만 복제되어 두 객체가 같은 자원을 해제하려 합니다.
문제 코드 — 잘못된 예시:
#include <algorithm>
#include <cstddef>
class Buffer
{
public:
explicit Buffer(std::size_t size)
: size_(size), data_(new int[size]{})
{
}
~Buffer()
{
delete[] data_;
}
// 복사 생성자와 복사 대입 연산자를 정의하지 않았습니다.
// 컴파일러가 만든 기본 복사는 data_의 주소만 복사합니다.
private:
std::size_t size_;
int* data_;
};
int main()
{
Buffer original(3);
Buffer copied = original; // 얕은 복사
} // 두 소멸자가 같은 주소를 delete[] 하므로 정의되지 않은 동작
개선 코드:
#include <cstddef>
#include <vector>
class Buffer
{
public:
explicit Buffer(std::size_t size) : data_(size, 0) {}
int& operator[](std::size_t index)
{
return data_[index];
}
const int& operator[](std::size_t index) const
{
return data_[index];
}
private:
std::vector<int> data_;
};
int main()
{
Buffer original(3);
original[0] = 10;
Buffer copied = original;
copied[0] = 20;
return original[0] == 10 && copied[0] == 20 ? 0 : 1;
}
설명: 직접 소유가 꼭 필요하면 Rule of Three 또는 Five를 완성해야 합니다. 하지만 도메인 클래스라면 개선 코드처럼 vector에 소유권을 위임하는 것이 더 단순합니다. 기본 복사 연산이 안전해지는 이유는 vector가 자신의 깊은 값 복사와 소멸 규칙을 이미 제공하기 때문입니다.
실수 2. 복사 대입에서 기존 자원을 먼저 지우기
복사 대입 대상은 이미 메모리를 소유하고 있을 수 있으므로 누수를 피하려고 먼저 delete를 호출하기 쉽습니다. 하지만 자기 대입이면 other가 같은 객체여서 복사할 원본까지 먼저 파괴하게 됩니다.
문제 코드 — 잘못된 예시:
Buffer& Buffer::operator=(const Buffer& other)
{
// 잘못된 예시: 자기 대입이면 other.data_도 먼저 해제됩니다.
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
개선 코드:
Buffer& Buffer::operator=(Buffer other)
{
swap(other);
return *this;
}
설명: copy-and-swap은 매개변수 복사본이 완성된 다음 현재 상태와 교환합니다. 자기 대입에서도 유효하고 새 배열 할당이 실패하면 기존 객체 상태가 바뀌지 않습니다. 매우 큰 자원이나 특별한 성능 요구가 있다면 측정 후 전용 대입 구현을 검토하되 예외 안전성과 자기 대입 조건을 함께 테스트해야 합니다.
실수 3. 하나의 raw pointer를 두 소유 객체에 전달하기
생성자가 `int*`만 받으면 호출자는 클래스가 주소를 빌리는지 소유권을 가져가는지 알기 어렵습니다. 같은 포인터를 두 Owner에 넘기면 두 소멸자가 모두 delete를 호출할 수 있습니다.
문제 코드 — 잘못된 예시:
class Owner
{
public:
explicit Owner(int* resource) : resource_(resource) {}
~Owner() { delete resource_; }
private:
int* resource_;
};
int main()
{
int* raw = new int(42);
Owner first(raw);
Owner second(raw); // 잘못된 예시: 소유자가 둘입니다.
}
개선 코드:
#include <memory>
#include <utility>
class Owner
{
public:
explicit Owner(std::unique_ptr<int> resource)
: resource_(std::move(resource))
{
}
private:
std::unique_ptr<int> resource_;
};
int main()
{
Owner owner(std::make_unique<int>(42));
}
설명: `std::unique_ptr<int>`를 값으로 받으면 소유권 이전이 타입과 `std::move` 호출에 드러납니다. unique_ptr은 복사할 수 없으므로 같은 자원을 실수로 두 단독 소유자에게 넘기는 경로도 차단합니다. 공동 수명이 실제 요구일 때만 `std::shared_ptr`를 검토하고, 단순 관찰자는 참조나 비소유 포인터를 사용합니다.
언제 어떤 방식을 선택해야 할까
객체가 독립적인 값처럼 복사되어야 하고 멤버가 `std::vector`, `std::string` 같은 값 타입뿐이라면 컴파일러가 만든 복사 연산을 우선 사용합니다. 멤버 타입의 복사 의미가 원하는 클래스 의미와 일치하는지 확인하는 것이 핵심입니다.
외부 자원을 단독 소유하지만 클래스 자체는 복사할 필요가 없다면 복사를 삭제하고 `std::unique_ptr`와 이동만 허용하는 설계가 더 명확할 수 있습니다. 복사가 필요하면 깊은 복사의 비용과 실패 조건을 API 사용자에게 예상 가능하게 만듭니다.
원시 new/delete를 직접 다루는 클래스는 메모리 관리 라이브러리나 매우 제한된 저수준 경계에서만 고려합니다. 일반 애플리케이션 코드에서는 RAII 타입으로 Rule of Zero를 지향하고, 공유가 필요한 경우에도 주소 복사와 소유권 공유를 같은 개념으로 취급하지 않습니다.
정리
얕은 복사는 멤버별 복사 결과 두 객체가 같은 자원을 가리킬 수 있는 상황이고, 깊은 복사는 복사본을 위한 독립 자원을 준비합니다. 원시 포인터 소유 클래스에서 기본 복사는 주소만 복사하므로 double delete와 댕글링 포인터로 이어질 수 있습니다.
직접 자원을 소유한다면 Rule of Three를 기본으로 보고 C++11 이후 이동까지 포함한 Rule of Five를 검토해야 합니다. 그러나 대부분의 코드에서는 `std::vector`, `std::string`, `std::unique_ptr`에 수명 관리를 맡기는 Rule of Zero가 더 안전하고 유지보수하기 쉽습니다.
modern C++에서 우선할 질문은 깊은 복사를 어떻게 손으로 구현할지가 아니라 클래스가 정말 raw resource를 직접 소유해야 하는지입니다. 값 타입과 RAII 소유권 타입으로 설계를 표현하면 복사 정책이 타입을 통해 자연스럽게 따라옵니다.
참고 자료
- cppreference: Copy constructors — 암시적으로 선언되는 복사 생성자와 멤버별 복사 규칙, 복사 생성자가 선택되는 조건을 확인할 때 참고합니다.
- cppreference: Copy assignment operator — 기본 복사 대입 연산과 사용자 정의 복사 대입 연산자의 규칙, 반환 형식과 생성 조건을 확인할 때 사용합니다.
- cppreference: Rule of three/five/zero — 소멸자, 복사, 이동 연산을 함께 설계해야 하는 이유와 RAII 멤버를 이용한 Rule of Zero 방향을 확인하기 좋습니다.
- Microsoft C++ documentation: Copy constructors and copy assignment operators — MSVC 환경에서 복사 생성자와 복사 대입 연산자의 선언 및 호출 예제를 비교할 때 참고합니다.
'C++ > C++ 기초' 카테고리의 다른 글
| C++ RAII란 무엇일까? new/delete 없이 자원을 안전하게 관리하는 방법: 스코프와 소멸자로 정리 책임을 묶는 C++ 습관 (0) | 2026.07.01 |
|---|---|
| C++ std::move는 무엇을 이동할까? 이동 생성자와 이동 대입 연산자 이해하기: std::move는 값을 옮기는 함수가 아니라 이동 가능 표시다 (0) | 2026.06.29 |
| C++ const 위치별 의미 정리: const int*, int* const부터 멤버 함수까지 (0) | 2026.06.23 |
| C++ 포인터와 참조 차이: null 가능성, 수명, 소유권으로 구분하기 (2) | 2026.06.21 |