1. 깊은 복사와 얕은 복사
객체 지향 프로그래밍에서 한 객체의 상태를 다른 객체로 복사해야 하는 경우는 매우 흔합니다. 이때 복사를 수행하는 방식에 따라 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)로 나뉩니다. 두 방식의 핵심적인 차이는 '객체 내의 참조(포인터) 멤버를 어떻게 처리하느냐'에 있으며, 메모리 관리, 데이터 무결성, 그리고 프로그램의 안정성에 큰 영향을 미칩니다.
2. 얕은 복사 (Shallow Copy)
2.1. 이론 및 개념
얕은 복사는 객체를 복사할 때, 해당 객체의 멤버 변수 값들을 그대로 복사하는 방식입니다. 만약 멤버 변수가 값(Primitive Type)이라면 해당 값이 복사되지만, 만약 참조(Reference Type, e.g., 포인터, 주소)라면 참조값(주소) 자체가 복사됩니다.
결과적으로, 원본 객체와 복사된 객체는 동일한 메모리 주소를 가리키는 멤버를 공유하게 됩니다. 이를 '데이터를 공유한다'라고 표현하며, 한쪽 객체에서 공유된 데이터를 변경하면 다른 쪽 객체에도 변경 사항이 그대로 반영됩니다.
2.2. 내부 구조 및 구현 방식
얕은 복사는 대부분의 프로그래밍 언어에서 기본적으로 제공하는 복사 메커니즘입니다.
사용자가 복사 생성자나 대입 연산자를 별도로 정의하지 않으면, 컴파일러는 멤버 대 멤버(member-wise) 복사를 수행하는 기본적인 얕은 복사 코드를 자동으로 생성합니다.
class MyArray {
public:
int* data;
int size;
MyArray(int s) : size(s) {
data = new int[size];
}
~MyArray() {
delete[] data;
}
// 복사 생성자를 정의하지 않으면 컴파일러가 아래와 유사하게 생성
// MyArray(const MyArray& other) : data(other.data), size(other.size) {}
};
int main() {
MyArray arr1(5);
for(int i=0; i<5; ++i) arr1.data[i] = i;
MyArray arr2 = arr1; // 얕은 복사 발생
arr2.data[0] = 100; // arr2를 수정했지만...
// arr1.data[0]도 100으로 변경됨!
return 0; // 프로그램 종료 시 이중 해제(double free) 문제 발생
}
위 예제에서 arr2가 소멸될 때 data 포인터를 해제하고, 이후 arr1이 소멸될 때 이미 해제된 메모리를 또다시 해제하려고 시도하면서 이중 해제(Double Free) 오류가 발생하여 프로그램이 비정상 종료됩니다.
2.3. 장단점 및 사용 사례
- 장점:
- 빠른 속도: 단순히 주소값만 복사하므로, 깊은 복사에 비해 월등히 빠릅니다.
- 메모리 효율성: 추가적인 데이터 공간을 할당하지 않아 메모리를 절약할 수 있습니다.
- 단점:
- 예기치 않은 데이터 변경 (Side Effect): 공유된 데이터를 한쪽에서 수정하면 다른 쪽에도 영향을 미쳐 데이터의 무결성이 깨질 수 있습니다.
- 댕글링 포인터 (Dangling Pointer): 원본 객체가 소멸되어 메모리가 해제되면, 복사된 객체는 유효하지 않은 메모리를 가리키게 되어 심각한 런타임 오류를 유발할 수 있습니다. (e.g., C++의 이중 해제 문제)
- 사용 사례:
- 객체의 데이터가 불변(Immutable)임이 보장될 때.
- 성능 최적화가 매우 중요하며, 데이터 공유의 부작용을 명확히 인지하고 제어할 수 있을 때.
- 의도적으로 데이터를 공유하여 두 객체가 항상 같은 상태를 유지하도록 설계할 때.
3. 깊은 복사 (Deep Copy)
3.1. 이론 및 개념
깊은 복사는 객체를 복사할 때, 객체의 모든 멤버를 재귀적으로 복사하여 완전히 새로운 객체를 생성하는 방식입니다. 참조(포인터) 멤버가 있다면, 해당 참조가 가리키는 실제 데이터 공간까지 새로 할당하여 그 내용을 복사합니다.
결과적으로, 원본 객체와 복사된 객체는 완전히 독립적인 메모리 공간을 차지하게 됩니다. 따라서 한쪽 객체의 데이터를 변경해도 다른 쪽 객체에 아무런 영향을 주지 않습니다.
3.2. 내부 구조 및 구현 방식
깊은 복사는 프로그래머가 직접 구현해야 하는 경우가 많습니다.
복사 생성자와 대입 연산자를 오버로딩하여 동적 할당된 메모리를 새로 할당하고 내용을 복사하는 코드를 명시적으로 작성해야 합니다. (Rule of Three/Five/Zero)
class MyArray {
public:
int* data;
int size;
// ... 생성자, 소멸자 ...
// 깊은 복사를 위한 복사 생성자
MyArray(const MyArray& other) : size(other.size) {
data = new int[size]; // 1. 새로운 메모리 공간을 할당
for (int i = 0; i < size; ++i) {
data[i] = other.data[i]; // 2. 내용을 일일이 복사
}
}
// 깊은 복사를 위한 대입 연산자 오버로딩
MyArray& operator=(const MyArray& other) {
if (this == &other) return *this; // 1. 자기 자신과의 대입 방지
delete[] data; // 2. 기존 메모리 해제
size = other.size;
data = new int[size]; // 3. 새로운 메모리 할당
for (int i = 0; i < size; ++i) {
data[i] = other.data[i]; // 4. 내용 복사
}
return *this;
}
};
int main() {
MyArray arr1(5);
for(int i=0; i<5; ++i) arr1.data[i] = i;
MyArray arr2 = arr1; // 깊은 복사 발생 (복사 생성자 호출)
arr2.data[0] = 100;
// arr1.data[0]은 여전히 0
// 이중 해제 문제도 발생하지 않음
return 0;
}
3.3. 장단점 및 사용 사례
- 장점:
- 데이터 안정성 및 무결성: 원본과 복사본이 완전히 분리되어 있어, 예기치 않은 변경으로부터 원본 데이터를 안전하게 보호할 수 있습니다.
- 직관적인 동작: 복사본을 수정해도 원본에 영향이 없다는 점이 프로그래머의 의도와 일치하여 버그 발생 가능성을 줄입니다.
- 단점:
- 느린 속도: 객체의 모든 데이터를 재귀적으로 복사하고 새로운 메모리를 할당해야 하므로 오버헤드가 큽니다.
- 많은 메모리 사용량: 복사할 데이터의 크기만큼 추가적인 메모리가 필요합니다.
- 복잡한 구현: 순환 참조나 복잡한 객체 구조를 가질 경우 직접 구현하기가 까다롭습니다.
- 사용 사례:
- 복사된 객체를 수정한 결과가 원본 객체에 영향을 미치면 안 되는 대부분의 경우.
- 객체의 특정 시점 상태를 저장하는 스냅샷(Snapshot) 기능이나 실행 취소(Undo) 기능을 구현할 때.
- 다중 스레드 환경에서 스레드 간 데이터 공유 없이, 각 스레드가 독립적인 데이터 복사본을 가지고 작업을 수행하게 하여 데이터 경합(Race Condition)을 방지하고자 할 때.
4. 얕은 복사 vs. 깊은 복사 비교
| 구분 | 얕은 복사 (Shallow Copy) | 깊은 복사 (Deep Copy) |
|---|---|---|
| 복사 대상 | 객체의 멤버 값 (참조 변수의 경우 주소값) | 객체가 참조하는 실제 데이터까지 모두 |
| 데이터 독립성 | 낮음 (내부 데이터 공유) | 높음 (완전히 독립적인 데이터) |
| 성능 (속도) | 빠름 | 느림 |
| 메모리 사용량 | 적음 | 많음 |
| 구현 복잡도 | 낮음 (언어에서 기본 제공) | 높음 (직접 구현 필요성 존재) |
| Side Effect | 발생 가능성 높음 | 발생하지 않음 |
| 주요 사용 사례 | - 데이터가 불변(Immutable)일 때 - 의도적으로 데이터를 공유/동기화할 때 - 극도의 성능 최적화가 필요할 때 |
- 복사본의 수정이 원본에 영향을 주면 안 될 때 - 객체의 스냅샷, 실행 취소 기능 구현 시 - 스레드 안전성을 위해 데이터 복사본을 만들 때 |
5. 동작 계층 구조
- 사용자 영역 (User Space) - Application
- 프로그래머가 코드 상에서 객체 복사를 요청합니다. (
MyObject b = a;,b = a;,b = copy.deepcopy(a);)
- 프로그래머가 코드 상에서 객체 복사를 요청합니다. (
- 사용자 영역 (User Space) - Library / Runtime
- 얕은 복사:
- C++ 컴파일러가 생성한 기본 복사/대입 코드 또는 Python의
copy()함수가 실행됩니다. malloc()이나new를 통해 새 객체를 담을 최상위 메모리 블록만 할당받습니다.memcpy()와 유사한 방식으로 원본 객체의 멤버 변수 영역을 그대로 복사합니다. 포인터 변수는 주소값이 그대로 복사됩니다.
- C++ 컴파일러가 생성한 기본 복사/대입 코드 또는 Python의
- 깊은 복사:
- 프로그래머가 직접 정의한 C++ 복사 생성자/대입 연산자 또는 Python의
deepcopy()함수가 실행됩니다. - 최상위 객체 메모리 할당 후, 멤버를 순회합니다.
- 포인터/참조 멤버를 발견하면, 해당 멤버가 가리키는 데이터의 크기만큼 추가적인 메모리 할당을
malloc()/new를 통해 요청합니다. - 할당받은 새 메모리 공간에 원본 데이터를 복사합니다. 이 과정은 객체 그래프의 끝에 도달할 때까지 재귀적으로 반복됩니다.
- 프로그래머가 직접 정의한 C++ 복사 생성자/대입 연산자 또는 Python의
- 얕은 복사:
- 커널 영역 (Kernel Space)
malloc(),new와 같은 라이브러리 함수는 내부적으로 시스템 콜(System Call)(e.g.,brk,mmap)을 호출하여 운영체제에 메모리를 요청합니다.- 커널의 메모리 관리자(Memory Manager)는 해당 프로세스의 가상 주소 공간(Virtual Address Space)에 물리 메모리(RAM) 페이지를 매핑하고, 할당된 가상 주소의 시작점을 반환합니다.
- 하드웨어 (Hardware)
- CPU는 메모리 복사(e.g.,
MOV명령어)나 데이터 쓰기/읽기 명령을 실행합니다. - MMU(Memory Management Unit)는 CPU가 요청한 가상 주소를 실제 물리 RAM 주소로 변환하여 메모리 버스를 통해 RAM에 접근합니다.
- RAM은 변환된 물리 주소에 해당하는 위치에 데이터를 저장하거나, 해당 위치의 데이터를 읽어 CPU에 제공합니다.
- CPU는 메모리 복사(e.g.,
6. 요약
깊은 복사와 얕은 복사의 가장 핵심적인 차이는 '데이터를 공유하느냐, 아니면 완전히 독립적인 복사본을 만드느냐' 입니다.
얕은 복사는 객체의 멤버 변수 값을 그대로 복사하는 방식입니다. 만약 멤버 변수가 포인터나 참조 변수라면, 그 주소값 자체를 복사하기 때문에 원본 객체와 복사된 객체가 내부의 데이터를 공유하게 됩니다. 그래서 속도가 빠르고 메모리를 아낄 수 있다는 장점이 있지만, 한쪽에서 공유 데이터를 수정하면 다른 쪽에도 영향이 가는 Side Effect가 발생할 수 있고, C++ 같은 언어에서는 이중 해제와 같은 심각한 문제를 일으킬 수 있습니다.
반면에 깊은 복사는 주소값이 가리키는 실제 데이터까지 포함하여, 객체의 모든 것을 재귀적으로 복사해 완전히 독립적인 새 객체를 만듭니다. 따라서 원본을 수정해도 복사본에 전혀 영향이 없고, 데이터의 안정성을 보장할 수 있습니다. 다만, 모든 데이터를 새로 만들다 보니 복사 과정이 느리고 메모리도 더 많이 사용한다는 단점이 있습니다.
'Computer Science' 카테고리의 다른 글
| 가상 메모리 Virtual Memory (0) | 2025.11.17 |
|---|---|
| 메모리 구조 (0) | 2025.11.17 |
| Red-Black 트리 (0) | 2025.11.13 |
| 교착 상태 (Deadlock) (0) | 2025.11.12 |
| malloc과 new의 차이 (0) | 2025.11.12 |
