얕은 복사와 깊은 복사 (Shallow Copy, Deep Copy)
한눈에 보기
주소만 복사하는 얕은 복사와 힙 데이터까지 복제하는 깊은 복사의 차이, 이중 해제 문제와 복사 생성자 구현, 3의 법칙과 5의 법칙, 이동 의미론과 스마트 포인터 기반 해법을 다룬다.
최종 업데이트 2026.09.17
얕은 복사(Shallow Copy)는 객체의 멤버 값을 그대로 복사해 포인터 멤버가 있으면 주소만 복사되는 방식이고, 깊은 복사(Deep Copy)는 포인터가 가리키는 대상까지 새로 할당해 통째로 복제하는 방식이다. C++에서 컴파일러가 자동 생성하는 복사 생성자(Copy Constructor)와 복사 대입 연산자는 얕은 복사를 수행하므로, 동적 자원을 직접 소유한 클래스는 이중 해제(Double Free) 같은 문제를 피하기 위해 깊은 복사를 직접 구현하거나 자원 관리를 표준 도구에 맡겨야 한다.
두 방식의 차이
얕은 복사 후에는 원본과 사본의 포인터 멤버가 같은 힙(Heap) 메모리를 가리킨다. 한쪽에서 값을 바꾸면 다른 쪽에도 반영되고, 한쪽이 소멸하며 메모리를 해제하면 다른 쪽은 댕글링 포인터(Dangling Pointer)를 쥐게 된다. 두 객체가 모두 소멸하면 같은 메모리를 두 번 해제하는 이중 해제로 프로그램이 죽는다. 깊은 복사 후에는 두 객체가 각자 독립된 메모리를 소유하므로 이런 간섭이 없다.
| 구분 | 얕은 복사 | 깊은 복사 |
|---|---|---|
| 복사 대상 | 멤버 값(주소 포함)만 | 가리키는 힙 데이터까지 |
| 복사 후 관계 | 같은 자원을 공유 | 완전히 독립 |
| 비용 | 저렴 | 할당과 복사 비용 발생 |
| 위험 | 이중 해제, 의도치 않은 공유 | 구현을 빠뜨리면 얕은 복사로 회귀 |
문제가 생기는 코드와 깊은 복사 구현
#include <cstring>
class Text {
char *buf;
public:
Text(const char *s) {
buf = new char[std::strlen(s) + 1];
std::strcpy(buf, s);
}
~Text() { delete[] buf; }
/* 깊은 복사 생성자 */
Text(const Text &other) {
buf = new char[std::strlen(other.buf) + 1];
std::strcpy(buf, other.buf);
}
/* 깊은 복사 대입 연산자 */
Text &operator=(const Text &other) {
if (this != &other) { /* 자기 대입 방지 */
char *tmp = new char[std::strlen(other.buf) + 1];
std::strcpy(tmp, other.buf);
delete[] buf;
buf = tmp;
}
return *this;
}
};
복사 생성자와 대입 연산자를 정의하지 않으면 컴파일러 기본 버전이 buf 주소만 복사해, 두 Text 객체의 소멸자가 같은 배열을 두 번 delete하게 된다. 위처럼 새 공간을 할당해 내용을 복사하면 문제가 사라진다. 대입 연산자에서는 자기 자신에게 대입하는 경우를 걸러야 하고, 새 할당을 먼저 성공시킨 뒤 기존 자원을 해제해야 할당 실패 시에도 객체가 온전한 예외 안전성(Exception Safety)을 확보할 수 있다.
3의 법칙, 5의 법칙과 이동 의미론
소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 직접 정의해야 한다면 셋 다 정의해야 한다는 것이 3의 법칙(Rule of Three)이다. 자원을 직접 소유한다는 신호이기 때문이다. C++11부터는 이동 생성자(Move Constructor)와 이동 대입 연산자가 추가되어 5의 법칙(Rule of Five)으로 확장되었다. 이동(Move)은 깊은 복사 대신 포인터 소유권만 넘기고 원본을 빈 상태로 만드는 기법으로, 곧 사라질 임시 객체에서 자원을 가져올 때 큰 할당과 복사 비용을 없앤다.
Text(Text &&other) noexcept : buf(other.buf) {
other.buf = nullptr; /* 원본은 소유권을 잃는다 */
}
이동 후 원본의 소멸자가 nullptr를 delete하는 것은 안전하므로 이중 해제가 발생하지 않는다. std::move는 실제로 무언가를 옮기는 함수가 아니라 이동 생성자가 선택되도록 인자를 오른값(Rvalue)으로 캐스팅하는 표시일 뿐이라는 점도 함께 알아 두어야 한다.
모던 C++의 실전 해법
가장 좋은 방법은 자원을 날 것의 포인터로 직접 관리하지 않는 것이다. 위 예시의 char 버퍼를 std::string으로 바꾸면 string이 스스로 깊은 복사와 해제를 처리하므로, 컴파일러 기본 복사만으로 올바르게 동작한다. 이렇게 특별한 멤버 함수를 하나도 정의할 필요가 없게 설계하라는 지침을 0의 법칙(Rule of Zero)이라 부른다. 소유권 표현이 필요하면 스마트 포인터(Smart Pointer)를 쓴다. std::unique_ptr는 복사가 금지되고 이동만 허용되어 단독 소유를 강제하고, std::shared_ptr는 참조 횟수(Reference Count)로 공유 소유를 관리해 마지막 소유자가 사라질 때 해제한다. shared_ptr의 복사는 대상 복제가 아니라 참조 횟수 증가라는 점에서, 얕은 복사의 공유 구조를 안전하게 만든 형태라고 이해할 수 있다.
면접에서 이렇게 물어본다
- 얕은 복사와 깊은 복사의 차이를 메모리 그림을 그리듯 설명해 보라.
- 포인터 멤버를 가진 클래스에서 기본 복사 생성자를 그대로 쓰면 어떤 문제가 왜 발생하는가?
- 3의 법칙과 5의 법칙은 무엇이며, 0의 법칙이 권장되는 이유는 무엇인가?
- 복사와 이동의 차이는 무엇이고, std::move는 실제로 무엇을 하는가?
- 대입 연산자 구현에서 자기 대입 검사와 예외 안전성을 어떻게 처리해야 하는가?
C / C++에서 같이 읽기
- C 언어 컴파일 과정 (Compile Process)C 소스 코드가 전처리, 컴파일, 어셈블, 링킹 네 단계를 거쳐 실행 파일이 되는 과정을 단계별 산출물, 목적 파일의 섹션 구조, 정적 링킹과 동적 링킹의 차이와 함께 정리한다.
- C 동적 할당 (malloc, free)실행 중에 힙 메모리를 확보하고 반납하는 C의 동적 할당을 malloc, calloc, realloc, free의 동작과 할당기의 내부 구조, 메모리 누수와 댕글링 포인터 같은 대표 오류 중심으로 설명한다.
- 포인터 (Pointer)다른 변수의 메모리 주소를 저장하는 포인터의 개념과 역참조, 포인터 산술, 배열과의 관계, 이중 포인터와 함수 포인터, 그리고 널 포인터와 댕글링 포인터 같은 오류 상황까지 정리한다.
- 구조체 메모리 크기와 정렬, 패딩 (Struct Alignment)구조체 크기가 멤버 합보다 커지는 이유인 메모리 정렬과 패딩의 규칙을 오프셋 계산 예시로 설명하고, 멤버 순서에 따른 크기 차이와 #pragma pack 사용 시 주의점을 다룬다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기