[Effective C++] 8. new와 delete를 내 맘대로 [1/2]
이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
가비지 컬렉션 기능을 아예 하단에 놓고 기본적으로 지원하는 프로그래밍 환경들이 저마다의 매력을 뿜어내는 요즘, 여전히 수동만을 고수하는 C++의 메모리 관리 기법은 어떻게 보면 적잖이 구닥다리로 보일 수 있다. 그럼에도 불구하고 아주 중요한 시스템 응용프로그램을 제작하는 전 세계의 수 많은 개발자들은 메모리를 수동으로 관리할 수 있다는 점 때문에 주저 없이 C++을 선택한다. 이쪽 개발자들은 일단 자신들이 만들 소프트웨어의 메모리 사용 성향을 연구한 후에, 그 연구 결과에 맞추어 메모리 할당 루틴과 해제 루틴을 다듬음으로써 가능한 최대의 수행 성능(시간 및 공간 모두에서)을 제공하려고 애쓰고 있다.
개발자로서 이런 작업이 가능하려면 일단 C++의 메모리 관리 루틴이 어떻게 동작하는지를 면밀히 파악해 두는 것에 좋은 자세이다. 그 ‘어떻게’가 바로 이 장의 초점이다. 이번 항목에서 주인공은 메모리 할당 및 해제 루틴(operators new 및 operators delete)이다. 여기에 new 처리(new-handler)도 알아본다. 이 함수는 사용자의 메모리 요구를 operators new가 맞추어 주지 못했을 때 호출되는 함수이다.
다중스레드 환경에서의 메모리 관리는 단일 스레드 시스템에서는 경험할 수 없는 여러 가지 문젯거리를 안고 있다. 힙은 수정이 가능한 전역 자원으로 분류되기 때문이다. 다중스레드 시스템에서는 스레드들이 이런 전역 자원에 미친 듯이 접근하면서 경쟁 상태가 생길 소지가 많아진다고 생각하면 된다. 이번 장을 보면 수정 가능한 정적 데이터의 사용법에 대해 이야기한 항목을 꽤 많이 만나게 되는데, 스레드를 좀 아는 프로그래머에게 있어서 수정 가능한 정적 데이터는 항상 머리털이 바짝 설 정도로 중요한 존재이다.
이 부분에 적절한 동기화를 걸지 않으면, 스레드 잠금에 영향을 받지 않는 어떤 알고리즘을 쓰든, 동시 접근을 막는 어떤 세심한 설계를 적용하든, 아무런 소용이 없다. 언제라도 메모리 루틴을 호출하는 순간이 오면 힙으로 관리되는 자료구조에 흙탕물을 튀기는 물줄기가 활짝 열릴 수 있으니 말이다. 여기서 이야기하면서 뼛속 깊이 새겨보자.
우리가 C++로 밥을 먹고 사는 이상 잊지 말아야 할 부분이 또 있다. operator new 및 operator delete는 객체 한 개를 할당할 때만 적용되는 함수라는 점이다. 배열을 담을 메모리의 경우, 할당할 때는 operator new[]를 쓰고 해제할 때는 operator delete[]를 쓴다. (할당 및 해제용 함수 이름에 모두 “[]” 부분이 있다는 점을 놓치지 말자). 다른 경우를 특별히 지정하지 않는 한, operator new 및 operator delete에 대해 이야기한 것은 operator new[] 및 operator delete[]에도 똑같이 적용된다.
항목 49 : new 처리자의 동작 원리를 제대로 이해하자.
사용자가 보낸 메모리 할당 요청을 operator new 함수가 맞추어 주지 못할 경우에(즉, 할당할 메모리가 없을 때) operator new 함수는 예외를 던지게 되어 있다. 오랜 옛날에는 널 포인터를 반환했었는데 구닥다리 컴파일러를 쓰는 환경에서는 여전히 경험할 수 있을 것이다. 사실 요즘도 옛날 스타일 동작을 사용할 수 없는 것은 아니지만, 이 부분에 대한 이야기는 이번 항목이 끝날 즈음으로 미루어 두자.
메모리 할당이 제대로 되지 못한 상황에 대한 반응으로 operator new가 예외를 던지기 전에, 이 함수는 사용자 쪽에서 지정할 수 있는 에러 처리 함수를 우선적으로 호출하도록 되어 있는데, 이 에러 처리 함수를 가리켜 new 처리자(new-handler, 할당에러 처리자)라고 한다. 사실 완전히 맞는 것은 아닌게 operator new의 동작은 이보다 좀더 복잡하다. 항목 51에서 자세하게 알아보자. 이와 같은 메모리 고갈 상황을 처리할 함수를 사용자 쪽에서 지정할 수 있도록, 표준 라이브러리에는 set_new_handler라는 함수가 준비되어 있고, 이 함수는
1
2
3
4
5
6
namespace std
{
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
}
우리도 보았겠지만 new_handler는 받는 것도 없고 반환하는 것도 없는 함수의 포인터에 대해 typedef를 걸어 놓은 타입동의어이다. 그리고 set_new_handler는 new_handler를 받고 new_handler를 반환하는 함수이다. 선언문 끝에 있는 throw()는 예외 지정이라고 불리는 부분인데, 항목 29를 다시 읽어보자.
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
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];
...
}
만약 operator new가 1억 개의 정수 할당에 실패하면 outOfMem 함수가 호출될 것이고, 이 함수는 에러 메세지를 출력하면서 프로그램을 강제로 끝내 버릴 것이다. (그런데 cerr에 에러 메세지를 쓰는 과정에서 또 메모리가 동적으로 할당되어야 한다면 어떻게 될까? 잠깐만 생각해 보자)
사용자가 부탁한 만큼의 메모리를 할당해 주지 못하면, operator new는 충분한 메모리를 찾아낼 때까지 new 처리자를 반복적으로 호출한다. new 처리자를 반복 호출한느 코드는 항목 51에서 그 자세한 모양새를 보겠지만, 응용프로그램 개발자 입장에서는 굳이 이 부분까지는 내려가지 않아도 된다. 어쨌든 이를 통해 호출’되는’ 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
17
18
19
class X
{
public:
static void outOfMemory();
...
};
class Y
{
public:
static void outOfMemory();
...
};
// 메모리 할당이 실패했을 경우 X::outOfMemory를 호출한다.
X* p1 = new X;
// 메모리 할당이 실패했을 경우 Y::outOfMemory를 호출한다.
Y* p2 = new Y;
C++에는 특정 클래스만을 위한 할당에러 처리자를 둘 수 있는 기능 같은 것이 없다. 하지만, 사실 직접 구현할 수 있기에 별 필요도 없다. 해당 클래스에서 자체 버전의 set_new_handler 및 operator new를 제공하도록 만들어 주기만 하면 OK이다. 여기서 클래스에서 제공하는 set_new_handler 함수의 역할은 사용자로부터 그 클래스에 쓰기 위한 new 처리자를 받아내는 것이다 (마치 표준 set_new_handler 함수가 사용자로부터 전역 new 처리자를 지정받는 데 쓰이는 것과 똑같은 이치다). 한편 클래스에서 제공하는 operator new 함수는, 그 클래스 객체를 담을 메모리가 할당되려고 할 때(그리고 실패했을 때) 전역 new 처리자 대신 클래스 버전의 new 처리자가 호출되도록 만드는 역할을 맡는다.
자, Widget 클래스에 대한 메모리 할당 실패를 우리가 직접 처리하고 싶다고 가정하자. Widget 객체를 담을 만큼의 메모리를 operator new가 할당하지 못할 경우에 호출될 new 처리자 함수를 어딘가에 간수해 둘 필요가 있으므로, 이 new 처리자를 가리키는 new_handler 타입의 정적 멤버 데이터를 선언한다. 즉, Widget 클래스는 아마 다음과 같은 형태일 것이다.
1
2
3
4
5
6
7
8
9
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;
};
정적 클래스 멤버의 정의는 그 클래스의 바깥쪽에 있어야 하므로(정수 타입의 상수 멤버가 아니라면 말이다. 항목 2 참조), 다음과 같이 하면 된다.
1
2
// 클래스 구현 파일에서 널로 초기화하자
std::new_handler Widget::currentHandler = 0;
Widget이 제공하는 set_new_handler 함수는 자신에게 넘어온 포인터를 아무런 점검 없이 저장해 놓고, 바로 전에 저장했던 포인터를 역시 아무런 점검 없이 반환하는 역할만 맡는다. 표준 라이브러리의 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;
}
이제 마지막으로, Widget의 operator new가 할 일만 남았다.
첫 번째, 표준 set_new_handler 함수에 Widget의 new 처리자를 넘겨서 호출한다. 즉, 전역 new 처리자로서 Widget의 new 처리자를 설치한다.
두 번째, 전역 operator new를 호출하여 실제 메모리 할당을 수행한다. 전역 operator new의 할당이 실패하면, 이 함수는 Widget의 new 처리자를 호출하게 된다. 바로 앞 단계에서 전역 new 처리자로 설치된 함수가 바로 이 함수니까말이다. 마지막까지 전역 operator new의 메모리 할당 시도가 실패하면, 전역 operator new는 bad_alloc 예외를 던진다.
이 경우 Widget의 operator new는 전역 new 처리자를 원래의 것으로 되돌려 놓고, 이 예외를 전파시켜야 한다. 원래의 전역 new 처리자를 항상 실수 없이 되돌려놓을 수 있도록, Widget은 전역 new 처리자를 자원으로 간주하고 처리한다. 즉, 항목 13의 조언대로 자원 관리 객체를 사용하여 전역 new 처리자를 관리함으로써 자원 누수를 막는다.
세 번째, 전역 operator new가 Widget 객체 하나만큼의 메모리를 할당할 수 있으면, Widget의 operator new는 이렇게 할당된 메모리를 반환한다. 이와 동시에, 전역 new 처리자를 관리하는 객체의 소멸자가 호출되면서 Widget의 operator new가 호출되기 전에 쓰이고 있던 전역 new 처리자가 자동으로 복원된다.
지금까지 나온 인간의 언어를 C++로 풀어 보자. 전역 new 처리자를 자원으로 삼는다고 했으므로, 우선 자원 관리 클래스를 하나 준비하자. 이 클래스는 객체 생성 중에 자원을 획득하고 객체 소멸 중에 그 자원을 해제하는, 지극히 교과서스러운 RAII 연산 외엔 아무것도 안 갖고 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class NewHandlerHolder
{
public:
explicit NewHandlerHolder(std::new_handler nh) : handler(nh) { }
~NewHandlerHolder() { std::set_new_handler(handler); }
private:
std::new_handler handler; // 이것을 기억해 두자.
// 복사를 막기 위한 부분
NewHandlerHolder(const NewHandlerHolder&);
NewHandlerHolder& operator=(const NewHandlerHolder&);
};
어지간한 일들이 자원 관리 클래스 쪽으로 몰려갔기 때문에, Widget의 operator new는 정말 간단히 구현할 수 있다.
1
2
3
4
5
6
void* Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}
Widget 클래스를 사용하는 쪽에서 new 처리자 기능을 쓰려면 다음과 같이 하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Widget 객체에 대한 메모리 할당이 실패했을 때 호출될 함수의 선언
void outOfMem();
// Widget의 처리자 함수로서 outOfMem을 설치
Widget::set_new_handler(outOfMem);
// 메모리 할당이 실패하면 outOfMem 호출
Widget* pw1 = new Widget;
// 메모리 할당이 실패하면 전역 new 처리자 함수가 (있으면) 호출
std::string* ps = new std::string;
// Widget 클래스만을 위한 new 처리자 함수를 null로 설정
Widget::set_new_handler(0);
// 메모리 할당이 실패하면 이제는 예외를 바로 던진다(Widget 클래스를 위한 new 처리자 함수가 없다).
Widget* pw2 = new Widget;
잠시만 생각해 보면, 자원 관리 객체를 통한 할당에러 처리를 구현하는 이런 방식의 코드는 어떤 클래스를 쓰더라도 똑같이 나올 것 같다. 그러니까 이 코드를 다른 클래스에서도 재사용할 수 있도록 잘 만져 놓으면 참 좋겠다는 생각도 든다. 이런 용도에 손쉽게 쓸 수 있는 방법으로 “믹스인(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
30
template<typename T>
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);
...
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);
}
template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = 0;
이렇게 만들어진 클래스 템플릿이 있으면, Widget 클래스에 set_new_handler 기능을 추가하는 것은 별로 어려워지지 않게 된다. 그저 NewHandlerSupport
1
2
3
4
class Widget : public NewHandlerSupport<Widget>
{
...
};
클래스에 다른 set_new_handler를 제공하는 데 필요한 작업은 이것으로 끝이다.
하지만 NewHandlerSupport
템플릿 매개변수로 Widget을 받아 만들어진 기본 클래스로부터 Widget이 파생된 모습의 이런 기법은 신기하게 반복되는 템플릿 패턴(curiously recurring template pattern: CRTP)이라고 불린다.
어떤 클래스에 클래스별 new 처리자를 붙이고 싶을 때 NewHandlerSupport와 같은 템플릿을 쓰면 확실히 쉬운 것은 사실이다. 하지만 이런 믹스인 양식의 상속을 쓰다 보면 어쩔 수 없이 다중 상속 이야기가 끌려 나오게 되는데, 이쪽 방면으로 알아볼 사람은 우선 항목 40을 잘 알아두자.
1993년까지의 C++은 operator new가 메모리 할당을 할 수 없을 때 널 퐁니터를 반환하도록 되어 있었다. 그러다가 몇 년이 지난 후에 bad_alloc 예외를 던지도록 명세가 바뀌게 된다. 하지만 컴파일러 제작사들이 수정된 명세를 지원하려고 폼을 잡을 당시에 이미 많고 많은 C++ 개발도구들이 만들어져 군웅할거하고 있다는 것이 걸림돌이었다. 그리고 C++ 표준화 위원회는 ‘널 포인터 점검’ 기반의 코드를 버리고 싶지 않았기 때문에, 결국 전통적인 ‘할당 실패 시 널 반환’으로 동작하는 대안적인 형태의 operator new도 같이 내놓았다. 이런 형태를 가리켜 “예외불가(nothrow)” 형태라고 하는데, new가 쓰이는 위치에서 이런 함수가 예외를 던지지 않는 객체(
1
2
3
4
5
6
7
8
9
class Widget { ... };
Widget* pw1 = new Widget;
if (pw1 == 0) ...
Widget* pw2 = new (std::nothrow) Widget;
if (pw2 == 0) ...
예외불가 new가 제공하는 예외 보장은 이름 자체에서 뿜어내는 중압감보다는 덜한 편이다. 위에 나온 “new (std::nothrow) Widget” 표현식에서는 실제로 두 가지 동작이 이루어진다. 우선 operator new 함수의 예외불가 버전이 호출되어 Widget 객체를 담기 위한 메모리 할당을 시도한다. 만약 이 할당이 실패하면 operator new는 널 포인터를 반환한다. 그런데 할당이 성공할 때가 주의해야 할 부분이다. 성공 시에는 Widget 생성자가 호출되는데, 이런 후에 예외불가고 뭐고 말짱 도루묵이다. Widget 생성자는 자기 하고 싶은 대로 할 수 있다. 구현에 따라서는 생성자 내부에서 자체적으로 new를 또 쓸 수도 있을 것이다. 이때 중요한 점은 이 new는 맨 처음에 실행됐던 예외불가 new로부터 전혀 제약을 받지 않는다는 것이다. 자초지종이 이러하니, “new (std::nothrow) Widget”에서 호출된 operator new가 예외를 뱉어내지 않는다 해도 Widget 생성자에서 예외가 빠져나올 수 있다. 그렇게 되면 예전처럼 예외가 전파되는 것은 마찬가지다.
결론을 내려보면, 예외불가 new는 그때 호출되는 operator new에서만 예외가 발생되지 않도록 보장할 뿐, “new (std::nothrow) Widget” 등의 표현식에서 예외가 나오지 않게 막아 준다는 이야기는 아니다. 십중팔구는 예외불가 new를 필요로 할 일이 없을 것이다.
예외를 던지는 new를 쓰든, 예외불가 new를 쓰든 상관없이 중요한 것이 하나 있다. 바로 new 처리자의 동작 원리를 제대로 이해해야 한다는 것이다. 보았겠지만 new 처리자는 양쪽에서 모두 쓰인다!
set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있다.
예외불가(nothrow) new는 영향력이 제한되어 있다. 메모리 할당 자체에만 적용되기 때문이다. 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있다.
항목 50 : new 및 delete를 언제 바꿔야 좋은 소리를 들을지 파악해 두자.
잠깐 진정하고 근본적인 이야기를 하자. 많고 많은 것들 중에 왜 하필 컴파일러가 열심히 장만해준 operator new와 operator delete를 바꿔야 하는 걸까? 가장 흔한 이유 세 가지를 꼽아 보면 다음과 같다.
첫 번째, 잘못된 힙 사용을 탐지하기 위해서
new한 메모리에 delete를 하는 것을 잊어버리면 메모리가 누출되는 것이 당연한 일이다. 한 번 new한 메모리를 두 번 이상 delete하면 미정의 동작이 발생하고 만다. 만일 할당된 메모리 주소의 목록을 operator new가 유지해 두고 operator delete가 그 목록으로부터 주소를 하나씩 제거해 주게 만들어져 있다면, 이런 식의 실수는 쉽게 잡아낼 수 있을 것이다. 또한 프로그래밍을 하다가 이런 저런 실수를 하다 보면 데이터 오버런(overrun, 할당된 메모리 블록 끝을 넘어 뒤에 기록하는 것) 및 언더런(underrun, 할당된 메모리 블록의 시작을 넘어 앞에 기록하는 것)이 발생할 수 있다. 이런 경우에 대비하여 사용자 정의 operator new를 활용한다면, 요구된 크기보다 약간 더 메모리를 할당한 후에 사용자가 실제로 사용할 메모리의 앞과 뒤에 오버런/언더런 탐지용 바이트 패턴(일명 경계 표지[signature])을 적어두도록 만들 수 있을 것이다. operator delete는 누군가가 이 경계표지에 손을 댔는지 안 댔는지 점검하도록 만들고 말이다. 만일 이 경계표지 부분이 원래와 다른 정보가 적혀 있다면 할당된 메모리 블록을 사용하는 도중에 오버런이나 언더런이 발생한 것이므로, operator delete는 이 사실을 로그에 기록함으로써 문제를 일으킨 포인터 값을 남겨 놓을 수 있다.
두 번째, 효율을 향상시키기 위해
컴파일러가 제공하는 기본 버전의 operator new 및 operator delete 함수는 대체적으로 일반적인 쓰임새에 맞추어 설계된 것이다. 실행 기간이 짧지 않은 프로그램에서 잘 돌아가야 하며, 1초 안에 끝나는 프로그램에서도 별 문제가 없어야 한다. 큰 블록만 할당하든 작은 블록만 할당하든, 아니면 크고 작은 블록이 섞여서 할당되든 간에, 이렇게 저렇게 계속되는 메모리 할당 요청을 무난하게 처리해야 한다. 프로그램 실행 내내 사용하는 블록 몇 개를 동적 할당하는 작업부터 수명이 짧은 객체를 아주 많이 할당했다가 해제했다가 하는 작업까지, 여러 가지 할당 유형도 소화할 수 있어야 한다. 힙 단편화에 대한 대처방안도 없으면 안 된다. 단변화를 방치했다가는 이리저리 흩어진 작은 메모리 블록들 사이에 낀 자투리 공간을 모으면 충분한 크기가 되는데도 불구하고 메모리 블록 할당에 실패할 수도 있으니 말이다.
이렇듯 메모리 관리자에 대한 요구사항은 정말 가지각색이다. 컴파일러가 기본적으로 제공하는 operator new 및 operator delete 함수가 지극히 대중적이고 온건지향 스타일의 전략을 취한 것도 놀랄 만한 일은 아니라는 이야기다. 결국 이들은 모든 사용자들로부터 욕먹지 않게끔 적당히 무난하게 동작하지만, 어느 누구에게도 칭찬을 듣지는 못한다. 그렇기 때문에, 만일 개발자가 자신의 프로그램이 동적 메모리를 어떤 성향으로 사용하는지를 제대로 이해하고 있다면, 사용자 정의 operator new 및 operator delete를 자신이 만들어 쓰는 편이 기본제공 버전을 썼을 때보다 더 우수한 성능을 낼 확률이 높다. 여기서 우수한 성능이랑 실행 속도가 빠르고 메모리도 적게 차지한다는 뜻이다. 응용프로그램에 따라서는 new와 delete를 사용자 정의 버전으로 바꾸는 것만으로 아주 쉽게 괄목할 만한 성능 향상을 뽑아내기도 한다.
세 번째, 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
입맛에 맞게 동작하는 new 및 delete를 무작정 작성해 보겠다고 맨땅에 헤딩부터 하는 것보다, 우리가 만드는 소프트웨어가 동적 메모리를 어떻게 사용하는지에 관한 정보를 수집하는 세심한 자세가 여러모로 좋을 것이다. 할당된 메모리 블록의 크기는 어떤 분포를 보이는지? 각각의 사용 기간은 또 어떤 분포를 보이는지? 메모리가 할당되고 해제되는 순서가 FIFO, LIFO, 마구잡이인지? 시간 경과에 따라 사용 패턴이 바뀌는지? 한 번에 실제로 쓰이는 동적 할당 메모리의 최대량은 어떤지? 사용자 정의 operator new 및 operator delete를 사용하면 이런 정보를 아주 쉽게 얻을 수 있다.
개념적으로 보면 operator new를 우리가 직접 만드는 작업은 사실 별로 어려운 것이 아니다. 한 예로, 버퍼 오버런 및 언더런을 탐지하기 쉬운 형태로 만들어 주는 전역 operator new를 만들어 봤다. 자잘한 부분에서 틀린 게 좀 많지만, 걱정은 조금 뒤에서 늘어놓도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
// 이 코드는 고쳐야 할 부분이 몇 개 잇다. 아래를 읽어보자.
void* operator new(std::size_t size) throw(std::bad_alloc)
{
using namespace std;
size_t realSize = size + 2 * sizeof(int); // 경계표지용 공간
void* pMem = malloc(realSize);
if (!pMem) throw std::bad_alloc();
// 메모리 블록의 시작 및 끝부분에 경계표지 기록
*(static_cast<int*>(pMem)) = signature;
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signature;
// 앞쪽 경계표지 바로 다음의 메모리를 가리키는 포인터 반환
return static_cast<Byte*>(pMem) + sizeof(int);
}
이 operator new 함수가 가진 자잘한 부분에서 틀린 점들은 대개 operator new라는 이름이 붙은 함수를 만들 때 통상적으로 쓰이는 관례를 지키지 않은 데 있다. 이를테면, 바로 뒤에 나오는 항목 51에서도 이야기하고 있듯이 operator new에는 new 처리자 함수를 호출하는 루프가 반드시 들어 있어야 하는데 지금 보는 함수에는 루프가 없는 것도 그런 점들 중 하나이다. 어쨌든 이런 C++ 관례에 대한 이야기는 항목 51에 다 몰아두었으므로 일단 넘어가자. 사실 이 함수는 좀더 까다로운 문제가 숨어 있는데, 바로 바이트 정렬이다. 지금은 이 부분에 집중해 보자.
우리가 쓰고 있는 컴퓨터는 많은 경우에 있어서 아키텍처적으로 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 하여 저장될 것을 요구사항으로 두고 있다. 이를테면, 포인터는 4의 배수에 해당하는 주소에 맞추어 저장되어야(다시 말해 4바이트 단위로 정렬되어야)하거나 double은 8의 배수에 해당하는 주소에 맞추어 저장되어야(즉, 8바이트 단위로 정렬되어야) 한다는 것이다. 어떤 아키텍처의 경우에는 이 바이트 정렬 데약을 따르지 않으면 프로그램이 실행되다가 하드웨어 예외를 일으킬 수 있다. 이보다는 좀더 느슨한 제약을 두는 아키텍처도 있다. 이런 아키텍처는 바이트 정렬을 만족했을 경우에 좀 더 나은 성능을 제공한다. 대표적인 예가 인텔 x86 아키텍처인데, 인텔 x86에서는 어떤 바이트 단위에 맞추더라도 double 값을 정렬할 수 있지만 8바이트 단위로 정렬하면 런타임 접근 속도가 훨씬 빨라진다.
바이트 정렬 문제는 지금 경우에도 아주 중요하다. 왜냐하면, 모든 operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 한다는 것이 c++의 요구사항이기 때문이다. 표준 malloc 함수는 이 요구사항에 맞추어 구현되어 있기 때문에, malloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 ‘안전’하다. 하지만 지금 보고 있는 operator new 함수에서는 malloc에서 나온 포인터를 반환하지 않는다. 그 포인터를 기준으로 int 크기만큼 뒤로 어긋난 주소를 포인터로 반환하고 있단 말이다. 이렇게 되는 경우는 안전하다는 보장을 할 수 없다! 만일 사용자가 operator new를 호출해서 double을 담을 메모리를 얻어내는데, int의 크기가 4바이트이지만 double이 8바이트 단위로 정렬되어야 하는 컴퓨터에서 그 사용자의 프로그램이 실행되고 있다면, 바이트 정렬이 완전히 어긋나 버린 포인터가 operator new에서 반환되는 것이다. 이것 때문에 어떤 경우에는 프로그램이 다운될 수 있다. 실행 속도가 느려지는 수준으로 끝날 수도 있겠지만 심하게 당황스럽기는 마찬가지일 것이다.
바이트 정렬의 세세한 문제를 어떻게 다루냐에 따라 메모리 관리자가 달라진다. 프로급 품질의 메모리 관리자가 될 수도 있고, 아니면 다른 일 때문에 신경 쓸 겨를이 없었던 프로그래머들이 낳은 비련의 사생아로 불릴 수도 있다는 것이다. 어지간히 돌아가는 사용자 정의 메모리 관리자는 뭐, 그냥저냥 만들 수 잇다. 하지만 정말 잘 돌아가는 쓸 만한 관리자를 만들기란 개념 상실한 초등학생들을 사람 만드는 것만큼이나 어렵다. 지극히 일반적인 경우만으로 말하자면, 꼭 만들어 쓸 이유가 없다면 굳이 들이댈 필요는 없다는 것을 말하고 싶다.
농담이 아니라, 진짜 필요가 없다. 시중에 나와 있는 컴파일러 중에는 메모리 관리 함수에 디버깅 및 로깅 기능을 넣어 놓고 필요에 따라 전환할 수 있도록 해 둔 것들도 있다. 계속 컴파일만 하지 말고 우리가 쓰고 있는 컴파일러 문서를 훑어보는 시간을 잠깐만 내 봐라. ‘행동 하나하나가 맘에 안 들어서 시간 나면 new랑 delete를 직접 만들어 놔야지’라는 생각이 상당수 날아갈 것이다. 게다가 꽤 많은 플랫폼에 쓸 수 있는 메모리 관리 함수만을 전문적으로 다루는 상업용 제품들도 출시되어 있어서, 이런것들로 대신할 수도 있다. 링크 한 번 다시 하는 것만으로 개선된 기능과 향상된 성능(아마도)을 맛 볼 수 있는 거다.
돈이 문제라면 오픈 소스 쪽에 눈을 돌려 보는 것도 방법이다. 여러 플랫폼을 동시에 지원하는 메모리 관리자 패키지가 많이 공개되어 있으니, 다운로드해서 시험 삼아 써 보는 것도 좋다. 이러한 오픈 소스 메모리 할당자 중 하나를 소개해 볼까 한다. 바로 부스트(항목 55 참조)의 풀(Pool) 라이브러리이다. 이 라이브러리에서 제공하는 메모리 할당자는 사용자 정의 메모리 관리 루틴으로 도움을 얻을 수 있는 가장 흔한 경우들 중 하나에 맞추어 튜닝되어 있는데, 크기가 작은 소형 객체를 많이 할당할 경우이다. 이 책의 이전 판(1판, 2판)에서도 그랬었지만 성능이 우수한 소형 객체 할당자를 직접 구현하고 그 코드를 공개한 C++ 책은 솔직히 많이 나와 있다. 하지만 오히려 현장에서 더 필수적으로 요구되는 세심한 부분, 이를테면 이식성이라든지 앞에서 말한 바이트 정렬 문제, 스레드 안전성 등에 대해서는 독자가 알아서 하라는 식으로 무책임하게 넘어간 경우가 허다했다. 자고로 현장에서 바로 쓰일 라이브러리가 되려면 훨씬 견고한 코드로 되어 있어야 자기 몫을 할 수 있다. 설사 혹시 우리가 new와 delete를 자체 제작한다는 큰 결심을 한다 해도 오픈 소스 버전을 보는게 좋다. 뼈대 구현에 헉헉대다가 자칫 간과하기 쉬운 세부사항들을 짚을 수 있게 해 주기 때문이다. 다시 말하지만 ‘대충 어지간한’ 라이브러리와 ‘진정한 실전’ 라이브러리가 구분되는 부분이 바로 이런 것들이다. (바이트 정렬 문제라면 항목 54의 TR1을 참조하는 것도 좋다)
지금까지 우리는 new 및 delete의 기본제공 버전을 다른 것으로 대체하는 작업을 언제 해야 의미가 있는가에 대해 알아보았다. 물론 이 ‘언제’는 전역 버전과 클래스별 버전 모두에 해당하는 것이다. 이번 항목을 마무리하는 의미에서, 이 ‘언제’에 대해 총 정리해 보는 시간을 가져 보자. 앞에서도 몇 가지 말했지만 좀더 살을 붙여서 말이다.
첫 번째, 잘못된 힙 사용을 탐지하기 위해 (앞에서 이미 말했다)
두 번째, 동적 할당 메모리의 실제 사용에 관한 통계를 수집하기 위해 (역시 앞에서 이미 말했다)
세 번째, 할당 및 해제 속력을 높이기 위해
기본으로 제공되는 범용 할당자는 사용자 정의 버전보다 꽤 느린 경우가 적지 않다. 특히 사용자 정의 버전이 특정 타입의 객체에 맞추어 설계되어 있으면 더욱 그렇다. 부스트의 Pool 라이브러리에서 제공하는 할당자처럼 고정된 크기의 객체만 만들어 주는 할당자의 전형적인 응용 예가 바로 클래스 전용(class-specific) 할당자이다. 우리가 만들 응용프로그램은 단일 스레드로 동작하는데 컴파일러에서 기본으로 제공하는 메모리 관리 루틴이 다중 스레드에 맞게 만들어져 있다면, 스레드 안전성이 없는 할당자를 우리가 직접 만들어 씀으로써 상당한 속력 이득을 볼 수 있을 것이다. 물론 operator new와 operator delete를 바꾸어서 속력을 높이는 쪽으로 결정을 내기 전에, 적절한 프로파일링을 통해 우리 프로그램 안에서 이들이 진짜로 병목을 일으키는 걸림돌인지 확인하는 센스는 기본이다.
네 번째, 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
범용 메모리 관리자는 사용자 정의 버전과 비교해서 속력이 느린 경우도 많은데다가 메모리도 잡아먹는 사례가 허다하다. 할당된 각각의 메모리 블록에 대해 전체적으로 지우는 부담이 꽤 되기 때문이다. 크기가 작은 객체에 튜닝된 할당자(부스트의 Pool 라이브러리에서 제공하는 할당자)를 사용하면 이러한 오버헤드를 실질적으로 제거할 수 있다.
다섯 번째, 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
앞에서도 잠깐 언급했듯이, x86 아키텍처에서는 double이 8바이트 단위로 정렫되어 있을 때 읽기/쓰기 속도가 가장 빠르다. 시중에 나와 있는 컴파일러 중에는 기본적으로 제공되는 operator new 함수가 double에 대한 동적 할당 시에 8바이트 정렬을 보장하지 않는 것이 있다는 슬픈 소식이 나돌고 있다. 이런 우울한 컴파일러를 쓰고 있다면, 기본 제공 operator new 대신에 8바이트 정렬을 보장하는 사용자 정의 버전으로 바꿈으로써 프로그램 수행 성능을 확 끌어올릴 수 있다.
여섯 번째, 임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아 놓기 위해
한 프로그램에서 특정 자료구조 몇 개가 대게 한 번에 동시에 쓰이고 있다는 사실을 우리가 알고 있고, 앞으로 이들에 대해서는 페이지 폴트 발생 횟수를 최소화하고 싶을 경우, 해당 자료구조를 담을 별도의 힙을 생성함으로써 이들이 가능한 한 적은 페이지를 차지하도록 하면 상당히 좋은 효과를 볼 수 있을 것이다. 이러한 메모리 군집화는 위치지정 new 및 위치지정 delete를 통해 쉽게 구현할 수 있다.
일곱 번째, 그대그때 원하는 동작을 수행하도록 하기 위해
인생을 살다 보면 컴파일러가 주는 버전이 하지 못하는 일을 operator new 및 operator delete가 해주었으면 하고 바라는 때가 종종 있게 마련이다. 메모리 할당과 메모리 해제를 공유 메모리에다 하고 싶은데 공유 메모리를 조작하는 일은 C API로밖에 할 수 없을 때가 한 가지 예이다. 이때 사용자 정의 버전을 만드는 것이다(위치지정 new와 위치지정 delete가 적당하다. 항목 52 참조). 기존의 C API에 C++ 옷을 입힐 수가 있는 거다. 또 다른 예로는 응용프로그램 데이터의 보안 강화를 위해 해제한 메모리 블록에 0을 덮어 쓰는 사용자 정의 operator delete를 만드는 경우도 생각해 볼 수 있다.
개발자가 스스로 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 나름대로 타당한 이유가 있다. 여기에는 수행 성능을 향상시키려는 목적, 힙 사용 시에 에러를 디버깅하려는 목적, 힙 사용 정보를 수집하려는 목적 등이 포함된다.
추가로
위에서 예외 지정을 위해서 throw(), throw(bad_alloc)을 사용했는데 이건 현대 C++에서 제거됐으니 noexcept 키워드를 사용하자
1
2
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) noexcept;
그리고 다시 복기하자면, NewHandlerHolder RAII 객체에서 복사를 막기위해 복사 생성자와 복사 대엽 연산자를 private 구역에 선언만 하고 구현은 하지 않는 방식을 사용했지만, 현대 C++에서는 = delete 구문을 사용해서 컴파일러 수준에서 복사를 막는다. 의도가 훨씬 잘 드러나고 컴파일 에러 메세지도 명확해진다.
1
2
3
4
5
6
class NewHandlerHolder
{
public:
NewHandlerHolder(const NewHandlerHolder&) = delete;
NewHandlerHolder& operator=(const NewHandlerHolder&) = delete;
};
참고