3장. 자원관리👥

안녕하세요! 두두코딩 입니다 ✋
오늘은 항목 13의 자원 관리 객체를 사용해야하는 이유에 대해 알아보겠습니다.

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

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

시작하기 전에

3장 자원관리에 대해 시작하기 전에 몇 가지 알아보자. 프로그래밍 분야에서 자원 (Resource)이란 사용을 마치고 난 후 시스템에 돌려주어야 하는 모든 것들을 말한다. 우리가 흔히 동적할당한 메모리를 자원이라고 생각한다.

사실 생각해보면, 메모리 뿐아니라 자원에는 파일 서술자 (File Descriptor), 뮤텍스 잠금(Mutex lock), 폰트, brush, 데이터베이스 연결 등 다양한 것들이 자원에 해당된다.

여기서 중요한 점은 자원을 가져다 썼으면 다시 돌려 줌 즉, 해제해야하는 사실이다.

일례로, 우리는 new 연산을 사용해 자원을 하나 할당받고 delete연산을 통해 자원을 반환하도록 구성한다. 하지만, 실제 프로그래밍을 할 때, delete연산을 잊어버리는 경우가 빈번하게 발생한다. 쉽게 생각해보면, delete연산 전 예외, return 문 등을 만나게 되면 실제 자원을 해제하지 못한다.

C++에서는 이런 부분을 기본적으로 지원하는 “생성자”, “소멸자”, “복사 함수들”을 이용하여 100%는 아니지만 아주 많은 확률로 자원누수현상을 막을 수 있다.

이제부터 C++에서 기본적으로 제공하는 방법을 통해 자원 누수현상을 박멸하는 방법에 대해 알아보자. 🙆

자원누수 현상

우리가 프로그래밍의 외주를 받아 투자 프로그램을 만든다고 가정해보자.

class Investment { ... };

우리는 프로그램을 만들기 위해 Investment라는 class를 만들 것이다. Investment를 기반으로 두고 다양한 투자 상품들 즉, 파생클래스가 생성되는데, 파생 클래스의 생성을 용이하게 하기 위해 Factory pattern을 이용해 구성했다고 가정해보자.

Tip Factory pattern은 파생클래스의 객체를 생성할 때, 다형성을 유지하기 위해 사용하는 패턴이다. 소스코드 내에서 객체 생성하는 부분의 변화를 최소화 하기 위해 사용한다. 해당 내용은 여기를 클릭해 알아보도록 하자.

void foo() {
  ...
  // 아래의 함수는 Investment 클래스 계통에 속한 함수
  // 클래스의 객체를 동적 할당하고, 포인터를 반환한다.
  // 객체의 해제는 호출한 곳에서 직접 하도록 한다.
  Investment* pInv = createInvestment();

  ...

  delete pInv;
}

우리는 위의 코드와 같이, Factory Pattern으로 구성된 createInvestment()를 활용해 파생 클래스의 객체를 생성한다. 해당 코드는 파생 클래스는 동적할당 되고 포인터를 반환하며 , Investement 포인터로 가리키도록 동작한다. 이럴 경우 주석에 적힌것과 같이 만약 Investment 객체를 사용을 다했을 때, 객체의 해제는 호출한 곳 (caller) 에서 직접 하도록 해야한다.

위의 코드에서 createInvestment()가 정상동작한다는 전제하에 작성되어졌지만, 만약 함수를 호출하는 중에 실패할 수 있는경우를 만나게 된다면 우리는 자원누수를 경험하게된다.

생각보다 함수 호출에 실패하는 경우가 다양하다.


🌷 위의 코드에서 “…” 으로 표기된 부분 즉, pInv를 사용한다고 가정하는 부분에서 return문이 들어있을 경우

🌷 deleteloop내 들어있고, loop내에서 continue 혹은 goto를 호출하게 될 경우

🌷 위 코드에 “…” 부분에서 예외가 발생할 경우


위와 같이, 다양한 방법으로 delete문을 건너뛰게 되지만, 결과적으로 delete문을 호출하지 못해, 메모리 즉, 자원누수가 발생하게 된다.

위와 같은 자원누수를 막기위해서는 어떻게 해야할까? 하나하나 따져가면서 코딩해야할까?

물론, 하나씩 따져가면서 코딩을하면 좋긴하지만 시간적, 마음적 여유가 없다. 그리고, 내가 아무리 자원누수에 초점을 맞춰 코딩을 한들 몇년 후 다른 사람이 리펙토링하게 될 경우 또 다른 자원누수가 발생할 수 있다.

그렇다면 이 문제를 어떻게 해결해야할까? 😓

자원 관리 객체

문제를 해결하기 위해서는 관리 객체를 하나 더 만들어 두면 된다.

구체적으로, foo() 내에서 createInvestment()호출 할 때, 결과를 일반적인 Pointer가 아닌 관리 객체에 넣도록 한다. 해당 관리 객체foo()내에서 생성하도록 하고, 해당 함수를 벗어나면 소멸자가 불리도록 해, 소멸자 내에서 자원을 해제하도록 수정한다.

Tip 소프트웨어 개발에 쓰이는 상당수의 자원이 메모리 HEAP 영역에서 동적으로 할당된다. 그리고, 하나의 블록 혹은 함수내에서만 사용되는 경우가 많기 때문에 블록 혹은 함수를 빠져나올 때, 자원이 해제하도록 하는것이 올바른 방법이다.

C++에서는 표준라이브러리로 자원을 관리하기 위해 만들어 둔 클래스 auto_ptr이 존재한다. auto_ptr은 포인터와 비슷하게 동작하는 객체로 “스마트포인터” 라고 불린다. 해당 객체는 가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 구현되어져있다.

Tip Modern C++에서는 auto_ptr 외에, unique_ptr, shared_ptr등이 존재한다. 자세한 내용을 알고 싶다면 구글하자! 🤗

void foo()
{
  // 자원생성을 위해 팩토리 함수 호출
  std::auto_ptr<Invetment> pInv(createInvestment());

  ...
  // auto_ptr이 자동으로 delete해줌
  // delete가 필요없어짐.
}

예시코드를 auto_ptr을 사용해서 바꾼 코드이다. 코드를 통해 우리는 관리 객체를 사용하는 두 가지 중요한 특징을 알 수 있다.

🌱 자원을 획득 후 자원 관리 객체에 넘김

위의 예시를 보면, createInvestment()를 통해 만들어진 자원은 자원을 관리할 auto_ptr의 객체를 초기화 하는데 사용된다. 즉, 자원을 획득하면서 관리객체를 초기화 하도록 하는 방법인데, 해당 테크닉은 C++ 내에서 엄청 자주사용된다.

해당 테크닉을 RAII (Resource Acquisition Is Initialization) 이라고 부른다.

🌱 자원 관리 객체는 자신의 소멸자를 활용해 자원이 확실히 해제되도록 함

소멸자는 객체가 소멸될 때, 자동적으로 호출되어지기 때문에 어떤 경위로 블록 혹은 함수를 벗어나는 것과 관계없이 자원 해제를 확실하게 이루어지도록 한다.

auto_ptr을 통해 추출한 특징은 위와 같다. 또한 auto_ptr은 소멸자를 통해 자원을 해제(제거)하기 때문에 오직 하나만 존재해야한다는 특징이 있는데.. 🙄

아래에서 auto_ptr의 특징에 대해 조금 더 자세히 알아보자.

auto_ptr 의 특징

위에서 살짝 언급한 것과 같이, auto_ptr은 소멸자가 호출 될 때, 가리키고 있는 대상을 delete연산을 통해 해제(제거)하기 때문에 둘 이상 존재하면 안된다. 만약 어떤 객체를 가리키는 auto_ptr이 두개 이상이 된다면 자원을 2번 삭제하기 때문에 미정의 동작이 발생하게 된다. 절대 이런 방식으로 구현되면 안된다.

이런 불상사를 막기위해 auto_ptr은 복사함수에 제약을 걸어서 관리한다. 아래의 예시를 보자. 복사함수를 호출할 때, 원본 객체는 null로 만들고, 사본 객체만이 해당 자원의 유일한 소유권을 갖도록 한다.

// createInvestment 함수에서 반환된 객체를 가리킴
std::auto_ptr<Investment> pInv(createInvestment());

// pInv2는 createInvestment 함수에서 반환된 객체를 가리킴
// pInv는 null
std::auto_ptr<Investment> pInv2(pInv);

// 위의 값과 반대가 됨.
// pInv2가 null이 되고, PInv는 펙토리함수에서 반환된 객체를
// 가리키도록함.
pInv = pInv2

RCSP (Reference couting smart pointer)

STL 컨테이너 같은 경우 가리키는 원소 값들이 정상적으로 복사되야하는 함수들을 갖고 있다. 이를테면 copy or copy_if등과 같이 원소자체를 복사해야할 때도 있다. 그럴 경우 기존의 값인 null로 변경되면 안되는데 이럴땐 어떻게 해야할까?

auto_ptr을 사용할 수 없는 상황이라면, 대안으로 참조 카운팅 방식 스마트 포인터를 활용하면 된다. RCSP라고 부르며, 특정 객체를 가리키는 포인터 즉, 외부 객체의 갯수를 카운팅하고 있다가 0이 되면 자원을 해제하는 방식으로 동작한다.

TR1에서 제공하는 tr1::shared_ptr이 대표적인 RCSP이다.

Tip TR1 이란, modern C++로 넘어오기 전 Technical Report에 필요한 기술들을 정리해두고 사용했던 라이브러리를 말한다. 현재는 modern C++에 다 녹아있으며, C++11 부터는 shared_ptr이라고 사용하면 된다. (tr1 네임스페이스를 사용할 필요가 없다)

void foo()
{
  ...
  std::tr1::shared_ptr<Investment>
    pInv(createInvestment());
  ...
}

auto_ptr을 사용한 부분을 tr1::shared_ptr로 변경하였다.

만약 shared_ptr을 사용해 복사를 하면 어떻게 될까?

void foo()
{
  ...
  std::tr1::shared_ptr<Investment>
    pInv1(createInvestment());

  std::tr1::shared_ptr<Investment>
    pInv2(createInvestment());

  // pInv1은 null로 변경되지 않는다.
  pInv1 = PInv2;
  ...
} // pInv1, pInv2는 소멸되고, 참조 카운트가 0이되 자원이 해제된다.

위의 예시와 같이, 복사를 해도 이전 값이 바뀌지 않는다는 점에 주목하자. auto_ptr을 사용하게 되면, 관리는 되나 복사 시, 이전 값을 null로 한다는 점도 기억하자.

우리가 “smart pointer”관련해서는 뒷부분에서 배울 것이기 때문에 여기서는 위의 문장들만 주의깊게 기억하도록 하자.

delete 와 delete[]

마지막으로 관리객체의 auto_ptr에서 알아둬야할 부분이 있는데, 소멸자 내부에서는 delete연산이 호출 된다. 즉, delete[]연산이 호출되지 않는다.

말하자면, 동적으로 할당된 배열에 대해 auto_ptr이나 tr1::shared_ptr 사용할 경우 난감한 현상이 발생한다.

std::auto_ptr<std::string>
  aps(new std::string[10]);

std::tr1::shared_ptr<int>spi(new int[1024]);

위의 코드는 auto_ptr 혹은 shared_ptr을 통해 배열을 할당 받고 있는 예시이다. 소멸자내에서 delete연산을 하기 때문에 모든 자원을 지울 수 없어 에러 혹은 경고가 발생해될 것 같지만.. 컴파일러단에서는 에러가 발생하지 않는다. (실은 에러를 발생하지 않는게 더 문제다..)

C++에서는 auto_ptr 혹은 tr1::shared_ptr와 같은 관리 객체에서는 동적할당된 배열을 처리하도록 하지 않는다. 왜냐하면 동적할당된 배열은 기존에 존재하는 vector 혹은 string으로 커버가 가능하기 때문이다. 만약 배열을 커버하는 관리객체를 써야하겠다면, boost라이브러리를 찾아서 확인해보자.

To Sum Up

이번 항목에서는 “자원 관리에 객체를 쓰자” 가 핵심 키워드이다. 자원을 개발자가 일일이 하다보면 언젠가는 놓치는 문제가 발생하고, 이후 리펙토링을 다른 사람이 하다가 놓칠 경우가 생길 수 있다. 따라서, 널리 쓰이고 있는 관리 객체를 활용해여 자원을 관리하도록 하자.

👉 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 해제하는 RAII 객체를 사용하자

👉 일반적으로 널리 쓰이는 RAII 객체는 auto_ptr 혹은 tr1::shared_ptr 이다. 두 객체의 차이는 “복사” 에 있으며, 복사시 누가 null로 만들고 누가 couting을 중요시 하는지 기억하자.