Skip to main content

Command Palette

Search for a command to run...

C언어 (9)

Updated
7 min read

1. new/delete 연산자와 동적 메모리

1-1. 동적 메모리의 필요성

동적 메모리는 실행시간(런타임)에 할당되어 사용되는 메모리 블록 이다. 프로그램이 돌아가는 도중에 필요한 만큼 메모리를 확보하고, 다 쓰면 반환하는 방식이다.

동적 메모리의 반대는 정적 메모리 다. 정적 메모리는 컴파일 타임에 크기가 결정되는 메모리로, 일반 변수나 배열이 여기에 해당한다. int arr[100]은 컴파일 시점에 400바이트가 필요하다는 것이 확정된다.

그런데 프로그램을 작성할 때 얼마만큼의 메모리가 필요한지 알지 못하는 경우 가 많다. 사용자가 데이터를 몇 개 입력할지, 파일에 몇 줄이 있는지, 네트워크로 얼마나 큰 데이터가 올지는 프로그램이 실행되어야 알 수 있다. 배열 크기를 넉넉하게 int arr[10000]으로 잡으면 대부분의 메모리가 낭비되고, 그마저도 10000개를 넘으면 부족해진다.

동적 메모리는 이 문제를 해결한다. 필요한 시점에 필요한 만큼만 할당하고, 다 쓰면 해제하면 된다.

동적 메모리가 할당되는 영역을 힙(Heap) 영역 이라 한다. C에서 mallocfree로 힙을 관리했던 것처럼, C++에서는 new로 생성하고 delete로 소멸 시킨다.

1-2. new와 delete

C에서는 mallocfree를 사용했다. C++에서도 쓸 수는 있지만, C++에는 더 나은 방법이 있다.

// C 방식
int *p = (int *)malloc(sizeof(int));
*p = 10;
free(p);

// C++ 방식
int *p = new int;
*p = 10;
delete p;

newmalloc보다 간결하다. 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[]로 해제

deletedelete[]를 혼동하면 안 된다. 단일 객체는 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/deletemalloc/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) 를 수행한다.

멤버가 intdouble 같은 기본 타입뿐이면 문제없다. 하지만 멤버에 동적 할당된 포인터 가 있으면 심각한 문제가 생긴다.

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" 메모리가 누구도 가리키지 않게 되어 메모리 누수 가 발생한다.

둘째, 프로그램이 끝나면 ab의 소멸자가 각각 호출되면서 같은 메모리를 두 번 해제(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로 묶어서 관리하는 것이 안전하다.

More from this blog

C언어 (13)

1. STL의 개념 1-1. 배경 C++로 프로그래밍을 하다 보면 동적 배열, 연결 리스트, 정렬, 검색 같은 자료구조와 알고리즘을 반복적으로 구현하게 된다. 프로젝트마다 매번 새로 만들면 시간도 낭비되고, 버그가 생길 가능성도 높아진다. 이런 문제를 해결하기 위해 자주 사용되는 자료구조와 알고리즘을 미리 만들어서 표준 라이브러리에 포함 시킨 것이 STL이

Apr 1, 202610 min read3

chamdom's tech

16 posts