본문 바로가기
서적 정리/Effective C++

14.자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자

by 민돌이2 2021. 12. 11.

자원 관리 클래스의 주축을 이루는 아이디어인 자원 획득 즉 초기화(RAII)기법, 힙 기반 자원에 의해 이 아이디어를 명쾌하게 적용한 스마트 포인터가 있다.

하지만 세상의 자원이 모두 힙에서 생기지 않는다는 현실이 있다.

그러기에 자원 관리 클래스를 스스로 만들어야 할 필요성이 있다.

 

Mutex 타입의 뮤텍스 객체를 조작하는 C의 API를 사용하고 있다고 가정해보자.

C의 API에서 제공하는 함수 중엔 lock 및 unlock이 있다.

void lock(Mutex* pm); //pm이 가리키는 뮤텍스에 잠금을 건다.
void unlock(Mutex* pm); //pm이 가리키는 해당 뮤텍스의 잠금을 해제한다.

이전에 걸어 놓은 뮤텍스 잠금을 잊지 않고 풀어 줄 목적인 뮤텍스 잠금을 관리하는 클래스를 하나 만들때 RAII법칙을 따라 구성한다. 즉 자원을 획득하고, 소멸 시에 그 자원을 해제하는 것이다.

class Lock
{
public:
	explicit Lock(Mutex* pm)
		: mutexPtr(pm)
	{ 
		lock(mutexPtr); //자원 획득
	} 

	~Lock()
	{
		unlock(mutexPtr); //자원 해제
	}

private:
	Mutex* mutexPtr;
};

사용자는 Lock을 사용할 때 RAII방식에 맞춰 쓰면 된다.

{
	Mutex m; //뮤텍스 정의

	Lock m1(&m); //뮤텍스에 잠금
} //지역 변수 소멸, 즉 m1의 소멸자를 호출하면서 자동으로 자원 해제

 

위의 코드는 문제 없어보인다. 그런데 Lock 객체가 복사가 된다면 어떻게 될까?

Mutex m;

Lock m1(&m);
Lock m2(m1); //복사 생성자

RAII객체가 복사될 때 어떤 동작이 이뤄져야 할까? 

 

1.복사를 금지한다.

복사하면 안 되는 RAII 클래스에 대해서 반드시 복사가 되지 않도록 막는 방법은 복사 연산을 private 멤버로 만드는 것이다(6장 참고).

class Uncopyable
{
protected:
	Uncopyable() {} //생성자 호출 허용
	~Uncopyable() {} //소멸자 호출 허용

private:
	Uncopyable(const Uncopyable&); //복사 생성자 호출 불가
	Uncopyable& operator=(const Uncopyable&); //복사 대입 연산자 호출 불가
};

class Lock : private Uncopyable
{
public:
	explicit Lock(Mutex* pm)
		: mutexPtr(pm)
	{
		lock(mutexPtr); //자원 획득
	}

	~Lock()
	{
		unlock(mutexPtr); //자원 해제
	}

private:
	Mutex* mutexPtr;
};

 

2.관리하고 있는 자원에 대해 참조 카운팅을 수행한다.

자원을 갖고 있는 마지막 객체가 소멸될 떄까지 그 자원을 해제하지 않는게 바람직한 경우가 종종 있다.

이럴 경우에 해당 자원을 참조하는 객체의 개수에 대한 카운트를 증가시키는 식으로 RAII 객체의 복사 동작을 만들어야 한다. tr1::shared_ptr이 이 방식을 사용하고 있다.

Lock클래스의 멤버 변수인 Mutex* mutexPtr을 tr1::shared_ptr<Mutex> mutexPtr로 바꾸면 된다.

하지만 tr1::shared_ptr은 참조카운트가 0이 될 때 자신이 가리키고 있던 대상을 삭제해 버리도록 구현되어 있다.

현재 Lock은 잠금 해제만 하면 되지 삭제까지는 원하지 않는다.

tr1::shared_ptr에는 삭제자(deleter) 지정이 허용한다. 여기서 삭제자란 tr1::shared_ptr이 유지하는 참조 카운트가 0이 되었을 때 호출되는 함수 혹은 함수 객체를 말한다. 삭제자는 tr1::shared_ptr생성자의 두 번째 매개변수로 선택적으로 넣어 줄 수 있다. 

class Lock
{
public:
	explicit Lock(Mutex* pm)
		: mutexPtr(pm, unlock) //mutexPtr 초기화, shared_ptr의 삭제자로 unlock
	{ 
		lock(mutexPtr.get());
	} 

private:
	tr1::shared_ptr<Mutex> mutexPtr;
};

위 코드를 보면 소멸자가 없다. 클래스의 소멸자는 비정적 데이터 멤버의 소멸자를 자동으로 호출하게 되어있다.

어차피 컴파일러가 자동으로 만들어주니 따로 동작하길 원하는 게 없으면 구현할 필요 없다(5장 참고).

mutexPtr의 소멸자는 뮤텍스의 참조 카운트가 0이 될 때 tr1::shared_ptr의 삭제자를 자동으로 호출할 것이다. 위 코드에서는 unlock함수를 두 번째 매개변수로 넣어줬다.

mutexPtr.get()에서 shared_ptr의 get() 함수는 소유한 자원(포인터)에 대한 주소를 반환한다(15장 참고).

이런 방식은 주석으로 컴파일러가 생성한 소멸자를 통해 동작한다고 적어두는게 협업 시에 좋을 것이니 알아두자

 

3.관리하고 있는 자원을 진짜로 복사한다.

자원 관리 객체를 복사하면 그 객체가 둘러싸고 있는 자원까지 복사해야 한다. 즉 깊은 복사를 수행해야 한다.

표준 string 타입을 보면 문자열을 구성하는 원소들을 힙 메모리에 저장해 놓고, 이 메모리에 대한 포인터를 데이터 멤버로 갖고 있다. string 타입으로 생성한 객체는 결국 힙 메모리를 포인터로 물고 있는 형태가 되는데, 이때 이 객체를 복사하면 사본은 포인터 및 그 포인터가 가르키는 새로운 힙 메모리를 갖게 된다. 즉 깊은 복사가 이뤄진다.

 

4.관리하고 있는 자원의 소유권을 옮긴다.

특정한 자원에 대해 그 자원을 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 만들고 싶을때 단 하나의 소유권을 보장해주는 자료구조가 있다. auto_ptr이다(13장 참고). RAII 객체가 복사될 때 그 자원의 소유권을 사본 쪽으로 아예 옮겨버리고 원본은 NULL로 만들어 버린다.

 

이것만은 잊지 말자

RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정된다.

RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해주는 선으로 마무리하는 것이다.

728x90

댓글