Item 13: 자원 관리에는 객체가 그만

프로그래밍 분야에서 자원이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야 하는 모든 것을 일컽는다. C++에서 가장 흔하게 말할 수 있는 자원이 동적 할당한 메모리를 말할 수 있는데 돌려주지 않으면 메모리 누수가 발생한다. 메모리는 수많은 자원 중 한가지에 불과하며 자원에는 파일 서술자(file descriptor), 뮤텍스 잠금(mutex lock), 그래픽 유저 인터페이스에서 쓰이는 폰트, 브러쉬 네트워크 소켓 등이 있다.

이들의 공통점은 가져와서 썼으면 다시 해제해야 하는 것이다.

기존 호출자 자원 해제의 문제점

Investment라는 최상위 클래스가 있고, 이것을 기본으로 하여 구체적인 형태의 클래스가 파생되었다고 가정해보자. 추가로 해당 클래스의 객체의 사용자를 얻어내는 용도의 팩토리 함수도 있다.

class Investment { ... };

// 여러 형태의 투자를 모델링한
// 클래스 계통의 최상위 클래스
Investment* createInvestment();

// Investment 클래스 계통에 속한
// 클래스의 객체를 동적 할당하고
// 그 포인터를 반환한다. 해제는 호출자에서

객체를 사용할 일이 없을 때 그 객체를 삭제해야 하는 쪽은 이 함수의 호출자(caller)이다. 실제 사용하는 사례를 예시로 만들어보면 다음과 같다.

void f()
{
    Investment* pInv = createInvestment();
    // ...
    delete pInv;
}

아무런 문제가 없어보이지만, 팩토리 함수로 부터 얻은 객체의 삭제에 실패할 수 있는 경우의 수가 한두 가지가 아니다. ...부분에서 도중에 리턴문이나 goto문, continue문, 내부에서 예외를 던지는 경우 등 delete문이 실행되지 않을 경우가 매우 많다.

만약 delete문이 실행되지 않는 경우에는 메모리가 누출되고, 그와 동시에 해당 객체가 가지고 있던 자원까지 모두 누출된다.

또 다른 문제점은 이런 함수가 정상적으로 동작한다고 해도, delete문이 가지는 약점은 대부분의 프로그래밍은 협업으로 이뤄지기에 타인에 의해 예기치 못한 방식으로 호출될 수 있다는 점이다. 이런 경우 프로그램의 버그를 찾기 어려운 상황이 발생할 수 있다.

결국 호출자에서 해당 객체를 자체적으로 해제하는 방법보단 createInvestment 함수로 얻어낸 자원이 항상 해제되도록 만들어야 한다. 이는 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만드는 것이다.

자원 관리 객체로 해결하기

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

표준 라이브러이에 존재하는 auto_ptr이란 것이 있는데, 바로 이런 용도에 쓰라고 마련된 클래스이다. 스마터 포인터는 앞서 말한 문제점을 해결하기 위해 나온 개념으로 가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 잇따.

void f()
{
    std::auto_ptr<Investment> pInv(createInvestment()); // 팩토리 함수

    ... // 호출자가 자원을 사용하는 코드

    // 함수가 끝나면 자동으로 자원이 해제된다.
}

아주 간단한 예제이지만 자원 관리에 객체를 사용하는 방법(이유)의 중요한 두 가지 특징을 말할 수 있다.

  • 자원을 획득한 후에 자원 관리 객체에게 넘긴다.
    • 팩토리가 만들어준 자원은 그 자원을 관리할 auto_ptr 객체를 초기화하는 데 쓰이고 있다.
    • 이렇게 자원 관리에 객체를 사용하는 아이디어에 대한 업계 용어로는 RAII(Resource Acquisition Is Initialization)이라고 한다.
    • RAII관련 정리글 추가 정리 예정..
  • 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다.
    • 소멸자는 어떤 객체가 소멸될 때 자동적으로 호출되기 때문에, 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가 제대로 이루어지게 되는 것이다.
    • 물론 해제하다가 예외가 발생될 수 있는 상황에 빠지면 사태가 많이 꼬이게 된다. 이 문제는 item 8에서 해결한다.

auto_ptr의 문제점

auto_ptr은 자신이 소멸될 때 자신이 가리키고 있는 대상에 대해 자동으로 delete를 먹이기 때문에, 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안된다. 만약 둘 이상이 가리키게 된다면 자원이 두 번 삭제되는 문제가 생기고 이는 미정의 동작으로 간주된다.

이런 불상사를 막고자 auto_ptr은 객체를 복사하는 경우 원본 객체를 null로 만든다. 복사하는 객체만이 그 자원의 유일한 소유권을 갖다는다고 가정한다. 이는 원래 알고 있던 복사 동작에 혼란을 줄 수 있다.

auto_ptr의 대안

auto_ptr을 쓸 수 없는 상황이라면 그 대안으로 참조 카운팅 방식 스마터 포인터(reference-counting smart pointer)를 사용할 수 있다. RCSP는 특정한 어떤 자원을 가리키는 외부 객체으 개수를 유지하고 있다가 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마터 포인터이다.

단순하게 보면 가비지콜렉션의 기능과 흡사하지만 참조 상태가 고리를 이루는 경우를 없앨 수 없다는 점은 가비지 콜렉션과 다르다. (서로를 가리키는 두 객체가 있으면 둘 다 삭제되지 않는 문제가 생긴다.) weak_ptr이라는 것이 이를 해결하기 위해 존재한다. but 결국은 트레이드 오프라는 점을 기억하자.

void f()
{
    std::shared_ptr<Investment> pInv(createInvestment());

    ...
}
// 함수가 끝나면 자동으로 자원이 해제된다.
void f()
{
    std::shared_ptr<Investment> pInv1(createInvestment());
    // pInv1이 가리키는 대상은 createInvestment에서 반환된 객체
    std::shared_ptr<Investment> pInv2(pInv1);
    // pInv2가 가리키는 대상은 역시 createInvestment에서 반환된 객체
    // 즉, 두 개의 객체가 하나의 자원을 가리키고 있음

    pInv1 = pInv2;
    // 마찬가지로 변한게 없음

    ...
}
// pInv1과 pInv2이 소멸되면서
// 두 개의 객체가 가리키는 자원을 해제한다.

중요하게 기억해야 할 포인트는 뒤에서 따로 다루긴 함 스마터 포인터는 소멸자 내부에서 delete연산자를 사용한다. delet [] 연산자가 아니다. 말하자면, 동적으로 할당한 배열에 대해 스마터 포인터를 사용하면 난감하다는 것이다.

정리

이번 항목에서 “자원 관리에 객체를 쓰자”라는 지침은 자원 해제를 일일이 하다 보면 언젠가 잘못을 저지르고 만다는 이야기다. 스마터 포인터의 도움을 받을 수 있지만, 결국은 자원 관리 클래스를 직접 만들어야 한다는 점이다.

  • 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII객체를 사용하자
  • 일반적으로 널리 쓰이는 RAII 클래스는 auto_ptr과 shared_ptr이 있다.

but, C++11 이후로 auto_ptr은 사라졌다. 대신 unique_ptr과 shared_ptr이 등장했다.

또한, move의 개념이 좀 더 도입되면서 소유권 이전이 좀 더 효율적으로 동작한다.>

태그: ,

카테고리:

업데이트:

댓글남기기