대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 C++ 프로그래밍 : 알면 유용한 메모리 연산자
등록일 조회수 7729
첨부파일  

C++ 프로그래밍

알면 유용한 메모리 연산자



C/C++는 메모리를 직접 다룰 수 있어 개발자 사이에서도 호불호가 극명하게 갈리는 편이다. 메모리를 직접 제어해야 하는 어려움은 다른 언어에도 많은 영향을 끼쳤다. 메모리 할당과 해제에 신경을 쓸 필요가 없는 자바(Java)나 C#만 봐도 개발자가 메모리를 다루는 것에 얼마나 큰 부담을 가지는지 짐작할 수 있다.



그럼에도 C/C++는 건재하다. 이는 C/C++ 개발자라면 필연적으로 메모리를 직접 제어해야 함을 의미하는 것이다. C/C++로 개발을 한다면 우아하고 안전하게 메모리를 다루는 데 능숙해야 한다. 그러한 C/C++ 개발자를 위해 이 글을 썼다. 메모리와 관련된 연산자의 특징과 더불어 잘 알려지지 않았지만 메모리에 관련된 유용한 연산자 등 C/C++ 개발자가 알아야 할 것들을 다시금 정리했다.



new와 malloc의 차이

기껏 new와 malloc이 나오니 실망한 독자도 있을지 모르겠다. new, malloc 모르는 C/C++ 개발자는 아마 없을 것이다. 그런데 의외로 new와 malloc의 의미를 정확히 모르는 경우가 많다. 필자는 가끔 경력 사원 채용 시 면접에 참여할 때가 있다. 그때 항상 묻는 질문이 new와 malloc의 근본적인 차이가 무엇인가다. new를 malloc만으로 대체할 수 있는가? 아쉽게도 대부분의 면접자는 이에 대답하지 못했다. 가끔은 new는 C++용이고 malloc은 C용이라는 답을 듣기도 했다. 물론 틀린 답은 아니지만 필자가 원하던 답은 아니다.

new와 malloc는 많은 차이를 가지고 있다. new의 구현을 살펴보면 malloc이 호출됨을 확인할 수 있다. 즉 간단히 말하면 new = malloc + something의 관계라고 할 수 있다. 여기서 something은 많은 것들이 있을 수 있지만 일반적으로 생성자 호출을 의미한다. 따라서 malloc은 메모리 할당, new는 메모리 할당 및 생성자 호출이라고 말할 수 있다.

순수한 C에서는 기본 타입과 기본 타입들의 모임인 구조체가 있었다. 기본 타입과 구조체에는 당연히 생성자, 소멸자라는 개념도 있지 않았다. C에 클래스가 도입되면서 C++가 등장하고, 클래스는 기존 구조체와는 다르게 생성자와 소멸자라는 개념이 있었다. 당연히 메모리에 클래스 객체를 생성할 때 메모리 할당뿐 아니라 생성자를 함께 호출해줄 무언가가 필요했다. new가 탄생할 수밖에 없었던 것이다.



Placement new

new 연산자가 메모리를 할당받고 생성자를 호출한다는 것을 이미 알 것이다. 동작을 나누자면 첫 번째 메모리 할당, 두 번째 할당된 메모리를 생성자를 통해 초기화한다. 물론 생성자는 여러 가지 역할을 할 수 있지만, 주된 목적은 할당된 메모리를 초기화하는 것이다. 보통의 경우 new를 통해 두 가지 동작을 한 번에 수행할 수 있기에 무척 편하다. 그러나 정말 특수한 경우 두 가지 동작을 각각 나누어 수행해야 할 때가 있다. 즉 메모리 할당을 하고 다른 작업을 수행한 후 할당된 메모리를 초기화하거나, 이미 할당된 적이 있는 메모리를 초기화하는 경우가 여기에 해당한다.

이런 경우가 과연 있기나 할까? 의심할 수도 있지만 의외로 꽤 있다. 가변 크기 클래스를 사용하는 경우가 그러하다. 가변 크기 클래스라는 말이 생소한 독자도 있을 것이다. C++에 도입된 새로운 개념이 아니라 필자가 지어낸 이름이다. 그렇다면 이런 이름을 왜 지었는지, 예제 코드를 통해 살펴보자.



<리스트 1> 가변 크기 클래스 class CPerson { public: char* m_Name; // ⓐ }; class CVSPerson // 가변 크기 클래스 { public: char m_Name[1]; // ⓑ }; void main() { CPerson* pPerson = new CPerson; pPerson->m_Name = "Kim Do Hyung"; char* name = "Kim Na In"; CVSPerson* pVSPerson = (CVSPerson*)malloc // ① (sizeof(CVSPerson) + strlen(name)); pVSPerson->m_Name[0] = NULL; strcat(pVSPerson->m_Name, name); }



<리스트 1>은 가변 크기 클래스의 예다. 일반적인 클래스 CPerson과 가변 크기 클래스 CVSPerson을 유심히 비교해 보자. 두 클래스는 모두 멤버로 문자열을 포함하는데, 이 둘의 차이는 각각의 멤버인 ⓐ, ⓑ에서 드러난다. CPerson의 경우 멤버 타입이 char*이고, CVSPerson의 경우 멤버 타입이 char인 배열인데 요소 개수는 1이다

main을 통해 두 클래스 객체를 생성한 다음 이름을 대입하는 코드를 살펴보자. CPerson은 간단히 이해되겠지만, CVSPerson의 경우 몇 가지 주의할 것들이 있다. ①을 살펴보면 CVSPerson의 크기에다가 name의 문자열 길이를 더한 후 malloc으로 메모리를 할당받는다. 그리고 m_Name에는 문자열 복사를 통해 이름을 대입했다. 문자열 복사에는 strcat이 쓰였는데, 이를 위해 m_Name[0]은 NULL로 초기화를 했다. 왜 이렇게 복잡하게 처리했는지 궁금할 수 있는데, 의외로 이런 방식이 많이 쓰이고 있다.





일반적인 클래스 CPerson과 가변 크기 클래스 CVSPerson 이 둘에는 결정적인 차이가 하나 더 있다. 바로 데이터의 위치다. <그림 1>에서 CPerson에 주목하자. 실제 데이터인 이름은 전력 데이터 영역에 존재하고, CPerson의 멤버인 m_Name은 해당 데이터 영역을 가리키는 포인터다. 즉, CPerson의 m_Name은 4바이트(혹은 8바이트)의 포인터일 뿐이다.

이번에는 CVSPerson을 살펴보자. 클래스 객체 자체가 바로 실제 데이터 영역과 일치한다. 즉, 클래스 객체 메모리에 실제 클래스의 정보가 모두 담겨있는 것이다. 그렇다면 왜 이런 일체형 구조가 필요한 것일까? 클래스 객체의 정보를 네트워크로 전송하거나 파일에 기록한다고 가정해 보자. 클래스의 경우 일반적으로 멤버 포인터가 가리키는 영역에 접근해 정보를 취합해야 하는 오버헤드가 발생할 것이다. 그러나 가변 클래스의 경우 실제 객체의 크기만큼 메모리를 읽기만 하면 된다. 즉 정보를 취합해 전송할 때 더 효율적이다. 그 뿐 아니다. 객체를 그대로 복사할 경우 CPerson의 경우에는 깊은 복사를 수행해야 하므로 복사 생성자를 적절히 잘 만들어야 하지만 가변 크기 클래스인 CVSPerson은 단순 메모리 복사인 얕은 복사만으로도 객체를 완벽히 복사할 수 있다.

가변 크기 클래스는 이런 장점에도 불구하고 제한적으로 사용될 수밖에 없다. 가장 마지막 멤버에 대해서만 배열 타입으로 선언해야 하기 때문이다. 즉 가변 데이터인 문자열을 담는 멤버를 2개 이상은 사용할 수 없는 한계가 있다. 그럼에도 가변 크기의 데이터를 담는 용도로 가변 크기 클래스는 종종 사용되고 있다.

실제 가변 크기 클래스가 언제 사용되는지 궁금할 수 있다. 몇 가지를 소개하면 MFC의 TLS를 구성하는 경우, 윈도우 쉘 네임스페이스(Windows Shell NameSpace)의 PIDL 작성하는 등에 가변 크기 클래스가 쓰이고 있다. 지금까지의 설명만으로 이미 유추할 수 있겠지만, 일반 클래스 객체는 new를 통해 생성 가? 수 없다. new를 사용하는 순간 할당된 메모리 크기는 클래스가 정의된 만큼으로 고정되기 때문이다. 가변적인 길이의 일체형 메모리를 할당할 수가 없는 것이다. 그래서 가변 크기 클래스 객체를 생성하기 위해서는 반드시 malloc을 사용해야 한다. 그런데 만일 CVSPerson에 생성자가 필요하다면 어떻게 될까? 이미 살펴본 것처럼 malloc은 오직 메모리만 할당해줄 뿐이지 생성자를 호출하지는 않는다. 바로 이런 경우에 Placement new가 필요하다.



<리스트 2> Placement new 예 class CVSPerson // 가변 크기 클래스 { public: CVSPerson() // ① { m_Age = 1; m_Name[0] = NULL; } int m_Age; char m_Name[1]; }; void main() { char* name = "Kim Na In"; void* pData = malloc (sizeof(CVSPerson) + strlen(name)); // ② CVSPerson* pVSPerson = new (pData) CVSPerson; // ③ strcat(pVSPerson->m_Name, name); }



<리스트 2>를 통해 Placement new의 사용법을 알 수 있다. 가변 크기 클래스인 CVSPerson에는 ①처럼 생성자가 추가됐다. 생성자에서는 strcat을 위해 m_Name[0]을 NULL로 초기화한다. 그 외에 다른 추가적인 멤버 초기화도 수행한다. 달라진 부분은 ②와 ③이다. 먼저 malloc을 통해서 실제 객체의 크기만큼 메모리만 할당받는다. 그리고 ③과 같이 new와 TYPE 사이의 괄호 안에 할당된 메모리 주소를 넣으면 해당 메모리 영역에 대한 생성자가 호출된다. Placement new 사용법은 다음과 같이 정리할 수 있다.

new (Pointer of Memory Block) TYPE

Placement 예제를 테스트할 때 컴파일 에러가 발생할 수도 있다. 구문 오류가 발생할 경우 테스트 환경이 Debug 모드인지 확인해야 한다. Placement new는 오직 Release 모드에서만 실행 가능함에 유의하자. VC++의 Debug 모드의 경우 디버깅을 하기 위해 new가 DEBUG_NEW로 재정의된다. 문제는 DEBUG_NEW의 경우 Placement new와 형식이 겹치기 때문에 제대로 지원되지 않는다는 점이다. 따라서 VC++의 Debug 모드에서 Placement new를 사용하려면 추가적인 처리가 필요하다.



#pragma push_macro("new") #undef new // Placement new 사용 #pragma pop_macro("new")



이처럼 Placement를 사용하는 곳의 앞뒤에 매크로를 붙여야 한다. 매크로의 의미는 간단한데, 먼저 기존에 정의된 new 매크로를 임시로 저장하고, new 매크로를 해제한 뒤에 Placement new를 사용한다. 그런 다음 다시 임시 저장된 new 매크로를 복원하라는 의미다. GCC로도 Placement new를 사용할 수 있다. 아마도 컴파일 에러 탓에 GCC가 Placement new를 지원하지 않는다고 알고 있는 개발자도 있을 것이다. 이 또한 new의 형식에 따른 문제인데, #include 를 추가하면 GCC에서도 Placement new를 사용할 수 있다. Placement new의 경우 실제 코드에 사용되는 경우가 극히 드물다. 그렇기 때문에 많은 개발자가 그 존재 자체를 모르는 경우가 많다. 하지만 이를 분명히 안다면 언젠가는 큰 도움을 받게 될 것이다. 자주 쓰이지는 않지만 꼭 알아두기를 바란다.



_msize

가끔 현재 포인터에 할당된 메모리의 크기를 알 수 있지 않은까 생각될 때가 있다. 물론 sizeof를 사용하면 된다고 얘기할 개발자도 있을 것이다. 그런데 sizeof는 타입 및 객체에 할당된 메모리의 크기를 알려줄 뿐이지, 포인터 객체가 가리키고 있는 메모리 블록의 크기를 알려주지는 않는다. 즉, 포인터에 아무리 큰 메모리를 할당하고 sizeof를 한들 포인터의 크기인 4바이트(x64에서는 8바이트)라는 답만 돌아올 것이다.

그렇다면 방법이 전혀 없는 것일까? 이것이 가능한 메모리 함수가 바로 _msize다. 일반적으로 new와 malloc로 메모리를 할당하면 내부적으로 힙 관리자를 통해 할당된 메모리가 관리된다. 힙 관리자는 당연히 현재 할당된 메모리의 주소와 크기를 알고 있다. 따라서 힙 관리자를 이용해 포인터, 즉 특정 메모리 주소에 할당된 메모리 블록의 크기를 쉽게 구할 수 있다. _msize가 바로 그러한 역할을 수행한다. 대신 _msize는 힙 관리자를 이용하기 때문에 CRT Library에 의존적일 수밖에 없다. 또한 _msize는 표준 C++함수는 아니기 때문에 각 컴파일러에 따라서 _msize와 같은 함수가 있을 수도 있고, 없을 수도 있다. 사실 _msize는 VC++에서 제공하는 메모리 함수다. _msize의 형식은 다음과 같다.

size_t _msize(void *memblock);

MSDN에 따르면 malloc을 비롯한 calloc, realloc을 통해 힙에 할당된 메모리 블록의 크기를 바이트 단위로 반환할 수 있다. 필자가 생각하기에 크게 쓸모가 있는 함수는 아니지만 디버깅이나 테스트용 코드를 VC++로 작성할 때 유용할 수 있다. 간단하게 예제를 통해 이들 함수를 확인해 보자.



<리스트 3> _msize class CTestA // 소멸자 없음 { public: int m_Value; }; class CTestB // 소멸자 있음 { public: ~CTestB() {} int m_Value; }; void main() { int* p1 = new int[2]; int s1 = _msize(p1); // ① s1 = 8 CTestA* p2 = new CTestA[2]; int s2 = _msize(p2); // ② s2 = 8 CTestB* p3 = new CTestB[2]; int s3 = _msize(p3); // ③ Exception }



<리스트 3>을 실행하면 s1, s2에는 정상적인 값이 대입된다. 기본 타입인 int나 소멸자가 존재하지 않는 클래스 CTestA가 new []을 호출할 경우 할당된 메모리의 크기는 malloc을 그대로 호출하는 것과 같다. 즉 포인터 p1, p2의 값은 힙 할당 내역에 그대로 존재한다. 그러나 s3를 구하기 위해 ③의 _msize가 실행되는 순간 힙 충돌 예외가 발생된다. 클래스 CTestB의 경우 명시적으로 소멸자가 존재하기 때문에 new [] 호출로 반환된 주소가 malloc을 호출해 반환된 주소와 다르기 때문이다. 즉 포인터 p3의 경우 힙 할당 내역에 존재하지 않기 때문에 잘못된 입력을 받은 _msize가 예외를 발생시키게 되는 것이다.

만일 잘 이해되지 않는다면 마이크로소프트웨어 3월호에 기고한 필자의 글을 읽어보길 권한다. 클래스에 소멸자가 존재하는가 않하는가에 따라서 new []는 명시적으로 할당되는 메모리 크기나 주소가 실제 할당되는 메모리 주소와 크기가 다를 수 있다. _msize는 오직 실제 힙 관리자에 의해서 이미 할당된 메모리의 시작 주소를 요구하며, 해당 주소가 아닐 경우 예외를 발생시킨다. 지금 다시 정확한 메모리 할당 원리를 설명하기에는 지면이 부족하다시 참조하자. 결국 _msize는 무척 제한된 용도로만 사용 가능하다. 가끔 이 함수를 이용해 배열 객체를 가리키는 포인터로 배열의 크기를 구하기도 한다. 그럴 경우 배열의 요소 타입이 기본 타입이거나 소멸자가 존재하지 않는 클래스여야 할 뿐 아니라 동적으로 할당된 배열이어야만 한다. 즉 스택상에 임시로 만들어진 배열을 가리키는 포인터에는 절대 쓸 수 없다.

실질적으로 사용되기 어렵고 제한이 많기 때문에 각 컴파일러의 CRT Library에서 _msize와 같은 함수를 모두 제공하지는 않는다. 현재 필자가 찾아본 바로는 GCC의 경우는 _msize에 대응되는 함수를 제공하지 않는 것 같다. 물론 _msize와 같은 함수를 이용할 경우 메모리 안전성을 체크하는 로직을 쉽게 구현할 수 있다.



마치며

지금까지 잘 알려져 있지 않지만 알면 유용한 메모리 관련 연산자 및 함수를 알아봤다. Placement new는 많이 모르지만 안다면 고급 프로그래밍을 구사하는 데 많은 도움이 될 것이다. _msize는 비록 특정 컴파일러에서만 지원되기에 활용 범위가 크게 위축될 수 있지만, 디버깅 및 테스트용으로 사용해도 큰 도움이 될 것이다.



출처 : 마이크로소프트웨어 12월호

제공 : 데이터 전문가 지식포털 DBguide.net