본문 바로가기
프로그래밍/C & C++

[C++] 스마트 포인터(Smart Pointer) - (1)

by 별준 2021. 8. 2.

References

Contents

  • 스마트 포인터(Smart Pointer)
  • RAII(Resource Acquisition Is Initialization)
  • unique_ptr
  • make_unique

프로그래밍 분야에서 리소스란, 사용을 하고난 후에는 시스템에 돌려주어야하는 모든 것을 말합니다. 돌려주지 않는 순간부터 문제가 하나둘씩 생겨날 수 있습니다. C++ 프로그램에서 가장 흔하게 사용되는 리소스로 동적 할당된 메모리를 말할 수 있는데, 이 메모리를 할당하고서 해제하지 않으면 메모리가 누수됩니다.

 

사실 메모리는 프로그램에서 관리해야되는 많은 리소스 중에 한 가지일 뿐입니다. 리소스에는 File Descriptor(파일 서술자), 뮤텍스(Mutex), GUI에서 사용되는 폰트와 브러시도 리소스의 일부입니다. 또한, 데이터베이스 연결, 네트워크 소켓도 리소스에 해당됩니다. 중요한 것은 이 리소스를 점유해서 사용했으면, 다시 놓아주어야 한다는 것입니다.

 

C++ 이후에 나온 많은 언어들은 대부분 가비지 컬렉터(Garbage Collector - GC)라 불리는 것들이 기본적으로 내장되어 있습니다. GC는 프로그램에서 더 이상 사용되지 않는 리소스들을 자동으로 해제해주는 역할을 합니다. 하지만, C++의 경우에는 수작업으로 해제해주지 않으면 프로그램이 종료되기 전까지 영원히 남아있게 됩니다. (프로그램이 종료되면 OS에 의해서 해제됩니다.)

 

아래의 예시를 살펴보겠습니다.

#include <iostream>

class Resource {
	int* data;

public:
	Resource() {
		data = new int[100];
		std::cout << "리소스 획득\n";
	}

	~Resource() {
		std::cout << "소멸자 호출\n";
		delete[] data;
	}
};

void f() {
	Resource* pRsc = new Resource();
}

int main(void) {
	f();

	return 0;
}

위 코드를 컴파일하고 실행시키면,

위와 같은 출력을 볼 수 있습니다.

즉, 자원만 획득하고, 소멸자가 호출되지 않아서 할당된 메모리 data를 해제하지 못하고 있습니다.

그 이유는 f 함수에서 

delete pRsc;

를 해주지 않았기 때문이죠.

 

만약 delete를 f 함수 내에서 해주지 않는다면, 생성된 객체를 가리키던 pRsc는 메모리에서 사라지게 됩니다. 따라서 Heap 메모리 영역 어딘가에서 Resource 클래스의 객체는 남아있지만, 그 주소값을 가지고 있는 포인터가 사라지게 되는 것입니다. Resource 객체는 프로그램이 종료되기 전까지 해제되지 못한 채 Heap에서 자리만 차지하고 있게 됩니다. 위 경우에는 총 400 바이트의 메모리 누수가 발생하게 됩니다.

 

void f() {
	Resource pRsc = new Resource();
    // do something...
    delete pRsc;
}

그렇다면 위 코드처럼 delete만 빼먹지 않으면 모든 것이 해결될까요?

위의 f 함수는 아주 간단하게 구현되어 있기 때문에 delete 만 추가해주면 될 것 같지만, 프로그램의 크기가 커질수록 객체의 메모리 해제에 실패할 수 있는 경우가 발생해서 놓치기 쉽습니다. 

do something 부분 어딘가에서 return문이 들어 있을 수도 있으며, 만약 delete가 어느 루프 안에 있는데, continue 혹은 goto 문에 의해서 갑자스럽게 루프를 빠져나왔을 때가 바로 그런 경우입니다. 또한, 함수가 수행되면서 예외를 던질 수 있다는 점도 고려해야 합니다. 예외가 던져지면 delete 문이 실행되지 않게 되죠.

void thrower() {
	throw 1;
}

void f() {
	Resource* pRsc = new Resource();
    thrower();
    
    delete pRsc;
}

int main(void) {
	try {
    	f();
    } catch (int i) {
    	std::cout << "예외 발생\n";
    }
    
    return 0;
}

이렇게 delete 문을 건너뛰는 경우가 발생합니다.


스마트 포인터(Smart Pointer)

물론 하나하나 따져 가면서 완벽하고 꼼꼼하게 프로그램을 만들면 이런 종류의 에러는 막을 수 있지만, 오랫동안 유지보수를 진행하다보면 언제든지 문제가 발생할 수 있습니다. 

그렇다면 이 상황을 어떻게 해결할 수 있을까요?

 

이렇게 f 함수를 통해 얻어낸 리소스가 항상 해제되도록 만드는 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 f 함수에서 떠날 때 호출되도록 만드는 것입니다. 즉, 자원을 객체 안으로 넣음으로써, C++에서 자동으로 호출해주는 소멸자에 의해 해당 자원을 저절로 해제할 수 있습니다.

 

소프트웨어 개발에 쓰이는 상당수의 자원이 Heap에서 동적으로 할당되고, 하나의 블록 혹은 함수 안에서만 쓰이는 경우가 잦기 때문에 그 블록 혹은 함수로부터 빠져나올 때 해제되는 것이 올바릅니다. 

위의 예시에서 포인터 pRsc의 경우에는 객체가 아니기 때문에 소멸자가 호출되지 않습니다. 포인터 대신 pRsc를 일반적인 포인터가 아닌, 포인터 객체로 만들어서 자신이 소멸될 때 소멸자가 호출되어 자원을 해제할 수 있도록 하면 됩니다.

 

이런 용도로 사용되는 포인터 객체를 스마트 포인터(Smart Pointer)라고 부르며, 이 객체는 가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 있습니다.

C++ 11 이전에는 이러한 용도로 auto_ptr 이 존재했지만, 많은 문제들이 있어서 사용을 거의 금지하고 있습니다.
auto_ptr은 C++ 17에서 아예 삭제되었습니다.

C++ 11에서는 auto_ptr를 보완한 unique_ptr과 shared_ptr을 제공하고 있습니다.


RAII

위와 같은 디자인 패턴은 RAII - Resource Acquisition Is Initialization, 즉, 리소스의 획득은 초기화이다라는 용어로 불리고 있습니다. 이는 리소스 관리를 스택에 할당한 객체를 통해 수행한다는 것이며, 리소스 획득하고 나서는 바로 리소스 관리 객체에 넘겨준다는 것을 의미합니다.

 


unique_ptr

C++에서 메모리를 잘못된 방식으로 관리하였을 때, 크게 두 종류의 문제점이 발생할 수 있습니다.

첫 번째는 앞서 이야기했듯이 메모리를 사용한 후에 해제하지 않은 경우이며, 이 경우에는 메모리 누수가 발생하게 됩니다. 간단한 프로그램은 크게 문제될 일이 없지만, 서버처럼 장시간 동작하는 프로그램의 경우에는 시간이 지남에 따라 점점 사용하는 메모리의 양이 늘어나서 결과적으로 나중에는 시스템 메모리가 부족해서 서버가 죽어버리는 상황이 발생할 수 있습니다.

이 문제는 RAII 디자인 패턴을 사용하면 해결할 수 있습니다. RAII를 통해서 사용이 끝난 메모리는 항상 해제되므로, 메모리 누수를 사전에 막을 수 있습니다.

 

두 번째는 이미 해제된 메모리를 다시 참조하는 경우입니다. 아래 함수를 살펴봅시다.

void f() {
	Resource* pRsc = new Resource();
	Resource* pRsc2 = pRsc;

	// pRsc 사용
	delete pRsc;

	// pRsc2 사용
	delete pRsc2;
}

위 경우에는 pRsc와 pRsc2가 동시에 하나의 객체를 가리키고 있는데, 먼저 delete pRsc 를 통해서 객체를 소멸했습니다. 그런데 pRsc2가 이미 소멸된 객체를 다시 소멸시키려고 합니다. 이런 경우 메모리 에러가 발생하면서 프로그램이 비정상으로 종료됩니다. (이렇게 이미 메모리 해제된 객체를 다시 해제시켜서 발생하는 버그를 double free 버그라고 부릅니다.)

 

위와 같은 문제는 만들어진 객체의 소유권이 명확하지 않아서 발생하게 됩니다. 만약, 어떤 포인터에 객체의 유일한 소유권을 부여하여, 이 포인터가 아니면 객체를 소멸시킬 수 없도록 만든다면 위와 같이 같은 객체를 두 번 해제하는 일은 없을 것입니다.

 

C++에서 이렇게 특정 객체에 유일한 소유권을 부여하는 포인터 객체(스마트 포인터)를 unique_ptr 이라고 합니다.

unique_ptr은 다음과 같이 사용할 수 있습니다.

#include <iostream>

class Resource {
	int* data;

public:
	Resource() {
		data = new int[100];
		std::cout << "리소스 획득\n";
	}

	~Resource() {
		std::cout << "소멸자 호출\n";
		delete[] data;
	}

	void do_something() {
		std::cout << "Do something\n";
	}
};

void f() {
	std::unique_ptr<Resource> pRsc(new Resource());
	pRsc->do_something();
}

int main(void) {
	f();

	return 0;
}

먼저 unique_ptr을 정의하는 부분부터 살펴보면, 정의하기 위해서 unique_ptr에 템플릿 인자로 포인터가 가리킬 클래스를 사용하면 됩니다. 그리고 pRsc를 Resource 클래스를 가리키는 포인터처럼 사용하면 됩니다.

(unique_ptr은 ->연산자를 오버로딩해서 마치 포인터를 다루는 것과 같이 사용할 수 있게 되어 있습니다.)

 

물론 이 unique_ptr을 사용함으로써 RAII 패턴을 사용할 수 있습니다. pRsc는 Stack 영역에 정의된 객체이기 때문에 f 함수가 종료될 때 자동으로 소멸자가 호출됩니다. 그리고 이 unique_ptr은 소멸자 안에서 자신이 가리키고 있는 리소스를 해제해줍니다.

 

만약 아래처럼 unique_ptr을 복사하려고 하면 다음과 같은 컴파일 에러가 발생합니다.

void f() {
	std::unique_ptr<Resource> pRsc(new Resource());
	pRsc->do_something();

	std::unique_ptr<Resource> pRsc2 = pRsc;
}

 

컴파일 에러가 발생하는 이유는 unique_ptr에서 복사 생성자를 삭제했기 때문입니다. 생성을 원하지 않는 함수를 삭제하는 기능은 C++ 11에서 처음 도입된 것인데, 아래처럼 = delete를 사용하면 사용자가 명시적으로 이 함수는 사용을 금지하도록 설정할 수 있습니다. 혹시나 사용하게 되더라도 컴파일 에러가 발생하게 됩니다.

class A {
public:
	A(int a) {}
	A(const A& a) = delete;
};

int main(void) {
	A a(3);
	A b(a);	// 컴파일 에러 발생

	return 0;
}

unique_ptr은 복사가 되지는 않지만, 소유권은 이전할 수 있습니다.

void f() {
	std::unique_ptr<Resource> pRsr(new Resource());
	std::cout << "pRsr : ";
	pRsr->do_something();

	// pRsc2에 소유권 이전
	std::unique_ptr<Resource> pRsr2 = std::move(pRsr);
	std::cout << "pRsr2 : ";
	pRsr2->do_something();
}

f 함수를 위와 같이 작성하고, 컴파일 후 실행시키면 다음의 출력을 확인할 수 있습니다.

unique_ptr에서 복사생성자는 정의되어 있지 않지만, 이동생성자(move contructor)는 가능합니다. 위 코드에서는 이제 pRsr2가 new Resource로 생성된 객체의 소유권을 갖게 되고, pRsr은 아무것도 가리키고 있지 않게 됩니다.

pRsr.get() // pRsc의 주소 반환

위 코드는 unique_ptr의 주소를 반환하는데 실제로 0을 출력하게 됩니다.

소유권이 이전된 unique_ptr를 댕글링 포인터(dangling pointer)라고 하며, 이를 재참조할 시에는 런타임 에러가 발생합니다. 따라서 소유권 이전을 하게 되면, 댕글링 포인터를 절대로 다시 참조하면 안됩니다.

unique_ptr을 함수 파라미터로 전달

unique_ptr은 복사생성자가 없는데, 함수 파라미터로 전달하면 값에 의한 복사는 컴파일 에러가 발생하게 됩니다.

그렇다면 레퍼런스로 함수 파라미터를 전달하면 될까요 ?

아래의 코드를 살펴보도록 하겠습니다.

#include <iostream>

class Resource {
	int* data;

public:
	Resource() {
		data = new int[100];
		std::cout << "리소스 획득\n";
	}

	~Resource() {
		std::cout << "소멸자 호출\n";
		delete[] data;
	}

	void do_something(int a) {
		std::cout << "Do something\n";
		data[0] = a;
	}
};

void f(std::unique_ptr<Resource>& ptr) {
	ptr->do_something(3);
}

int main(void) {
	std::unique_ptr<Resource> pRsr(new Resource());
	f(pRsr);

	return 0;
}

일단, 함수 내부로 unique_ptr가 잘 전달되고 있는 것은 확실합니다. 하지만 위의 코드처럼 함수에 unique_ptr을 레퍼런스로 전달하는 것이 올바른 의도일까요?

 

unique_ptr은 어떠한 객체의 소유권을 유일하게 가지고 있다는 것을 의미합니다. 하지만, 이렇게 레퍼런스로 unique_ptr을 전달하게 되면, f 함수 내부에서 ptr은 더 이상 유일한 소유권을 의미하지 않습니다.

물론, ptr은 레퍼런스이기 때문에 f 함수가 종료되면서 pRsr가 가리키는 객체를 소멸시키지는 않지만, 유일하게 소유하고 있던 객체는 이제, f 함수 내부에서도 ptr을 통해 소유할 수 있게 됩니다. 즉, unique_ptr은 유일한 소유권을 의미한다는 원칙에 위배됩니다.

 

그렇기 때문에 unique_ptr의 레퍼런스를 사용하는 것은 unique_ptr을 유일한 소유권이라는 중요한 의미를 망각한 채 단순히 포인터의 단순한 Wrapper로 사용하는 것에 불과합니다.

 

따라서, 함수에 올바르게 unique_ptr을 전달하려면, 그냥 원래의 포인터 주소값을 전달해주는 방법을 사용하면 됩니다.

void f(Resource* ptr) {
	ptr->do_something(3);
}

int main(void) {
	std::unique_ptr<Resource> pRsr(new Resource());
	f(pRsr.get());

	return 0;
}

make_unique

C++14부터는 unique_ptr을 간단히 만들 수 있는 std::make_unique 함수를 제공합니다.

#include <iostream>
#include <memory>

class Foo {
	int a, b;

public:
	Foo(int a, int b) : a(a), b(b) {
		std::cout << "Contructor !\n";
	}
	
	void print() {
		std::cout << "a : " << a << " , b : " << b << "\n";
	}

	~Foo() {
		std::cout << "Destructor !\n";
	}
};

int main(void) {
	auto ptr = std::make_unique<Foo>(3, 3);

	return 0;
}

위 코드에서 22 lines의 코드는 다음의 코드와 같습니다.

std::unique_ptr<Foo> = ptr(new Foo(3, 3));

 


다음 글에서 또 다른 스마트 포인트인 share_ptr과 weak_ptr에 대해서 알아보도록 하겠습니다.

댓글