본문 바로가기
P.L

C/C++ call by 포인터, 소멸자, 깊은 복사자가 필요한 이유, 메모리 누수에 대한 고찰

by ahsung 2021. 10. 4.

 

요즘 유행하는 대부분 언어들의 경우

프로그래밍을 할 때 메모리 누수의 고려를 줄이기 위해 특정 타입의 변수에 대해서는

강제로 call by reference 접근만 허용하며 언어의 자체 엔진이 메모리 관리를 합니다.

ex) GC(Garbage Collection), Reference Counts 기법 등등

 

다만 C언어의 경우 변수간의 할당 작업은 구조체 일지라도 모두 call by value 방식이기에

구조체 내부에 포인터가 섞여 있는 경우 메모리 누수에 대해 신경쓸 부분이 많습니다.

 

C/C++의 call by value, 포인터 메모리 동작 방식

https://asung123456.tistory.com/43

 

C/C++ call by value, 포인터(call by reference), 메모리 접근 동작 방식

C/C++의 call by value, call by reference 포인터 = call by reference, 그 외 타입은 call by value 라고 그냥 외우는 경우들도 많은데, 이런 암기로는 C/C++ 언어의 특성과 동작 방식을 제대로 이해하기 힘들다..

asung123456.tistory.com

 

 

메모리 누수는 보통 heap 영역에 메모리 할당후 포인터로 가르켰는데,

해당 포인터 변수의 메모리는 해제되어 사라졌지만,

정작 포인터 변수 메모리에 저장했던 heap영역의 주소에 해당하는 메모리는 해제하지 않았으므로 이때 누수가 발생됩니다. 

 

구조체 사용시 누수가 일어나는 케이스

1. 구조체 객체의 메모리가 해제되면서  포인터 멤버 변수 메모리는 해제되었지만,

포인터 멤버 변수가 가르키고 있는 heap 영역의 메모리가 해제되지 않은 경우.

 

이런 케이스가 가장 흔히 일어나는 경우이며,

다행히 소멸자에 포인터들의 heap 영역 해제를 명시해 놓은다면, 객체 소멸시 메모리 누수를 방지할 수 있습니다.

 

 

2. 구조체 객체에 재할당이 일어난 경우 

(이번에 이런 경우는 실제로 C언어는 어떻게 동작하는지, 어떻게 방지할 수 있는지 확인해 봅시다)

결론: 소멸자와 깊은 복사자(대입(할당,=)연산자 오버라이딩)이 필요하다.

 

구조체에 대해 기본적으로 call by reference만 제공하는 언어의 경우 ( ex python, java)

변수의 구조체 할당은 사실상 주소값 복사로 인하여, 메모리(객체)를 공유하게됩니다.

 

하지만 C/C++의 경우 구조체이더라도 할당 연산자를 통한 할당은 call by value로서 

구조체에 담겨져 있는 메모리 값을 그대로 복사하게 됩니다.

 

0 int a
1
2
3
4 int b
5
6
7
8 int * ptr
9
10
11
12
13
14
15

Class A는 위와 같은 메모리 구조를 갖게 될텐데,

이때 class A 타입의 변수 객체에 재할당을 하게되면, ptr 영역이 덮어 씌어지고

이전의 ptr 값은 사라지게 됩니다. 이전 ptr이 heap 영역을 할당하여 가르키고 있었다면

메모리 누수로 이어지게 될수도 있는 아주 큰 문제입니다.

 

첫번째 가정으로, 재할당이 이루어 질때 기존 메모리 영역의 객체에 대해

소멸자가 실행된다면, 위 1번과 같이 소멸자 정의를 통해 메모리 누수를 방지할 수 있습니다.

 

 

소멸자가 실행되는지 테스트 코드 ( 급하시면 출력코드만 보세용~)

#include<iostream>
#include<string>
using namespace std;

class A{
public:
    int a;
    int b;
    int * ptr;
    
    A(int a, int b, int ptr_value = 9){
        this->a = a;
        this->b = b;
        this->ptr = new int(ptr_value);
        cout << "생성자: 생성!"<<endl;
        print_info("생성자");
        cout << endl;
    }
    ~A(){
        cout << "소말자: 소멸!" <<endl;
        print_info("소멸자");
        // delete this->ptr;
        cout << endl;
    }
    void print_info(string caller){
        cout << caller + ": " << "객체 첫주소:" << this << endl;
        cout << caller + ": " << "a:" << this->a << "  b:" << this->b <<"  ptr:"<< this->ptr << "  ptr_value:" << *this->ptr << endl;
    }
};

int main(){

    cout << "##  A var1(1,2)  main local에 생성!" << endl;
    cout << "##  ptr_var1_ptr = var1.ptr,  ptr_var1_a = &var1.a" << endl << endl;
    A var1(1,2);
    int * ptr_var1_ptr = var1.ptr;
    int * ptr_var1_a = &var1.a;
    cout << "---------------------------------------------------------- " <<endl << endl;

    cout << "## *ptr_var1_ptr = 55 대입" << endl << endl;
    *ptr_var1_ptr = 55;
    cout << "# var1 정보 출력" << endl;
    var1.print_info("MAIN");
    cout << endl;
    cout << "# local의 ptr_var1_ptr,a 정보 출력" << endl;
    cout << "MAIN: ptr_var1_ptr:" << ptr_var1_ptr << "  ptr_var1_ptr_value:" <<*ptr_var1_ptr << "  ptr_var1_a:" << ptr_var1_a << "  ptr_var1_a_value:"<< *ptr_var1_a << endl;
    cout << "---------------------------------------------------------- " <<endl << endl;

    cout << "## var1 = A(4,4,10) 대입" << endl << endl;
    // delete var1.ptr;
    var1 = A(4,4,10);
    cout << "# var1 정보 출력" << endl;
    var1.print_info("MAIN");
    cout << endl;
    cout << "# local의 ptr_var1_ptr,a 정보 출력" << endl;
    cout << "MAIN: ptr_var1_ptr:" << ptr_var1_ptr << "  ptr_var1_ptr_value:" <<*ptr_var1_ptr << "  ptr_var1_a:" << ptr_var1_a << "  ptr_var1_a_value:"<< *ptr_var1_a << endl;
    cout << "---------------------------------------------------------- " <<endl << endl;


    cout << "## 대입 연산 없이 main local에 A(5,5) 객체 생성" << endl << endl;
    A(5,5);
    cout << endl;
    cout << "# var1 정보 다시 출력" << endl;
    var1.print_info("MAIN");
    cout << "MAIN END" << endl;
    cout << "---------------------------------------------------------- " <<endl << endl;
}

 

첫번째 실험

생성자와 소멸자에 메모리 주소를 확인 할수 있는 코드를 넣고

객체를 생성, 재할당, 변수에 할당 없이 객체 선언을 시도해봅니다.

 

출력

##  A var1(1,2)  main local에 생성!
##  ptr_var1_ptr = var1.ptr,  ptr_var1_a = &var1.a

생성자: 생성!
생성자: 객체 첫주소:0x7ffeeeb328d0
생성자: a:1  b:2  ptr:0x7fc659c05920  ptr_value:9

----------------------------------------------------------

## *ptr_var1_ptr = 55 대입

# var1 정보 출력
MAIN: 객체 첫주소:0x7ffeeeb328d0
MAIN: a:1  b:2  ptr:0x7fc659c05920  ptr_value:55

# local의 ptr_var1_ptr,a 정보 출력
MAIN: ptr_var1_ptr:0x7fc659c05920  ptr_var1_ptr_value:55  ptr_var1_a:0x7ffeeeb328d0  ptr_var1_a_value:1
----------------------------------------------------------

## var1 = A(4,4,10) 대입

생성자: 생성!
생성자: 객체 첫주소:0x7ffeeeb32888
생성자: a:4  b:4  ptr:0x7fc659c05930  ptr_value:10

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffeeeb32888
소멸자: a:4  b:4  ptr:0x7fc659c05930  ptr_value:10

# var1 정보 출력
MAIN: 객체 첫주소:0x7ffeeeb328d0
MAIN: a:4  b:4  ptr:0x7fc659c05930  ptr_value:10

# local의 ptr_var1_ptr,a 정보 출력
MAIN: ptr_var1_ptr:0x7fc659c05920  ptr_var1_ptr_value:55  ptr_var1_a:0x7ffeeeb328d0  ptr_var1_a_value:4
----------------------------------------------------------

## 대입 연산 없이 main local에 A(5,5) 객체 생성

생성자: 생성!
생성자: 객체 첫주소:0x7ffeeeb32860
생성자: a:5  b:5  ptr:0x7fc659c05940  ptr_value:9

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffeeeb32860
소멸자: a:5  b:5  ptr:0x7fc659c05940  ptr_value:9


# var1 정보 다시 출력
MAIN: 객체 첫주소:0x7ffeeeb328d0
MAIN: a:4  b:4  ptr:0x7fc659c05930  ptr_value:10
MAIN END
----------------------------------------------------------

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffeeeb328d0
소멸자: a:4  b:4  ptr:0x7fc659c05930  ptr_value:10

결과 

출력의 35줄을 보면, 재할당을 통해 포인터 타입의 멤버 변수인 ptr의 값이 변했습니다.

다만 이전의 ptr의 메모리를 바라보고있는 ptr_var1_ptr을 통해

heap영역의 메모리가 그대로 남아있는 것을 볼 수 있으며,

재할당을 위해 새롭게 만든 객체는 할당 연산자를 통해 메모리 복사후 소멸자가 실행되어 사라졌지만,

var1 변수 메모리에 있던 기존의 객체에 대해서 소멸자는 실행되지 않았습니다.

즉  ptr_var1_ptr이 없는 상황이었다면 메모리 누수가 발생하였을 것입니다.

 

 

두번째 실험

테스트 코드의 22번 줄의 주석을 풀어 소멸자에 ptr 포인터 해제를 추가하고

객체를 생성, 재할당, 변수에 할당 없이 객체 선언을 시도해봅니다.

출력

##  A var1(1,2)  main local에 생성!
##  ptr_var1_ptr = var1.ptr,  ptr_var1_a = &var1.a

생성자: 생성!
생성자: 객체 첫주소:0x7ffee67398d0
생성자: a:1  b:2  ptr:0x7fed45405920  ptr_value:9

----------------------------------------------------------

## *ptr_var1_ptr = 55 대입

# var1 정보 출력
MAIN: 객체 첫주소:0x7ffee67398d0
MAIN: a:1  b:2  ptr:0x7fed45405920  ptr_value:55

# local의 ptr_var1_ptr,a 정보 출력
MAIN: ptr_var1_ptr:0x7fed45405920  ptr_var1_ptr_value:55  ptr_var1_a:0x7ffee67398d0  ptr_var1_a_value:1
----------------------------------------------------------

## var1 = A(4,4,10) 대입

생성자: 생성!
생성자: 객체 첫주소:0x7ffee6739888
생성자: a:4  b:4  ptr:0x7fed45405930  ptr_value:10

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffee6739888
소멸자: a:4  b:4  ptr:0x7fed45405930  ptr_value:10

# var1 정보 출력
MAIN: 객체 첫주소:0x7ffee67398d0
MAIN: a:4  b:4  ptr:0x7fed45405930  ptr_value:10

# local의 ptr_var1_ptr,a 정보 출력
MAIN: ptr_var1_ptr:0x7fed45405920  ptr_var1_ptr_value:55  ptr_var1_a:0x7ffee67398d0  ptr_var1_a_value:4
----------------------------------------------------------

## 대입 연산 없이 main local에 A(5,5) 객체 생성

생성자: 생성!
생성자: 객체 첫주소:0x7ffee6739860
생성자: a:5  b:5  ptr:0x7fed45405930  ptr_value:9

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffee6739860
소멸자: a:5  b:5  ptr:0x7fed45405930  ptr_value:9


# var1 정보 다시 출력
MAIN: 객체 첫주소:0x7ffee67398d0
MAIN: a:4  b:4  ptr:0x7fed45405930  ptr_value:9
MAIN END
----------------------------------------------------------

소말자: 소멸!
소멸자: 객체 첫주소:0x7ffee67398d0
소멸자: a:4  b:4  ptr:0x7fed45405930  ptr_value:9
a.out(75575,0x118b90e00) malloc: *** error for object 0x7fed45405930: pointer being freed was not allocated
a.out(75575,0x118b90e00) malloc: *** set a breakpoint in malloc_error_break to debug
[1]    75575 abort      ./a.out

 

결과 

구조체 재할당시 애초에 기존 메모리의 객체에 대해서는 소멸자가 실행되지 않았으므로,

출력의 35번줄의 이전 포인터에 대한 메모리 누수는 여전합니다.

 

게다가 소멸자 추가로인해 메모리 누수뿐만아니라 엄청난 버그도 야기되는데,

출력의 20번줄:  var1 = A(4,4,10) 코드에서 A(4,4,10) 메모리 갑을 그대로 var1에 복사한후

A(4,4,10)의 소멸자가 실행되면서 ptr메모리를 해제해 버렸습니다.

 

이때 A(4,4,10)와 var1의 ptr은 같은 주소를 바라보고 있는데,

멋대로 A(4,4,10) 소멸자가 메모리를 해제하는 바람에 var1의 ptr이 가르키고 있는 메모리는 freed된 메모리가 되어버렸고

 

출력의 38번줄의 코드에서 우연치 않게 freed된 메모리를 받아가 사용했고

이때 var1의 ptr이 가르키던 메모리가 다시 활성화되었습니다.

 

이는 프로그래머의 예상에서 벗어난 행위이며

결국 38번줄에 생성됬던 객체는 main 함수의 종료와 함께 소멸자로 해제되어

다시 var1의 ptr이 가르키고 있던 영역의 메모리는 freed되었고 

var1에 대해 소멸자가 실행될때 freed된 영역을 다시 메모리 해제하려고하니 에러가 발생하였습니다.

 

오히려 소멸자를 작성하게되면 메모리 재할당시 엄청난 버그를 야기할수 있음을 보았습니다.

이를 해결하기 위해서는 깊은 복사자까지 생성되어 하며,

 

깊은 복사자가 있는 경우

재할당(대입)시 포인터 멤버들은 주소를 그대로 복사하여 공유하는 것이 아닌

주소가 가르키고 있는 heap 영역 자체를 복사하여 새로운 Heap영역에 할당하고

새로운 heap 영역의 주소를 멤버 포인터에 넣어줍니다.

 

이로서 복사되던 주체의 포인터와 같은 주소를 공유하지 않기 때문에

복사된 객체가 소멸하거나 다른 작업을 하더라도 서로 영향을 받지 않습니다.

 

 

 

 

 

결론

c++에서 제공하는 STL들은 사용하다보면,

객체 재할당 및 포인터에 대한 해제를 신경쓰지 않고도 메모리 누수가 발생하지 않습니다.
이는 STL의 객체들은 깊은 복사자와 소멸자가 모두 제대로 생성되었음을 의미합니다.

 

결론적으로 C/C++에서 구조체를 사용시 메모리 누수를 방지하기 위해서는

옳바른 깊은 복사자(대입연산자 오버로딩)와 소멸자가 모두 필요합니다.

 

ex) 

#include<vector>
using namespace std;


vector<int> v(10,10);

vector<int> v2 = v; // v와 v2 내부의 배열은 깊은 복사자로 인해 서로 독립적으로 사용됩니다.

                    // 만약 v2에 pointer 멤버가 heap 영역을 가르키고 있었다면,
                    // v2에 v메모리 복사(재할당)이 일어나면서, 기존의 v2 heap은 메모리 누수

// 만약 v와 독립적이지 않고 메모리를 공유하고 싶었다면,

vector<int> * v3 = &v;

 

 

 

 

 

 

 

 

 

 

 

댓글