이 항목은
C++을 쓰다보면 가끔 이런 생각이 들 때가 있습니다. '이 인간들, 이해하기 어렵게 만드려고 생난리를 쳤구먼...?'이라고요.
그 중 하나가 지금 이야기하고자 하는 'new 연산자'와 'operator new'의 차이랍니다. 이게 서로 다른 것인 줄도 모르셨죠?
라는 문장으로 시작한다.
처음에는 "operator가 한국어로 연산자인데 뭘까.. 'new 연산자'와 '연산자 new'의 차이라는 건가.."라는 생각이 들었다.
정말 모르겠지만.. 차근 차근 이해를 해보자!
하고 몇 줄 읽지 않아 답이 나왔다.
new 연산자가 호출하는 그 함수의 이름이 바로 operator new입니다. 진짜입니다.
아하! 답답함이 한순간에 풀렸다.
비주얼 스튜디오에서 new 연산자를 사용해보자.
아무거나 하나 작성하고 new 연산자 위에 마우스를 올리면, operator new 함수를 호출하고 있다는 것을 알 수 있다. new 연산자가 호출하는 함수의 이름이 operator new 라는 말이 진짜였다.
컨트롤을 누른 상태에서 new를 클릭하면 이렇게 operator new가 어떻게 선언되어있는지 볼 수 있다.
이제 처음 의문을 가지게 한 문제를 해결했으니 마음 편하게 공부할 수 있게 됐다.
1. 정적 메모리 할당과 동적 메모리 할당
이게 메모리 구조인데 heap 영역은 메모리를 동적으로 할당했을 때 사용되는 영역이고, stack 영역은 정적으로 할당했을 때 사용되는 영역이다.
'정적으로 할당한다', '동적으로 할당한다'가 뭘까? 정적 할당은 우리가 코드에서 선언한 변수 선언을 통해 예상되는 메모리를 확보한다. 그러니까 프로그램이 실행될 때 이미 메모리가 결정되어 있고, 해당 메모리는 프로그램이 종료될 때까지 건드릴 수 없다(종료될 때 운영체제가 알아서 회수함). 동적 할당은 프로그램 실행 중 메모리가 확보 된다. 정적 메모리 할당은 운영체제가 알아서 해준다고 했는데, 동적 메모리 할당은 프로그래머가 수동으로 작업 한다. 위키백과의 동적 메모리 할당 항목을 보면 설명이 잘 되어있다.
동적 메모리 할당 또는 메모리 동적 할당은 컴퓨터 프로그래밍에서 실행 시간 동안 사용할 메모리 공간을 할당하는 것을 말한다. 사용이 끝나면 운영체제가 쓸 수 있도록 반납하고 다음에 요구가 오면 재 할당을 받을 수 있다. 이것은 프로그램이 실행하는 순간 프로그램이 사용할 메모리 크기를 고려하여 메모리의 할당이 이루어지는 정적 메모리 할당과 대조적이다.
동적으로 할당된 메모리 공간은 프로그래머가 명시적으로 해제하거나 쓰레기 수집이 일어나기 전 까지 그대로 유지된다. C/C++와 같이 쓰레기 수집이 없는 언어의 경우, 동적 할당하면 사용자가 해제하기 전까지는 메모리 공간이 계속 유지된다. 동적 할당은 프로세스의 힙영역에서 할당하므로 프로세스가 종료되면 운영 체제에 메모리 리소스가 반납되므로 해제된다. 그러나 프로세스가 계속 실행될 때에는 동적할당 된 영역은 유지되므로 프로그램이 정해진 힙 영역의 크기를 넘는 메모리 할당을 요구하면 할당되지 않는다. 따라서 사용이 완료된 영역은 반납하는 것이 유리한데, 프로그래머가 함수를 사용해서 해제해야 한다.
동적 할당은 함수가 종료되거나 변수 영역을 벗어나면 자동으로 공간 해제가 이루어지는 스택을 사용한 자동 변수와 대조적이다. 프로세스의 정적 메모리 할당은 프로세스가 시작할 때 이미 정해진 메모리량으로 한정되어있기 때문에, 프로세스가 시작할 때부터 끝날 때까지 유지되는데 반해, 동적 할당은 프로세스의 실행 과정 중에 필요한 메모리를 운영체제에 요구해 할당받고 해제하는 것이 가능하다.
- 위키피디아 - 동적 메모리 할당
그래서 결론적으로 동적 메모리 할당은 런타임 동안 사용할 메모리 공간을 할당하는 것을 말하는데, C++에서는 동적 메모리 할당과 반납을 위해 new 연산자와 delete 연산자를 사용한다. C에서 사용했던 malloc과 free도 여전히 사용할 수 있지만, new와 delete를 사용하는 것이 더 권장되고 있다.
동적 할당을 왜 하는걸까? 기존에 파이썬을 쓰면서 나름대로 코드를 효율적으로 작성하기 위해 노력은 했지만, 메모리 할당까지 수동으로 하지 않았기에 처음에는 접해보지 않은 개념이라 이해하기 힘들었다.
하지만 코딩테스트 연습을 할 때 똑같은 알고리즘을 사용해서 풀어도 C++이 종종 파이썬의 10배가 넘는 속도로 결과를 내는 것을 종종 봐왔기에, 그를 위한거라면 어려워도 기꺼이 공부할 필요가 있다고 생각했다. 책을 보니 "가비지 컬렉션 기능을 아예 하단에 놓고 기본적으로 지원하는 프로그래밍 환경들이 저마다의 매력을 뿜어내는 요즘, 여전히 '수동'만을 고수하는 C++의 메모리 관리 방법은 어떻게 보면 적잖이 구닥다리로 보일 수 있습니다. 그럼에도 불구하고 아주 중요한 시스템 응용프로그램을 제작하는 전 세계의 수많은 개발자들은 메모리를 수동으로 관리할 수 있다는 점 때문에 주저 없이 C++을 선택하고 있죠.(Effective C++ p347)"라고 나와있었다. 납득했다. 좋은 성능은 그냥 나오는게 아니다.
동적 메모리 할당을 공부할 때 나에게 가장 잘 와닿았던 예시는 MMORPG 게임의 몬스터 생성이다. MMORPG 게임에서 플레이어의 수는 동시에 1명일 수도(심지어는 0명일 수도)있고, 5000명...10000명이 넘어갈 수도 있다. 레벨 4의 플레이어가 1명 접속해있는데, 레벨 155의 몬스터를 1000마리를 만들어놓는 건 낭비이다. 레벨 4의 플레이어에게는 그 정도 레벨의 몬스터가 필요하지도 않고, 나중에 성장해서 간다거나 길을 잃어서 간다고해도 필드에 플레이어가 도착하면 생성하면 그만이다. 그런데 정적 할당을 하면 이런걸 조절을 못한다. 왜냐면 런타임이 되기도 전에 이미 메모리 할당을 어떻게 할지 정해져 버리니까. 그래서 동적할당이 필요하다.
동적 할당도 막 하면 안된다. 정적 할당은 운영체제가 알아서 거둬가 주지만, 동적할당은 프로그래머가 직접 하고 거둬가야 한다. 그러니까 C++에서 메모리 관리를 어떻게 해야 하는지, C++에서 메모리 관리 루틴은 어떻게 동작하는지를 잘 파악해야 한다.
아까 언급한 Effective C++에서도 "이쪽(아주 중요한 시스템 응용프로그램) 개발자들은 일단 자신들이 만들 소프트웨어의 메모리 사용 성향을 연구한 후에, 그 연구 결과에 맞추어 메모리 할당 루틴과 해제 루틴을 다듬음으로써 가능한 최대한의 수행 성능(시간 및 공간 모두에서)을 제공하려고 애쓰고 있습니다."라고 나와있었다.
물론 요즘은 동적으로 할당 할 때도 직접 로우 레벨 메모리 연산을 하기보단, 벡터와 같은 메모리를 알아서 관리해주는 컨테이너를 이용하는 것을 선호한다고 한다. 그리고 포인터도 더이상 사용하지 않으면 자동으로 해제해주는 스마트 포인터를 사용한다. 하지만! 원리를 알아야 잘 사용할 수 있는 법이니까(또 예전에 작성된 코드를 다룰 때 요즘은 그런거 안써요 라고 한다면..?😅) 공부하자.
위에서 C++에서는 동적 메모리 할당과 반납을 위해 new 연산자와 delete 연산자를 사용한다고 했다. 최대한의 수행 성능이라는 (당장은 막막하게 느껴지는)목표를 위해 new 연산자와 delete 연산자의 동작 원리를 공부해보자.
2. new 연산자와 delete 연산자는 세트로 동작한다
new - delete
new[] - delete[]
new와 delete가 반드시 세트여야 하는 이유는 메모리 누수 때문이다. new를 사용해서 변수에 필요한 메모리 공간(데이터 블록)을 할당할 수 있다. 이 상황에서 메모리 누수는 new의 반환 값을 무시하거나, 해당 포인터를 담았던 변수가 스코프를 벗어나 할당했던 메모리 영역에 접근할 수 없게 되면 발생한다. 스택에서 직접적으로든, 간접적으로든 접근할 수 없는 데이터 블록이 힙에 발생하하는 것이다.
메모리는 한정된 자원이니 메모리를 쓰고나면 해제해야 한다. 이럴 때 delete를 사용한다. delete에 해제할 메모리를 가르키는 포인터를 지정한다. 이 작업을 해주지 않으면 놀고있는 메모리가 생기게 된다.
int* sample = new int[5];
delete sample; // (1) 땡!
delete[] sample; // (2) 정답!
(1)과 같이 메모리를 해제하게 되면 위의 프로그램은 우리가 알수 없는 동작을 하게 된다.
new 연산자를 사용해서 어떤 객체를 동적 할당하면, 내부적으로 두가지 동작을 하게 된다. 처음 이 글의 시작을 알렸던 ①operator new라는 이름의 함수를 사용해서 메모리가 할당되고, ②할당된 메모리에 대해 한 개 이상의 생성자가 호출된다. delete 연산자를 사용하면, 마찬가지로 두가지 동작을 하게 된다. ①기존에 할당된 메모리에 대해 한 개 이상의 소멸자가 호출되고, ②operator delete라는 이름의 함수를 사용해서 메모리가 해제된다.
'delete sample;'을 통해 메모리를 해제하면 sample이 단일 객체라고 생각하게 된다. 안그러면 포인터가 배열을 가리키고 있다는 것을 모르게 된다. 그러니 소멸자의 호출 횟수가 생성자가 호출된 횟수보다 적을거고, 어떤 문제(해당 객체를 담고 있는 메모리가 누출되고, 해당 객체가 갖고 있던 리소스까지 샐 수 있음)가 생길 것이다. 물론 int같은 기본 제공 타입이라고 해도 마찬가지로 문제가 생길 것이다.
그러니까 일을 어렵게 만들지 말고 new를 썼으면 delete를 쓰고, new[]를 썼으면 delete[]를 쓰자.
3. new 연산자와 operator new 함수(객체 생성과 메모리 할당)
위에서 new 연산자는 ①operator new 함수를 사용해서 요청한 타입의 객체를 담을 수 있는 크기의 메모리를 할당하고, ②해당 객체의 생성자를 호출하여 할당된 메모리에 객체 초기화를 수행하는 순서로 동작한다고 했다. new 연산자가 ①과 ②라는 일을 한다는 것은 바꿀 수 없다.
다만 'operator new 함수를 사용해서 요청한 타입의 객체를 담을 수 있는 크기의 메모리를 할당한다'라는 ①이 동작하는 방법, 즉 '객체를 담을 메모리를 할당하는 방법'을 바꿀 수있다.
그 방법이 바로 new연산자가 메모리 할당을 위해서 호출하는 operator new 함수 오버로딩이다.
operator 함수는 대개 아래와 같은 모습으로 정의된다.
void* operator new(size_t size);
void* operator new[](size_t size);
함수의 반환 타입은 void*으로, 초기화되지 않은 원시 메모리의 포인터를 반환한다. 매개 변수인 size_t는 할당할 메모리의 크기를 결정한다.
operator new를 오버로딩해서 매개변수를 추가한다고 해도, 첫번째 매개변수는 항상 size_t 타입이어야 한다.
operator new는 malloc과 마찬가지로 메모리만 할당한다. 할당된 메모리를 받아 객체 구실(메모리에 값을 세팅해서 생성자대로 객체를 초기화) 할 수 있도록 하는 것은 new 연산자의 일이다.
오케이. 그러면 operator new가 객체 구실할 수 있도록 하는게 new 연산자의 일이라고 했는데, 왜 객체 구실을 할 수 있도록 해야하는 걸까??? 그 답은 Effective C++ 항목 13에 있다. 스마트 포인터를 공부할 때도 나오는 부분이다.
void f()
{
Test *test = createSomething(); // createSomething은 Test 클래스의 팩토리 함수
...
delete test;
}
이런 함수가 있다고 생각해보자 createSomething은 Test 클래스의 팩토리 함수로, 이를 통해 얻어낸 객체를 사용할 일이 없을 때 해제해야 하는 건 호출자 쪽이다. 그래서 delete test; 가 있다.
그런데 이 createSomething()함수로 메모리는 잘 할당 받았는데 '...' 로 생략된 어딘가에서 return이라던가.. exception이라던가 다양한 이유로 delete에 도달하지 않을 가능성이 있다. 그러면 당연히 메모리가 누출되고, 자원이 새게 된다.
열심히, 꼼꼼히 잘 만들면 이런 문제가 생기지 않겠지만, 코드는 혼자 작성하는게 아니고 유지보수를 맡은 누군가가 return이나 continue, exception문을 써서 문제가 발생할 수 있다. 그리고 사람이 항상 멀쩡한 정신으로 코드를 작성하는게 아니니까 이런 실수는 시스템적으로 대처를 할 수 있다면 대처를 하는게 좋다. 그 시스템적으로 대처를 하는 방법이 '객체'로 다루는 것이다!
이게 무슨 말이냐 하면, 자원을 객체에 넣고 그 자원의 해제를 소멸자가 맡도록 하고, 해당 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만드는 것이다. 그니까 자원을 객체에 넣으면, C++이 소멸자를 자동으로 호출해주기 때문에 자원이 딱 필요없어질 때 해제되게 된다는 거다. Effective C++(항목 13, 119p)에도 그렇게 해제 되는게 맞다고 나와있다.
소프트웨어 개발에 쓰이는 상당수의 자원이 힙에서 동적으로 할당되고, 하나의 블록(block) 혹은 함수 안에서만 쓰이는 경우가 잦기 때문에 그 블록 혹은 함수로부터 실행 제어가 빠져나올 때 자원이 해제되는 게 맞다.
여기서 나온 용어가 Resource Acquisition Is Initinalization(RAII)다. 자원 획득이 곧 초기화라는 말인데, 자원 획득과 자원 관리 객체의 초기화가 한 문장에서 이루어지는게 너무 일상적이기 때문이다.
소멸자는 어떤 객체가 소멸될 때(유효 범위를 벗어날 때 등) 자동적으로 호출되기 때문에, 실행 제어가 어떤 이유로 블록을 떠나게 되든(exception, continue, return 등 상관없음) 자원 해제가 자동으로 이루어지게 된다. 객체를 해제하다가 예외가 발생되는 상황에 빠지면 일이 꼬이게 되지만, 이건 소멸자에서 예외가 빠져나가면 안되게 처리(Effective C++ 항목8)하면 된다. 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외든 소멸자에서 모두 받아낸 후에 삼키든 프로그램을 끝내든 해야한다. 또 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 한다.
4. placement new(메모리 지정)
placement new는 operator new의 특별판으로, 어디선가 초기화되지 않은 메모리를 할당받았을 때 직접 객체 행세를 하도록 생성자를 호출하기 위해 사용하는 함수이다. 물론 이름은 opertor new로 같다. 함수의 선언 형태는 아래와 같다.
void* operator new(size_t, void *location)
{
return location;
}
이 함수를 어떻게 쓰는지 한번 봐보자.
#include <new>
class Exam{
public:
Exam (int examScore);
};
Exam* constructExamInBuffer(void *buffer, int examScore)
{
return new (buffer) Exam(examScore);
}
Exam이라는 객체를 buffer로 지정되는 메모리에 생성하고, 해당 포인터를 반환한다. 공유 메모리나 메모리-맵 I/O를 사용하는 애플리케이션을 만들 때 유용하다. 이런 애플리케이션에서는 객체를 특별한 주소 공간이나 별도의 루틴을 통해 할당한 메모리에다 두어야 하기 때문이다.
이러한 placement new를 사용하면 유지보수가 더 힘들다는 점과 객체를 삭제할 때 소멸자를 직접 호출해야 한다는 단점이 있다. 소멸자를 호출하는 것 뿐만 아니라 operator delete를 호출해서 비가공 메모리를 직접 해제해야 한다.
이제 대략적으로 new 연산자와 operator new함수에 대한 결론이 나왔다.
- 어떤 객체를 heap에 생성한다 -> new 연산자 사용(메모리 할당 + 생성자 호출)
- 메모리만 할당하고 싶다 -> operator new 함수 사용
- heap에서 메모리를 떼어오지 않고, 이미 가지고 있는(지정한) 메모리를 사용하려 한다 -> placement new 사용
5. delete 연산자와 operator delete 함수(객체 삭제와 메모리 해제)
이제 new의 짝꿍인 delete를 볼 차례다. 앞에서 new 연산자와 operator new 함수의 차이를 공부했으니, 이제 delete 연산자와 operator delete 함수의 차이도 대략적으로 알 수 있다. delete 연산자를 사용하면 해당 객체에 대한 소멸자를 호출하고, operator delete 함수를 호출해서 메모리를 해제한다.
void operator delete(void *memoryToBeDeallocated);
operator delete는 위와같이 선언되어있는데
string *ps;
...
delete ps;
이렇게 delete ps를 사용하면
ps -> ~string();
operator delete(ps);
이런식으로 동작하게 된다는거다.
ps -> ~string(); 을 하는걸 보면 느낌이 왔겠지만, 미초기화된 메모리만을 가지고 어떤 일을 할 떄는 new와 delete 연산자를 건너뛰고 operator new 함수와 operator delete 함수를 호출해서 일을 해야 한다. malloc과 free의 느낌으로 일을 하면 된다.
여기까지는 그냥 operator new고 placement new(메모리 지정 new)를 사용한 경우, 그 메모리에는 delete 연산자를 사용하면 안된다. placement new는 operator new에 의해 할당된 것이 아니기 때문이다. 메모리 지정 new는 자신에게 넘어온 포인터를 반환했을 뿐이고, 이 포인터가 어디서 왔는지 모른다. 따라서 해당 객체의 소멸자를 직접 호출하는 수 밖에 없다.(메모리를 직접 해제해야 함)
6. new[] 연산자와 delete[] 연산자(배열)
드디어 마지막 관문이다. operator new는 opertor new(하나의 객체)와 operator new[]로 갈린다(delete도 operator delte와 operator delete[]).
string *ps = new string[10]; // (1)
...
delete ps[]; // (2)
위에서와 []를 빼면 똑같은 예제 코드다. 하나의 객체만 생성하는게 아니고, 배열을 할당하는 차이점이 있다. 배열 생성에 사용되는 new 연산자는 단일 객체 생성에 사용되는 new 연산자와 몇몇 차이점이 있다.
첫번째로 메모리를 할당 할 때 opertor new 함수를 호출하지 않는다. 대신 operator new[]를 호출한다. 이 함수도 오버로딩이 가능하다.
두번째로 단일 객체 생성용 new 연산자와 객체 배열 생성용 new 연산자는 호출하는 생성자의 개수가 다르다. 배열 생성용 new 연산자는 배열 요소에 대해 일일이 생성자를 호출해야 한다. 위의 예제에 맞게 설명해보면, (1) operator new[]를 호출해서 10개의 string 객체에 메모리를 할당하고, 배열의 각 요소에 대해 기본 생성자를 호출한다. 그리고 (2) 배열의 각 요소에 대해 소멸자를 호출하고, operator delete[]를 호출해서 배열 전체의 메모리를 해제 한다.
여기를 끝으로 드디어 construction(생성자!!! new 연산자가 생성자를 호출해 할당된 메모리에 객체 초기화를 수행)과 allocation(operator new 함수가 메모리를 할당함), destruction(소멸자! delete 연산자가 소멸자를 호출함)과 deallocation(operation delete 함수가 메모리를 해제함)의 차이를 알아냈다. 기나긴 여정이었다... 이제 실전에서 적용을 하러 가야겠다!
끝.
참고 및 출처
- Effective C++ 항동 4 - 쓸데 없는 기본 생성자는 그냥 두지 말자
- Effective C++ 항목 8 - 예외가 소멸자를 떠나지 못하도록 붙들어 놓자.
- Effective C++ 항목 16 - new 및 delete를 사용할 때는 형태를 반드시 맞추자
- Effective C++ 항목 49 - new 처리자의 동작 원리를 제대로 이해하자
- Effective C++ 항목 50 - new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자
- Effective C++ 항목 51 - new 및 delete를 작성할 때 따라야 할 기존의 관례를 잘 알아 두자
- More Effective C++ 항목 8 - new와 delete의 의미를 정확히 구분하고 이해하자
- 전문가를 위한 C++ 7장 - 메모리 관리
- 전문가를 위한 C++ 15장 - 메모리 할당과 해제 연산자 오버로딩하기
- 위키백과 - 동적 메모리 할당(링크)