C언어 (9)
1. new/delete 연산자와 동적 메모리
1-1. 동적 메모리의 필요성
동적 메모리는 실행시간(런타임)에 할당되어 사용되는 메모리 블록 이다. 프로그램이 돌아가는 도중에 필요한 만큼 메모리를 확보하고, 다 쓰면 반환하는 방식이다.
동적 메모리의 반대는 정적 메모리 다. 정적 메모리는 컴파일 타임에 크기가 결정되는 메모리로, 일반 변수나 배열이 여기에 해당한다. int arr[100]은 컴파일 시점에 400바이트가 필요하다는 것이 확정된다.
그런데 프로그램을 작성할 때 얼마만큼의 메모리가 필요한지 알지 못하는 경우 가 많다. 사용자가 데이터를 몇 개 입력할지, 파일에 몇 줄이 있는지, 네트워크로 얼마나 큰 데이터가 올지는 프로그램이 실행되어야 알 수 있다. 배열 크기를 넉넉하게 int arr[10000]으로 잡으면 대부분의 메모리가 낭비되고, 그마저도 10000개를 넘으면 부족해진다.
동적 메모리는 이 문제를 해결한다. 필요한 시점에 필요한 만큼만 할당하고, 다 쓰면 해제하면 된다.
동적 메모리가 할당되는 영역을 힙(Heap) 영역 이라 한다. C에서 malloc과 free로 힙을 관리했던 것처럼, C++에서는 new로 생성하고 delete로 소멸 시킨다.
1-2. new와 delete
C에서는 malloc과 free를 사용했다. C++에서도 쓸 수는 있지만, C++에는 더 나은 방법이 있다.
// C 방식
int *p = (int *)malloc(sizeof(int));
*p = 10;
free(p);
// C++ 방식
int *p = new int;
*p = 10;
delete p;
new는 malloc보다 간결하다. sizeof를 쓸 필요도 없고, 타입캐스팅도 필요 없다. new int라고 쓰면 int 크기만큼 힙에 메모리를 할당하고 그 주소를 반환한다.
선언과 동시에 초기화할 수도 있다.
int *p = new int(42); // 42로 초기화
double *d = new double(3.14);
배열을 동적으로 할당할 때는 new[]와 delete[]를 사용한다.
int *arr = new int[5]; // int 5개 크기의 배열 할당
for (int i = 0; i < 5; i++) {
arr[i] = (i + 1) * 10;
}
delete[] arr; // 배열은 반드시 delete[]로 해제
delete와 delete[]를 혼동하면 안 된다. 단일 객체는 delete, 배열은 delete[]다. 배열에 delete를 쓰면 정의되지 않은 동작이 발생한다.
1-3. new/delete가 malloc/free보다 나은 이유
C++에서 new/delete를 쓰는 가장 큰 이유는 생성자와 소멸자가 호출되기 때문 이다.
class Student {
private:
string name;
int score;
public:
Student(string n, int s) : name(n), score(s) {
cout << name << " 생성" << endl;
}
~Student() {
cout << name << " 소멸" << endl;
}
void print() {
cout << name << ": " << score << "점" << endl;
}
};
int main() {
Student *p = new Student("홍길동", 90); // 생성자 호출됨
p->print();
delete p; // 소멸자 호출됨
return 0;
}
new는 메모리 할당 + 생성자 호출을 한 번에 처리한다. delete는 소멸자 호출 + 메모리 해제를 한 번에 처리한다. malloc은 메모리만 잡아줄 뿐 생성자를 호출하지 않고, free는 메모리만 해제할 뿐 소멸자를 호출하지 않는다.
객체를 다루는 C++에서는 생성자와 소멸자가 제대로 호출되는 것이 매우 중요하다. 소멸자에서 동적 메모리를 해제하거나, 파일을 닫거나, 자원을 정리하는 코드가 있을 수 있기 때문이다. 그래서 C++에서는 malloc/free 대신 new/delete를 사용한다.
정리하면 new/delete가 malloc/free보다 나은 점은 세 가지다. 타입캐스팅이 불필요하다. sizeof 계산이 불필요하다. 그리고 가장 중요한 것으로, 생성자와 소멸자가 자동 호출 된다.
1-4. 동적 메모리와 소멸자
클래스 내부에서 동적 메모리를 사용하면, 소멸자에서 반드시 해제해야 한다.
class IntArray {
private:
int *data;
int size;
public:
IntArray(int s) : size(s) {
data = new int[size]; // 생성자에서 동적 할당
}
~IntArray() {
delete[] data; // 소멸자에서 해제
cout << "메모리 해제 완료" << endl;
}
int& operator[](int index) {
return data[index];
}
};
int main() {
IntArray arr(5);
arr[0] = 10;
arr[1] = 20;
return 0;
} // 여기서 arr의 소멸자가 호출되어 data가 해제됨
생성자에서 new로 할당하고 소멸자에서 delete로 해제하는 패턴은 C++에서 매우 흔하다. 이것을 RAII(Resource Acquisition Is Initialization) 패턴이라고 한다. 자원의 획득을 초기화 시점에, 반환을 소멸 시점에 묶어서 관리하는 방식이다.
2. 대입 연산자 오버로딩
2-1. 대입 연산자란?
대입 연산자(=)는 이미 생성된 객체에 다른 객체의 값을 복사하는 연산자다. 생성과 동시에 초기화하는 복사 생성자와는 다르다.
Student s1("홍길동", 90);
Student s2("김철수", 85);
s2 = s1; // 대입 연산자 — 이미 존재하는 s2에 s1을 복사
Student s3 = s1; // 이건 복사 생성자! (새 객체 생성 시 초기화)
s2 = s1은 대입 연산자, Student s3 = s1은 복사 생성자다. 모양이 비슷해서 헷갈리지만, 핵심 차이는 왼쪽 객체가 이미 존재하는지 여부 다.
2-2. 기본 대입 연산자의 문제
대입 연산자를 직접 정의하지 않으면 컴파일러가 기본 대입 연산자 를 자동 생성한다. 기본 대입 연산자는 멤버를 하나씩 그대로 복사하는 얕은 복사(Shallow Copy) 를 수행한다.
멤버가 int나 double 같은 기본 타입뿐이면 문제없다. 하지만 멤버에 동적 할당된 포인터 가 있으면 심각한 문제가 생긴다.
class MyString {
private:
char *str;
int len;
public:
MyString(const char *s) {
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}
~MyString() {
delete[] str;
}
};
int main() {
MyString a("Hello");
MyString b("World");
b = a; // 기본 대입 연산자 → 얕은 복사
return 0;
}
기본 대입 연산자가 실행되면 b.str = a.str이 된다. 두 객체의 str이 같은 메모리 주소를 가리킨다. 이때 두 가지 문제가 발생한다.
첫째, b가 원래 가리키던 "World" 메모리가 누구도 가리키지 않게 되어 메모리 누수 가 발생한다.
둘째, 프로그램이 끝나면 a와 b의 소멸자가 각각 호출되면서 같은 메모리를 두 번 해제(double free) 한다. 이로 인해 프로그램이 크래시한다.
얕은 복사 후 상태:
a.str ──┐
├──→ [ H | e | l | l | o | \0 ]
b.str ──┘
b가 원래 가리키던 메모리:
[ W | o | r | l | d | \0 ] ← 누구도 안 가리킴 (메모리 누수!)
2-3. 대입 연산자 오버로딩
이 문제를 해결하려면 대입 연산자를 직접 정의해서 깊은 복사(Deep Copy) 를 수행해야 한다.
class MyString {
private:
char *str;
int len;
public:
MyString(const char *s) {
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}
~MyString() {
delete[] str;
}
// 대입 연산자 오버로딩
MyString& operator=(const MyString &other) {
if (this == &other) { // 자기 자신 대입 방지
return *this;
}
delete[] str; // 기존 메모리 해제
len = other.len;
str = new char[len + 1]; // 새 메모리 할당
strcpy(str, other.str); // 내용 복사
return *this;
}
};
깊은 복사 대입 연산자의 핵심 단계를 하나씩 보면 이렇다.
자기 자신 대입 체크 — a = a처럼 자기 자신을 대입하는 경우를 먼저 걸러낸다. 체크하지 않으면 기존 메모리를 먼저 해제한 뒤 이미 해제된 메모리에서 복사하려는 상황이 생긴다.
기존 메모리 해제 — 대입을 받는 쪽(this)이 이미 가지고 있던 동적 메모리를 먼저 delete한다. 이렇게 해야 메모리 누수가 발생하지 않는다.
새 메모리 할당 후 복사 — 원본과 같은 크기의 새 메모리를 할당하고, 내용을 복사한다. 이러면 두 객체가 각자의 독립적인 메모리를 가진다.
자기 자신의 참조 반환 — return *this로 대입된 객체의 참조를 반환한다. 이렇게 해야 a = b = c 같은 연쇄 대입 이 가능해진다.
깊은 복사 후 상태:
a.str ──→ [ H | e | l | l | o | \0 ]
b.str ──→ [ H | e | l | l | o | \0 ] ← 별도의 새 메모리
각 객체가 독립적인 메모리를 가지므로 double free 문제 없음
JavaScript에서 객체를 =로 대입하면 참조만 복사되는 것(얕은 복사)과 비슷한 문제다. JS에서 스프레드 연산자({...obj})나 structuredClone으로 깊은 복사를 하는 것처럼, C++에서는 대입 연산자를 오버로딩해서 깊은 복사를 구현하는 것이다.
2-4. Rule of Three
동적 메모리를 가진 클래스에서는 다음 세 가지를 반드시 함께 정의해야 한다. 이것을 Rule of Three 라고 한다.
소멸자 — 동적 메모리를 해제한다. 복사 생성자 — 새 객체를 만들 때 깊은 복사를 수행한다. 대입 연산자 — 기존 객체에 대입할 때 깊은 복사를 수행한다.
이 중 하나라도 빠지면 얕은 복사로 인한 문제가 발생할 수 있다. 세 가지는 항상 세트로 생각해야 한다.
class MyString {
public:
MyString(const char *s); // 생성자
~MyString(); // 소멸자
MyString(const MyString &other); // 복사 생성자
MyString& operator=(const MyString &other); // 대입 연산자
};
셋 중 하나를 직접 정의해야 할 상황이라면, 나머지 둘도 반드시 정의해야 한다는 규칙이다. 컴파일러가 자동 생성하는 기본 버전은 전부 얕은 복사를 하므로, 동적 메모리가 있는 클래스에서는 기본 버전에 의존하면 안 된다.
마무리
new/delete는 C++에서 동적 메모리를 관리하는 핵심 도구다. malloc/free와 달리 생성자와 소멸자를 자동 호출해주므로, 객체의 생명주기를 제대로 관리할 수 있다. 그리고 동적 메모리를 가진 객체를 복사하거나 대입할 때는 반드시 깊은 복사를 구현해야 한다. 대입 연산자 오버로딩은 그 핵심 방법이고, 소멸자, 복사 생성자와 함께 Rule of Three로 묶어서 관리하는 것이 안전하다.