가비지 컬렉션(garbage collection) 기능을 아예 하단에 놓고 기본적으로 지원하는 프로그래밍 환경들(예를 들면 자바나 닷넷 등)이 저마다의 매력을 뿜어내는 요즘, 여전히 "수동"만을 고수하는 C++의 메모리 관리 방법은 어떻게 보면 구닥다리로 보일 수 있습니다. 그럼에도 불구하고 메모리를 수동으로 관리할 수 있다는 점은 큰 장점이 될 수 있습니다.
사용자가 보낸 메모리 할당 요청을 operator new 함수가 맞추어 주지 못할 경우에(즉, 할당할 메모리가 없을 때) operator new 함수는 예외를 던지게 되어 있습니다. 예외를 던지기 전에, 이 함수는 사용자 쪽에서 지정할 수 있는 에러 처리 함수를 우선적으로 호출하도록 되어 있는데, 이 에러 처리 함수를 가리켜 new 처리자(new-handler, 할당에러 처리자)라고 합니다. 표준 라이브러리에는 set_new_handler라는 사용자가 지정할 수 있는 함수가 준비되어 있습니다.
1 2 3 4 | namespace std { typedef void(*new_handler)(); new_handler set_new_handler(new_handler p) throw(); } | cs |
new_handler는 받는 것도 없고 반환하는 것도 없는 함수의 포인터에 대해 typedef를 걸어 놓은 타입동의어입니다. 그리고 set_new_handler는 new_handler를 받고 new_handler를 반환하는 함수이죠. set_new_handler가 받아들이는 new_handler 타입의 매개변수는 요구된 메모리를 operator new가 할당하지 못했을 때 operator new가 호출할 함수의 포인터입니다. 반환 값은 지금의 set_new_handler가 호출되기 바로 전까지 new 처리자로 쓰이고 있던 함수의 포인터입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // 충분한 메모리를 operator new가 할당하지 못했을 때 호출할 함수 void outOfMem() { std::cerr << "Unable to satisfy request for memory\n"; std::abort(); } int main() { std::set_new_handler(outOfMem); int *pBigDataArray = new int[100000000L]; ... } | cs |
만약 operator new가 1억 개의 정수 할당에 실패하면 outOfMem 함수가 호출될 것이고, 이 함수는 에러 메시지를 출력하면서 프로그램을 강제로 끝내 버릴 것입니다. 사용자가 부탁한 만큼의 메모리를 할당해 주지 못하면, operator new는 충분한 메모리를 찾아낼 때까지 new 처리자를 되풀이해서 호출합니다. 이를 통해 호출'되는' new 처리자 함수가 프로그램의 동작에 좋은 영향을 미치는 쪽으로 설계되어 있다면 다음 동작 중 하나를 꼭 해주어야 한다는 점을 잘 알아두십시오.
사용할 수 있는 메모리를 더 많이 확보합니다. operator new가 시도하는 이후의 메모리 확보가 성공할 수 있도록 하자는 전략입니다. 구현 방법은 여러 가지가 있지만, 프로그램이 시작할 때 메모리 블록을 크게 하나 할당해 놓았다가 new 처리자가 가장 처음 호출될 때 그 메모리를 쓸 수 있도록 허용하는 방법이 그 한 가지입니다.
다른 new 처리자를 설치합니다.
현재의 new 처리자가 더 이상 가용 메모리를 확보할 수 없다 해도, 이 경우에 자기 몫까지 해 줄 다른 new 처리자의 존재를 알고 있을 가능성도 있겠지요.
만약 그렇다면 현재의 new 처리자는 제자리에서 다른 new 처리자를 설치할 수 있습니다(현재의 new 처리자 안에서 set_new_handler를 호출합니다).
operator new 함수가 다시 new 처리자를 호출할 때가 되면, 새로 설치된(가장 마지막으로 설치된) new 처리자가 호출되는 것입니다.
이 방법을 살짝 비틀어, new 처리자가 자기 자신의 동작 원리를 변경하도록 만들 수도 있습니다.
이렇게 만드는 한 가지 방법은 new 처리자의 동작을 조정하는 데이터를 정적 데이터 혹은 네임스페이스 유효범위 안에 데이터, 아니면 전역 데이터로 마련해 둔 후에 new 처리자가 이 데이터를 수정하게 만드는 것입니다.
new 처리자의 설치를 제거합니다. 다시 말해, set_new_handler에 널 포인터를 넘깁니다. new 처리자가 설치된 것이 없으면, operator new는 메모리 할당이 실패했을 때 예외를 던지게 됩니다.
예외를 던집니다. bad_alloc 혹은 bad_alloc에서 파생된 타입의 예외를 던집니다. operator new에는 이쪽 종류의 에러를 받아서 처리하는 부분이 없기 때문에, 이 예외는 메모리 할당을 요청한 원래의 위치로 전파(propagate, 예외를 다시 던짐)됩니다.
복귀하지 않습니다. 대개 abort 혹은 exit를 호출합니다.
이 정도면 여러분이 new 처리자 함수를 만들 때 헷갈리지 않으면서도 융통성 있게 대처할 수 있을 것입니다.
할당된 객체의 클래스 타입에 따라서 메모리 할당 실패에 대한 처리를 다르게 가져가고 싶은 경우가 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class X { public: static void outOfMemory(); ... }; class Y { public: static void outOfMemory(); ... }; X* p1 = new X; // 메모리 할당이 실패했을 경우 // X::outOfMemory를 호출합니다. Y* p2 = new Y; // 메모리 할당이 실패했을 경우 // Y::outOfMemory를 호출합니다. | cs |
C++에는 특정 클래스만을 위한 할당에러 처리자를 둘 수 있는 기능 같은 것이 없습니다. 하지만, 해당 클래스에서 자체 버전의 set_new_handler 및 operator new를 제공하도록 직접 구현할 수 있기에 별 필요도 없습니다. 여기서 클래스에서 제공하는 set_new_handler 함수의 역할은 사용자로부터 그 클래스에 쓰기 위한 new 처리자를 받아내는 것입니다. 마치 표준 set_new_handler 함수가 사용자로부터 전역 new 처리자를 지정받는 데 쓰이는 것과 똑같은 이치죠. 한편 클래스에서 제공하는 operator new 함수는, 그 클래스 객체를 담을 메모리가 할당되려고 할 때(그리고 실패했을 때) 전역 new 처리자 대신 클래스 버전의 new 처리자가 호출되도록 만드는 역할을 맡습니다.
Widget 클래스에 대한 메모리 할당 실패를 직접 처리하고 싶다고 가정합시다. Widget 객체를 담을 만큼의 메모리를 operator new가 할당하지 못할 경우에 호출될 new 처리자 함수를 어딘가에 간수해 둘 필요가 있으므로, 이 new 처리자를 가리키는 new_handler 타입의 정적 멤버 데이터를 선언합니다.
1 2 3 4 5 6 7 8 | class Widget { public: static std::new_handler set_new_handler(std::new_handler p) throw(); static void * operator new(std::size_t size) throw(std::bad_alloc); private: static std::new_handler currentHandler; }; | cs |
정수 타입의 상수 멤버가 아닌 정적 클래스 멤버의 정의는 그 클래스의 바깥쪽에 있어야 하므로, 클래스 구현 파일에서 초기화하면 됩니다. Widget이 제공하는 set_new_handler 함수는 자신에게 넘어온 포인터를 아무런 점검 없이 저장해 놓고, 바로 전에 저장했던 포인터를 역시 아무런 점검 없이 반환하는 역할만 맡습니다.
1 2 3 4 5 6 | std::new_handler Widget::set_new_handler(std::new_handler p) throw() { std::new_handler oldHandler = currentHandler; currentHandler = p; return oldHandler; } | cs |
이제 마지막으로, Widget의 operator new가 할 일만 남았습니다.
1. 표준 set_new_handler 함수에 Widget의 new 처리자를 넘겨서 호출합니다.
즉, 전역 new 처리자로서 Widget의 new 처리자를 설치합니다.
2. 전역 operator new를 호출하여 실제 메모리 할당을 수행합니다. 전역 operator new의 할당이 실패하면, 이 함수는 Widget의 new 처리자를 호출하게 됩니다. 바로 앞 단계에서 전역 new 처리자로 설치된 함수가 바로 이 함수니까요. 마지막까지 전역 operator new의 메모리 할당 시도가 실패하면, 이 경우 Widget의 operator new는 전역 new 처리자를 원래의 것으로 되돌려 놓고, 이 예외를 전파시켜야 합니다. 원래의 전역 new 처리자를 항상 실수 없이 되돌려놓을 수 있도록, Widget은 전역 new 처리자를 자원으로 간주하고 처리합니다.
3. 전역 operator new가 Widget 객체 하나만큼의 메모리를 할당할 수 있으면, Widget의 operator new는 이렇게 할당된 메모리를 반환합니다. 이와 동시에, 전역 new 처리자를 관리하는 객체의 소멸자가 호출되면서 Widget의 operator new가 호출되기 전에 쓰이고 있던 전역 new 처리자가 저동으로 복원됩니다.
지금까지 나온 과정을 C++로 작성하자면 우선 자원 관리 클래스가 하나 필요합니다. 이 클래스는 객체 생성 중에 자원을 획득하고 객체 소멸 중에 그 자원을 해제하는, RAII 연산 외엔 아무것도 안 갖고 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class NewHandlerHolder { public: explicit NewHandlerHolder(std::new_handler nh) // 현재의 new 처리자를 : handler(nh) {} // 획득합니다. ~NewHandlerHolder() // 이것을 해제합니다. { std::set_new_handler(handler); } private: std::new_handler handler; // 이것을 기억해 둡니다. NewHandlerHolder(const NewHandlerHolder&); // 복사를 막기 위한 부분 NewHandlerHolder& operator=(const NewHandlerHolder&); }; | cs |
어지간한 일들이 자원 관리 클래스 쪽으로 몰려갔기 때문에, Widget의 operator new는 정말 간단히 구현할 수 있습니다.
1 2 3 4 5 6 7 8 9 | void * Widget::operator new(std::size_t size) throw(std::bad_alloc) { NewHandlerHolder // Widget의 new 처리자를 h(std::set_new_handler(currentHandler)); // 설치합니다. return ::operator new(size); // 메모리를 할당하거나 // 할당이 실패하면 예외를 던집니다. } // 이전의 전역 new 처리자가 // 자동으로 복원됩니다. | cs |
Widget 클래스를 사용하는 쪽에서 new 처리자 기능을 쓰려면 다음과 같이 하면 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void outOfMem(); // Widget 객체에 대한 메모리 할당이 // 실패했을 때 호출될 함수의 선언 Widget::set_new_handler(outOfMem); // Widget의 new 처리자 함수로서 // outOfMem을 설치합니다. Widget *pw1 = new Widget; // 메모리 할당이 실패하면 // outOfMem이 호출됩니다. std::string *ps = new std::string; // 메모리 할당이 실패하면 // 전역 new 처리자 함수가 // (있으면) 호출됩니다. Widget::set_new_handler(0); // Widget 클래스만을 위한 // new 처리자 함수가 아무것도 없도록 // 합니다(즉, null로 설정합니다). Widget *pw2 = new Widget; // 메모리 할당이 실패하면 이제는 // 예외를 바로 던집니다(Widget // 클래스를 위한 new 처리자 함수가 // 없습니다). | cs |
자원 관리 객체를 통한 할당에러 처리를 구현하는 이런 방식의 코드는 어떤 클래스를 쓰더라도 똑같이 나올 것 같습니다. 그러니까 이 코드를 다른 클래스에서도 재사용할 수 있도록 잘 만져 놓으면 좋겠다는 생각도 듭니다. 이런 용도에 손쉽게 쓸 수 있는 방법은 "믹스인(mixin) 양식"의 기본 클래스를 추천합니다. 즉, 다른 파생 클래스들이 한 가지의 특정 기능만을 물려받아 갈 수 있도록 설계된 기본 클래스를 만들면 됩니다. 지금 경우의 '특정 기능'은 클래스별 new 처리자를 설정하는 기능이고, 그 다음엔 그렇게 만든 기본 클래스를 템플릿으로 탈바꿈시킵니다. 이렇게 하면 파생 클래스마다 클래스 데이터(원래의 new 처리자를 기억해 두는 정적 멤버 데이터)의 사본이 따로따로 존재하게 되지요.
이렇게 설계된 클래스 템플릿으로 얻을 수 있는 효과를 깔끔하게 풀어 보면 두 가지입니다. 우선 기본 클래스 부분은 파생 클래스들이 가져야 하는 set_new_handler 함수와 operator new 함수를 물려줍니다. 그리고 템플릿 부분은 각 파생 클래스가 인스턴스화된 클래스가 되면서 currentHandler 데이터 멤버를 따로따로 가질 수 있게 합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | template<typename T> // 클래스별 set_new_handler를 class NewHandlerSupport { // 지원하는 "믹스인 양식"의 public: // 기본 클래스 static std::new_handler set_new_handler(std::new_handler p) throw(); static void* operator new(std::size_t size) throw(std::bad_alloc); ... // operator new의 다른 버전들을 // 이 자리에 둡니다. private: static std::new_handler currentHandler; }; template<typename T> std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw() { std::new_handler oldHandler = currentHandler; currentHandler = p; return oldHandler; } template<typename T> void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc) { NewHandlerHolder h(std::set_new_handler(currentHandler)); return ::operator new(size); } // 클래스별로 만들어지는 currentHandler 멤버를 널로 초기화합니다. template<typename T> std::new_handler NewHandlerSupport<T>::currentHandler = 0; | cs |
이렇게 만들어진 클래스 템플릿이 있으면, Widget 클래스에 est_new_handler 기능을 추가하는 것은 별로 어려워지지 않게 됩니다. 그저 NewHandlerSupport<Widget>로부터 상속만 받으면 끝이거든요. 클래스에 따른 set_new_handler를 제공하는 데 필요한 작업은 이것으로 끝입니다.
이 템플릿은 T를 쓸 필요가 전혀 없는데, 실제로 필요한 것은 NewHandlerSupport로부터 파생된 각 클래스에 대한 NewHandlerSupport 객체의 서로 다른 사본(더 정확히 말하면 정적 데이터 멤버인 currentHandler의 사본)밖에 없기 때문입니다. 따라서 템플릿 메커니즘 자체는 NewHandlerSupport가 인스턴스화될 때 전달되는 T를 위한 currentHandler의 사본을 자동으로 찍어내는 공장인 셈이죠. 템플릿 매개변수로 Widget을 받아 만들어진 기본 클래스로부터 Widget이 파생된 모습은 뭔가 덜 떨어져 보이기도 하지만, 익숙해지기 시작하면 꽤 쓸만한 기법입니다. 신기하게 반복되는 템플릿 패턴(curiously recurring template pattern: CRTP)이라 불립니다.
C++은 operator new가 메모리 할당을 할 수 없을 때 널 포인터를 반환하도록 되어 있다가, bad_alloc 예외를 던지도록 명세가 바뀌었습니다. 하지만 C++ 표준화 위원회는 '널 포인터 점검' 기반의 코드를 버리고 싶지 않았기 때문에, 결국 전통적인 '할당-실패-시-널-반환'으로 동작하는 대안적인 형태의 operator new도 같이 내놓았습니다. 이런 형태를 "예외불가(nothrow)" 형태라고 하는데, new가 쓰이는 위치에서 이런 함수가 예외를 던지지 않는 객체(<new> 헤더에 정의되어 있습니다)를 사용한다는 점도 그렇게 불리는 부분적인 이유라고 하네요.
1 2 3 4 5 6 7 8 9 | class Widget { ... }; Widget *pw1 = new Widget; // 할당이 실패하면 // bad_alloc 예외를 던집니다. if (pw1 == 0) ... // 이 점검 코드는 꼭 실패합니다. Widget *pw2 = new (std::nothrow) Widget; // Widget을 할당하다 실패하면 // 0(널)을 반환합니다. if (pw2 == 0) ... // 이 점검 코드는 성공할 수 있습니다. | cs |
위에 나온 "new (std::nothrow) Widget" 표현식에서는 실제로 두 가지 동작이 이루어집니다. 우선 operator new 함수의 예외불가 버전이 호출되어 Widget 객체를 담기 위한 메모리 할당을 시도합니다. 만약 이 할당이 실패하면 operator new는 널 포인터를 반환합니다. 그런데 할당이 성공할 때가 주의애햐 할 부분입니다. 성공 시에는 Widget 생성자가 호출되는데, 이런 후에는 예외불가고 뭐고 말짱 도루묵입니다. Widget 생성자는 자기 하고 싶은 대로 할 수 있습니다. 구현에 따라서는 생성자 내부에서 자체적으로 new를 또 쓸 수도 있겠지요. 이때 중요한 점은 이 new는 맨 처음에 실행됐던 예외불가 new로부터 전혀 제약을 받지 않는다는 것입니다. 결론은 예외불가 new는 그때 호출되는 operator new에서만 예외가 발생되지 않도록 보장할 뿐, "new (std::nothrow) Widget" 등의 표현식에서 예외가 나오지 않게 막아 준다는 이야기는 아닙니다. 십중팔구는 예외불가 new를 필요로 할 일이 없을 것입니다.
"보통"의(다시 말해, 예외를 던지는) new를 쓰든, 예외불가 new를 쓰든 상관없이 중요한 것은 바로 new 처리자의 동작 원리를 제대로 이해해야 한다는 것입니다. new 처리자는 양쪽에서 모두 쓰이거든요.
set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있습니다.
예외불가(nothrow) new는 영향력이 제한되어 있습니다.
메모리 할당 자체에만 적용되기 때문입니다.
이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있습니다.