3장. 자원관리👥

안녕하세요! 두두코딩 입니다 ✋
오늘은 자원관리 클래스의 복사하는 방법들에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

HEAP 영역이 아닌 자원 관리에 대한 고찰

우리는 이전 포스팅을 통해 자원 관리의 핵심 기능인 RAII 기법에 대해 공부를 했다.

이전 내용을 잠시 상기시켜 보자면, auto_ptr 혹은 tr1::shared_ptr을 활용해 HEAP메모리 영역의 메모리를 얻어 데이터를 활용한 후 자체적으로 소멸 시키도록하는 방법을 배웠다.

사실 생각해보면, 모든 객체가 메모리에서 생성되어지지 않는다는 점을 쉽게 알 수 있다. HEAP 생기지 않는객체는 auto_ptr등과 같은 관리 객체로 관리할 수 없다. 따라서, 대부분은 아니지만 일부는 우리가 만들어서 자원을 관리해야될 때가 있다.

사용자가 만드는 자원관리 객체

사용자가 만들어 사용하는 자원 관리 객체는 어떤 것들이 있을까?

운영체제를 공부하다보면, Mutex 개념을 배운다. Mutex란 race condition이 발생하는 부분을 제거하기 위해 사용되는데, locking 개념이다. 만약 Mutex를 획득한후 자원을 다 사용한 후에는 항상 Mutex를 시스템에 반환해 다음 thread가 수행할 수 있도록 해야한다.

사용자를 너무 믿지 말라고 했던 것이 슬 떠오를 것이다. 만약 사용자가 Mutex를 획득한 후 Mutex를 해제해주지 않는다면 다음 thread는 해당 영역에 들어오지 못하고 대기해야하는 즉, deadlock 상태를 유발할 수 있게 된다.

따라서, 이런 Mutex는 관리 객체로 생성되는데, 이 Mutex가 Heap 영역이 아닌 일반 Stack영역에서 만들어 져야한다고 가정해보자. 우리는 Mutex를 관리할 수 있는 lock이라는 객체를 만들어 사용할 것이다.

// 우리가 Mutex를 lock, unlock을 위해 사용하는 API이다.

// pm이 가리키는 mutex를 lock 함
void lock(Mutex *pm);
// pm이 가리키는 mutex를 unlock함
void unlock(Mutex *pm);

위의 API는 Mutex를 활용해 lock과 unlock을 할 수 있는 API이다. 해당 함수들을 활용해 관리 객체를 만들어보자.

class Lock {
public:
  explicit Lock(Mutex *pm);
  :mutexPtr(pm)
  { lock(mutexPtr); }

  ~Lock() { unlock(mutexPtr); }

private:
  Mutex *mutexPtr;
};

int main() {
  ...
 //Mutex 선언
  Mutex m;
  {
  // Lock을 생성하는데, {  }영역 내에서만 사용
    Lock m1(&m);

    ...
  // block을 나가게 되면, unlock 됨
  }
}

위의 코드는 RAII법칙을 적용해, Lock 이라는 관리 객체를 만들어 Mutex를 사용하고 해제하도록 구성했다. 위의코드를 자세히 보면, 문제가 없을 것 같아 보인다. 하지만, 아래의 코드와 같이 Lock 객체가 복사가 된다면 어떻게 해야할까?

Lock ml1(&m); // m의 잠금을 건다.
Lock ml2(ml1); // m1을 m2로 복사한다.

위의 코드는 어떻게 정리해야하는가? 사실 Lock이 아니라 좀 더 일반화해서 생각할 수 있다. RAII 클래스는 객체가 복사될때 어떤 동작을 해야하는가?

보통 아래의 4가지로 축약되고, 이 중 하나를 선택하게 된다.

복사할 때 취할 수 있는 4가지 선택

🌱 복사를 금지한다

RAII 객체가 복사되도록 놔두는 것 자체가 말이 안되는 경우가 많이 존재한다. 위와 같은 Lock 관리 객체도 실제로는 복사를 금지하는 것이 맞다. 왜냐하면 thread를 동기화 하기 위한 객체인데, 이 Lock이 복사되는 건 동기화를 포기하겠다는 의미이기 때문에 금지하는 것이 올바른 선택이다.

복사를 막는 방법에 대해서 우리는 이전 포스팅 을 통해 배웠다. 배웠던 것을 기반으로 적용시켜 복사를 막아보자.

class Uncopyable {
protected:
  Uncopyable();
  ~Uncopyable();

private:
  Uncopyable(const Uncopyable&);
  Uncopyable& operator=(const Uncopyable&);
};

class Lock: private Uncopyable {
public:
  ...
};

위와같이, Uncopyable을 만들어 복사를 금하게 만들면 된다. 물론 modern C++을 쓰는 사람이라면 delete를 활용해 제거해도 상관없다. 만약 delete 사용법을 잘 모른다면 여기를 클릭해 확인해보자.

🌱 관리하고 있는 자원에 대한 참조 카운팅 활용

위의 방법과 같이, 복사를 아예하지 못하도록 할 수 있지만 때론 자원을 사용하고 있는 마지막 객체가 소멸될 때까지 관리 객체가 유지해야될 경우도 존재한다.

예를 들어, 우리는 Mutex를 한개가 아닌 다수개를 사용해야할 경우가 존재할 수 있다. 그럴 경우 참조 카운팅 기법을 활용해야한다.

이럴 경우, 관리객체에서 해당 자원을 참조하는 개수를 파악해, RAII 객체의 복사 동작을 만들도록 해야하는데, 우리가 배웠던 tr1::shared_ptr을 이용하면 쉽게 할 수 있다.

즉, 기존의 Lock코드에서는 Mutex를 일반 포인터 (raw pointer)로 갖고 있었다. 하지만 이를 raw pointer가 아닌 tr1::shared_ptr로 변경하게 된다면 mutex가 다수개 들어오더라도 참조가 가능하다.

그럼 여기서 눈치 빠른 사람들은 생각할 수 있다. tr1::shared_ptr는 카운트가 0이 될 경우 자원을 해제 즉, 삭제하도록 되어져 있는데 왜 저걸 쓰지? 우리는 Mutex를 unlock하도록 해야하는데.. 라고 말이다.

tr1::shared_ptr에서는 다행스럽게도 단위 전략 기법을 제공하며, 삭제자라는 것을 전달할 수 있도록 구현해두었다. 삭제자라고 하면, tr1::shared_ptr이 유지하는 참조 카운트가 0이 될 경우 호출되는 hook 함수라고 볼 수 있다. 해당 삭제자tr1::shared_ptr의 두번째 인자로 넘겨주어 변경할 수 있도록 한다.

class Lock {
public:
  explicit Lock(Mutex *pm)
    // 삭제자 전달
  : mutexPtr(pm, unlock);
    // shared_ptr에서 raw_pointer를 꺼내 전달함.
  { lock(mutexPtr.get()); }

  // 소멸자를 만들 필요가 없음. 삭제자가 변경되었기 때문
  // ~Lock()

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

위의 코드를 보면, 생성자에서 shared_ptr의 두번째 인자로 unlock함수를 전달하는 것을 볼 수 있다. 위에서 중요한 점은 unlock함수를 hook 하라고 전달했기 때문에, 따로 소멸자를 만들 필요가 없다는 점이다. 소멸자를 만들지 않으면, 컴파일러가 자동 생성해주기 때문에 우리는 신경쓸 필요 없다. 만약 hook의 개념 혹은 단위전략패턴 개념을 모르는 사람을 위해 소멸자가 없는 부분에 대해 간단하게, “컴파일러가 자동 소멸자를 생성할 것”이라고 적어주는 센스를 발휘하자.

위와 같이 참조 카운트 기반으로 관리 객체를 만들 경우 복사에도 문제 없이 동작한다.

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

해당 방법은 우리가 문법책에서 배우는 Deep Copy 개념이다. 때에 따라서 자원을 원하는 대로 복사할 수 있어야한다. 이때 “자원을 다 썼을 때 각각의 사본을 확실히 해제하는 것” 이 중요하다. 자원 내에 있는 모든 값을 복사하도록 해야하며, 해제도 확실하게 해줘야한다는 것을 잊지말자.

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

그리 흔한 경우는 아닌데, 어떤 특정한 자원에 대해 그 자원을 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 만들고 싶을 경우다. RAII 객체가 복사될 때 그 자원의 소유권을 사본쪽으로 아예 옮기는 것이다. 생각해보면 우리는 이전 포스팅에서 해당 내용을 배웠는데 바로 auto_ptr이 위와 같이 동작한다.

객체 복사함수는 컴파일러에 의해 생성될 여지가 있기 때문에 컴파일러가 생성한 버전의 동작이 우리가 예상한 동작과 맞지 않을 수 있다. 이럴 경우 우리는 직접 복사를 만들어야하는데, 위와 같이 많은 케이스를 놓고 생각해서 적절한 복사를 구현할 수 있도록 하자.

To Sum Up

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

👉 RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅 해주는 선으로 마무리된다. 그렇다고 해서 마지막 2가지를 잊지 말고 알고 있도록 하자.