전문가를 위한 C++/ 메모리 관리

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

동적 메모리 다루기

메모리의 작동 과정 살펴보기

int i = 7;

위와 같은 로컬 변수 i를 자동 변수(automatic variable)이라 부르며, 이는 스택에 저장된다. 프로그램의 실행 흐름이 이 변수가 선언된 스코프(유효 범위)를 벗어나면 메모리가 자동으로 해제된다.

int* ptr = new int;

new 키워드를 사용하면 힙 메모리가 할당된다. 포인터 역시 일종의 변수이기 때문에 ptr이라는 변수는 스택에 저장되지만, 이 변수가 가리키는 값은 힙에 있다. (이는 C#에서도 동일한 식으로 관리된다. 힙에 할당된 데이터를 가리키는 스택 변수가 있다. 이는 scope를 벗어난 후에도 데이터를 사용하기 위해 스택과는 다른 공간에 메모리를 할당하는 것이다)

int** handle = nullptr;
handle = new int*;
*handle = new int;

위 코드는 포인터가 스택과 힙에 모두 있는 예를 보여준다. 먼저 정수 포인터에 대한 포인터를 handle이란 변수로 선언한다. 그런 다음 정수 포인터를 담는데 충분한 크기로 메모리를 할당한 뒤 그 메모리에 대한 포인터를 handle에 저장했다. 이어서 이 메모리(*handle)에 정수를 담기 충분한 크기의 힙 메모리를 동적으로 할당했다. 이렇게 되면 두 포인터 중 하나(handle)은 스택에, 다른 하나(*handle)은 힙에 존재하게 된다.

메모리 할당과 해제

new와 delete 사용법

변수에 필요한 메모리 블록을 할당하려면 new에 그 변수의 타입을 지정해서 호출한다. 그러면 할당한 메모리에 대한 포인터가 리턴된다.

물론 이 포인터를 변수에 저장하는 등 관리하는 작업은 프로그래머의 몫이다. new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나면 할당했던 메모리에 접근할 수 없는데, 이를 메모리 누수(memory leak)이라 부른다.

void leaky()
{
new int; // 메모리 누수가 발생한다.
}

위 코드는 int를 담을 공간만큼 메모리 누수가 발생하는 예를 보여준다. 스택에서 직접적이든 간접적이든 더는 접근할 수 없는 데이터 블록이 힙에 발생하면 메모리 누수가 발생한다.

힙 메모리를 해제하려면 delete 키워드에 해제할 메모리를 가리키는 포인터를 지정한다.

int* ptr = new int;
delete ptr;
ptr = nullptr;

Note) 메모리를 해제한 포인터는 nullptr로 다시 초기화한다. 그래야 이미 해제된 메모리를 가리키는 포인터를 모르고 다시 사용하는 실수를 방지할 수 있다.

malloc()

C++은 여전히 malloc()을 지원하지만 malloc() 대신 new를 사용하는 것이 바람직하다. new는 단순히 메모리를 할당하는데 그치지 않고 객체까지 만들기 때문이다.

(이하 생략)

Caution) C++에서는 malloc()과 free()를 절대 사용하지 말고 new와 delete만 사용한다.

메모리 할당에 실패한 경우

new가 항상 제대로 처리될 것이라 생각하는 사람이 적지 않지만 메모리가 부족해서 상황이 무지무지 좋지 않으면 new가 실패할 수 있다. 기본적으로 new가 실패하면 프로그램이 종료된다. 

(이하 설명 생략)

배열

기본 타입 배열

프로그램에서 배열에 대한 메모리를 할당하면 실제 메모리에서도 연속된 공간을 할당한다. 이때 메모리의 한 칸은 배열의 한 원소를 담을 수 있는 크기로 할당된다. 예컨대 다섯 개의 int 값으로 구성된 배열을 다음과 같이 로컬 변수로 선언하면 스택에 메모리가 할당된다. (C++은 놀랍게도 배열을 스택에 만들 수 있다!)

int myArray[5];

배열을 힙에 선언할 때도 비슷하다. 배열의 위치를 가리키는 포인터를 사용한다는 점만 다르다. 다음 코드는 int 값 5개를 담는 배열에 메모리를 할당해서 그 공간을 가리키는 포인터를 myArrayPtr이란 변수에 저장한다.

int* myArrayPtr = new int[5];

(이하 설명 생략)

명심해야 할 점은 new[]를 호출한 만큼 delete[]도 호출해야 한다는 것이다.

C++은 realloc() 이란 함수도 지원하는데, 이 함수 역시 C 언어에서 물려받은 것이므로 절대 사용하지 말기를 바란다.

객체 배열

(생략)

배열 삭제하기

(생략)

다차원 배열

(생략. 다차원 배열도 스택과 힙에 각각 생성할 수 있다.)

힙에서는 메모리 공간이 연속적으로 할당되지 않기 때문에, 스택 방식의 다차원 배열처럼 메모리를 할당하면 안 된다. 이럴 때는 힙 배열의 첫 번째 인덱스에 해당하는 배열을 연속적인 공간에 먼저 할당한다. 그런 다음 이 배열의 각 원소에 두 번째 인덱스에 해당하는 차원의 배열을 가리키는 포인터를 저장한다.

아쉽지만 여기서 하위 배열을 할당하는 작업은 컴파일러가 자동으로 처리할 수 없기 때문에 직접 할당해야 한다.

char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
char** myArray = new char*[xDimension]; // 첫 번째 차원의 배열을 할당한다.

for (size_t i = 0; i < xDimension; i++)
{
myArray[i] = new char[yDimension[; // i번째 하위 배열을 할당한다.
}
return myArray;
}

다차원 힙 배열에 할당된 메모리를 해제할 때도 마찬가지로 delete[]로 하위 배열을 해제할 수 없기 때문에 일일히 해제해야 한다. 

void releaseCharacterBoard(char** myArray, size_t xDimension)
{
for (size_t i = 0; i < xDimension; i++)
{
delete [] myArray[i]; // i번째 하위 배열을 해제한다.
}
delete [] myArray; // 첫 번째 차원의 배열을 해제한다.
}

기존 C 스타일 배열은 메모리 안정성이 떨어지므로 가급적 사용하지 않는 것이 좋다. 여기서 C 스타일 배열도 소개한 이유는 기존에 작성된 코드를 봐야 할 수도 있기 때문이다. 코드를 새로 작성할 때는 std::array나 std::vector와 같은 C++ 표준 라이브러리에서 제공하는 컨테이너를 사용하기 바란다.

일차원 동적 배열은 vector<T>로, 이차원 동적 배열은 vector<vector<T>>로 작성한다. 행의 길이가 모두 같은 이차원 데이터를 다루고 싶다면 Matrix<T>나 Table<T> 클래스 템플릿을 이용한다.

포인터 다루기

포인터는 남용하기 쉬운 기능으로 악명 높다. 포인터는 단지 메모리 주소이기 때문에 이론상 그 주소를 얼마든지 변경할 수 있고, 심지어 다음과 같이 위험한 일도 할 수 있다.

char* scaryPointer = (char*)7;

이 코드는 메모리 주소 7에 대한 포인터를 만드는데, 이 포인터는 어떤 값을 가리키거나 애플리케이션의 다른 영역에서 사용하는 공간일 가능성이 높다.

포인터의 작동 방식

포인터는 메모리의 한 지점을 가리키는 숫자에 불과하다. 공간적 사고에 익숙한 사람은 포인터를 화살표로 생각하면 이해하기 쉽다. 포인터는 손가락으로 가리키는 것처럼 참조 단계를 표현한다. 이 관점에서 보면 여러 단계로 구성된 포인터에서 각 단계는 데이터에 이르는 경로라고 볼 수 있다.

*연산자로 포인터를 역참조하면 메모리에서 한 단계 더 들어가 볼 수 있다. 포인터를 주소 관점에서 보면 역참조는 포인터가 가리키는 주소로 점프하는 것과 같다. 역참조를 하는 부분을 그림으로 표현하면 출발 지점에서 목적지로 향하는 화살표로 나타낼 수 있다.

& 연산자를 사용하면 특정 지점의 주소를 얻을 수 있다. 이렇게 하면 메모리에 대한 참조 단계가 하나 더 늘어난다. 이 연산자를 주소 관점에서 보면 프로그램은 특정 특정 메모리 지점을 숫자로 표현한 주소로 본다. 공간 관점에서 보면 표현식의 결과가 담긴 위치를 가리키는 화살표를 생성한다고 볼 수 있다. 그리고 이 화살표가 시작하는 지점을 포인터로 저장할 수 있다.

포인터에 대한 타입 캐스팅

포인터는 단지 메모리 주소에 불과해서 타입을 엄격히 따지지 않는다. XML 문서를 가리키는 포인터와 정수를 가리키는 포인터는 크기가 서로 같다. 프로인터 타입은 C 스타일 캐스팅을 이용해서 얼마든지 바꿀 수 있다.

Document* documentPtr = getDocument();
char* myCharPtr = (char*)documentPtr;

정적 캐스팅을 사용하면 좀 더 안전하다. 그러면 관련 없는 데이터 타입으로 포인터를 캐스팅하면 컴파일 에러가 발생한다.

Document* documentPtr = getDocument();
char* myCharPtr = static_cast<char*>(documentPtr);  // 컴파일 에러가 발생한다.

정적 캐스팅 하려는 포인터와 캐스팅 결과에 대한 포인터가 가리키는 객체가 서로 상속 관계에 있다면 컴파일 에러가 발생하지 않는다.

배열과 포인터의 두 얼굴

배열=포인터

힙 배열을 참조할 때만 포인터를 사용하는 것은 아니다. 스택 배열에 접근할 때도 포인터를 사용할 수 있다. 배열의 주소는 사실 (인덱스가 0인) 첫 번째 원소에 대한 주소다. 컴파일러는 배열의 변수 이름을 보고 배열 전체를 가리킨다고 알지만, 실제로는 배열의 첫 번째 원소에 대한 주소만 가리킬 뿐이다. (배열은 메모리 공간에 연속적으로 존재한다)

그래서 힙 배열과 똑같은 방식으로 포인터를 사용할 수 있다. 다음 코드는 0으로 초기화한 스택 배열을 만들고 포인터로 접근하는 예를 보여준다.

int myIntArray[10];
int* myIntPtr = myIntArray;
// 포인터로 배열 접근하기
myIntPtr[4] = 5;

스택 배열을 포인터로 접근하는 기능은 배열을 함수에 넘길 때 특히 유용하다. 다음 함수는 정수 배열을 포인터로 받는다. 여기서 함수를 호출할 때 배열의 크기를 지정해야 하는데, 포인터만으로는 크기를 알 수 없기 때문이다. 사실 C++에서 배열은 원소의 타입이 포인터가 아니더라도 크기 정보를 다루지 않는데, 이 점은 표준라이브러리에서 제공하는 컨테이너를 사용해야 하는 또 다른 이유이기도 하다. (C++이 오래된 언어라서 그런지 현재 기준으로 보면 납득 안되는 부분이 좀 있는 것 같다.)

void doubleInts(int* theArray, size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

이 함수를 호출할 때 스택 배열을 전달해도 되고 힙 배열을 전달해도 된다. 힙 배열을 전달하면 이미 포인터가 담겨 있어서 함수에 값으로 전달된다. 스택 배열을 전달하면 배열 변수를 전달하기 때문에 컴파일러가 이를 배열에 대한 포인터로 변환한다. 이때 프로그래머가 직접 첫 번째 원소의 주소를 넘겨도 된다. 세 경우를 코드로 표현하면 다음과 같다.

size_t arrSize = 4;
int* heapArray = new int[arrSize] { 1, 5, 3, 4 };
doubleInts(heapArray, arrSize);
delete [] heapArray;
heapArray = nullptr;

int stackArray[] = { 5, 7, 9, 11 };
arrSize = std::size(stackArray); // C++17부터는 <array>를 사용한다.
//arrSize = sizeof(stackArray) / sizeof(stackArray[0]); // C++17 이전 방식
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);

배열을 매개변수로 전달하는 과정은 포인터를 매개변수로 전달할 때와 섬뜩할 정도로 비슷하다. 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급한다. 배열을 인수로 받아서 그 안에 담긴 값을 변경하는 함수는 복사본이 아닌 원본을 직접 수정한다.

포인터와 마찬가지로 배열을 전달하면 시제로 레퍼런스 전달 방식의 효과가 나타난다. 함수에 전달한 값이 복사본이 아닌 원본 배열의 주소이기 때문이다. 다음에 나온 doubleInts() 코드는 포인터가 아닌 배열 매개변수를 받더라도 원본 배열이 변경되는 것을 보여준다.

void doubleInts(int theArray[], size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

컴파일러는 이 함수의 프로토타입에서 theArray 뒤의 대괄호([]) 사이에 나온 숫자를 무시한다. 그래서 다음 세 가지 방식으로 표현한 문장은 모두 같다.

void doubleInts(int* theArray, size_t inSize);
void doubleInts(int theArray[], size_t inSize);
void doubleInts(int theArray[2], size_t inSize);

왜 이렇게 처리하는지 이해가지 않을 수 있다. 함수 정의 부분에 배열 문법을 사용하면 컴파일러가 그 배열을 복사해야 한다고 생각할 수도 있다. 하지만 그렇게 하지 않은 이유는 성능 때문이다. 배열에 담긴 원소를 모두 복사하는데 시간이 걸릴 뿐 아니라, 메모리 공간도 상당히 차지한다. 이처럼 항상 포인터를 전달하기 때문에 컴파일러가 배열을 복사하는 코드를 추가할 필요가 없다.

길이를 알고 있는 스택 배열을 레퍼런스 전달 방식으로 함수에 전달하는 방법도 있는데, 문법이 깔끔하지 않다. 이 방식은 힙 배열에 적용할 수 없다.

void doubleIntsStack(int (&theArray)[4]);

12장에서 설명할 함수 템플릿을 사용하면 스택 배열의 크기를 컴파일러가 알아낼 수 있다.

template<size_t N>
void doubleIntsStack(int (&tgeArray)[N])
{
for (size_t i = 0; i <N; i++)
{
theArray[i] *= 2;
}
}

포인터가 모두 배열은 아니다!

포인터와 배열이 같다고 생각하면 안된다. 사실 미묘하지만 중요한 차이가 있다. 포인터 자체는 의미가 없다. 임의의 메모리를 가리킬 수도 있고 객체나 배열을 가리킬 수 있다. 언제든지 포인터에 배열 문법을 적용해도 되지만 실제로 포인터가 배열은 아니기 때문에 부적절한 경우도 있다.

int* ptr = new int;

ptr이란 포인터는 정상적인 포인터지만 배열은 아니다. 이 포인터가 가리키는 값을 배열 문법(ptr[0])으로 표현할 수는 있지만 바람직한 작성 방식이 아닐 뿐만 아니라 좋은 점도 없다. 사실 이렇게 배열이 아닌 포인터를 배열 문법으로 표현하면 버그가 발생하기 쉽다. ptr[1]에 있는 메모리에 어떤 값이 있을지 모르기 때문이다.

로우레벨 메모리 연산

C보다 C++이 좋은 점 중 하나는 메모리에 신경을 덜 쓸 수 있다는 것이다. 객체를 이용할 때는 메모리 관리를 클래스 단위로만 신경 쓰면 된다. 생성자와 소멸자를 통해 메모리 관리 작업을 해야 할 시점만 알려주면 나머지 작업은 컴파일러가 도와준다. 이렇게 메모리 관리 작업을 클래스 단위로 숨기면 사용성이 크게 높아진다.

포인터 연산

C++ 컴파일러는 포인터 연산을 수행할 때 그 포인터에 선언된 타입을 이용한다. 포인터를 int로 선언하고 그 값을 1만큼 증가시키면 포인터는 메모리에서 한 바이트가 아닌 int 크기만큼 이동한다.

이 연산은 주로 배열을 다루는데 유용한데, 배열에 담긴 데이터는 모두 타입이 같을 뿐만 아니라 메모리에 연속적으로 저장되어 있기 때문이다.

(이하 생략)

커스텀 메모리 관리

C++에서는 기본으로 제공하는 메모리 할당만으로도 대부분의 일을 처리할 수 있다. new와 delete의 내부 처리 과정을 살펴보면 메모리르 ㄹ적절한 크기로 잘라서 전달하고, 현재 메모리에서 사용할 수 있는 공간을 관리하고, 다쓴 메모리를 해제하는데 필요한 작업을 수행한다.

리소스가 상당히 부족하거나 메모리 관리와 같은 특수한 작업을 수행할 때는 메모리를 직접 다뤄야 할 수도 있다. 핵심은 클래스에 큰 덩어리의 메모리를 할당해놓고 필요할 때마다 잘라 쓰는데 있다.

메모리를 직접 관리하면 뭐가 좋을까? 오버헤드를 좀 더 줄일 수 있다. 여기서 오버헤드란 new로 메모리를 할당하면 현재 프로그램에서 얼마나 할당했는지 기록하는데 필요한 공간을 말한다. 이렇게 기록해둬야 delete를 호출할 때 딱 필요한 만큼 해제할 수 있다.

가비지 컬렉션

메모리를 정상 상태로 유지하기 위한 최후의 보루는 가비지 컬렉션(garbage collection)이다. C++은 자바나 C#과 달리 가비지 컬렉션이 기본으로 제공되지 않는다. 최신 버전 C++은 스마트 포인터로 메모리를 관리해서 나아졌지만, 예전에는 new와 delete를 이용해서 직접 관리해야 했다.

shared_ptr 같은 스마트 포인터는 가비지 컬렉션과 상당히 비슷한 방식으로 메모리를 관리한다.

(이하 설명 생략)

가비지 컬렉션 메커지늠을 구현하기는 상당히 어렵고 속도가 느릴 가능성이 높기 때문에 굳이 애플리케이션 안에 가비지 컬렉션 기능을 구현하고 싶다면 재사용 가능한 형태로 구현되어 있는 가비지 컬렉션 라이브러리를 찾아서 쓰기 바란다.

객체 풀

객체 풀은 접시를 재사용하는 것에 비유할 수 있다. 사용할 접시 수를 미리 정해놓고, 음식을 먹고 난 빈 접시에 다시 음식을 담아오는 것이다. 객체 풀은 타입이 같은 여러 개의 객체를 지속적으로 사용해야 하지만 매번 객체를 생성하면 오버헤드가 상당히 커지는 상황에 적용하기 좋다.

스마트 포인터

메모리 누수를 방지하려면 스마트 포인터를 사용하는 것이 좋다. 기본적으로 스마트 포인터는 메모리뿐만 아니라 동적으로 할당한 모든 리소스를 가리킨다. 스마트 포인터가 스코프를 벗어나거나 리셋되면 거기에 할당된 리소스가 자동으로 해제된다. 

스마트 포인터는 함수 스코프 안에서 동적으로 할당된 리소스를 관리하는데 사용할 수도 있고, 클래스의 데이터 멤버로 사용할 수도 있다. 동적으로 할당된 리소스의 소유권을 함수의 인수로 넘겨줄 때도 스마트 포인터를 사용한다.

C++은 스마트 포인터를 지원하는 기능을 언어 차원에서 다양하게 제공한다. 첫째, 템플릿을 이용하면 모든 포인터 타입에 대해 타입에 안전한 스마트 포인터 클래스를 사용할 수 있다. 둘째, 연산자 오버로딩을 이용하여 스마트 포인터 객체에 대한 인터페이스를 제공해서 스마트 포인터 객체를 일반 포인터처럼 활용할 수 있다.

스마트 포인터의 종류는 다양하다. 가장 간단한 것은 리소스에 대한 고유 소유권을 받는 것이다. 그래서 스마트 포인터가 스코프를 벗어나거나 리셋되면 참조하던 리소스를 해제한다. 표준 라이브러리에서 제공하는 std::unique_ptr가 이러한 고유(단독) 소유권 방식을 지원한다.

포인터를 관리하는 과정에서 발생하는 문제는 단순히 스코프를 벗어날 때 해제하는 것을 깜빡 잊는 것 말고도 많이 있다. 간혹 어떤 포인터의 복사본을 여러 객체나 코드에서 갖고 있을 때가 있다. 이러한 상황을 앨리어싱(aliasing)이라 부른다.

모든 리소스를 제대로 해제하려면 리소스를 마지막으로 사용한 포인터가 해제해야 한다. 그런데 코드의 어느 지점에서 그 리소스를 마지막으로 사용하는지 알기 힘들 때가 많다. 실행 시간에 입력되는 값에 따라 동작이 결정된다면 정확한 순서를 알아내는 것은 근본적으로 불가능하다. 그래서 리소스의 소유자를 추적하도록 레퍼런스 카운팅(reference counting)을 구현한 스마트 포인터도 있다. 

이 기능이 지원되는 스마트 포인터를 복사해서 리소스를 가리키는 인스턴스가 새로 생성되면 레퍼런스 카운트가 증가한다. 또한 이렇게 복사해서 만든 스마트 포인터 인스턴스가 스코프를 벗어나거나 리셋되면 레퍼런스 카운트가 감소한다. 레퍼런스 카운트가 0이되면 그 리소스를 사용하는 곳이 없기 때문에 스마트 포인터에 의해 자동으로 해제된다. 표준 라이브러리에서 제공하는 std::shared_ptr가 바로 이러한 레퍼런스 카운팅을 이용하여 공유 소유권(shared ownership) 방식을 지원한다. C++ 표준에서 정한 shared_ptr은 스레드에 안전하다. 그렇다고 해서 포인터가 가리키던 리소스도 스레드에 안전하다는 뜻은 아니다.

Note) 주로 unique_ptr을 사용하고 shared_ptr은 리소스를 공유할 때만 사용한다.

unique_ptr

동적으로 할당한 리소스는 항상 unique_ptr와 인스턴스에 저장하는 것이 바람직하다.

unique_ptr 생성 방법

void couldBeLeaky()
{
Simple* mySimplePtr = new Simple();
mySimplePtr->go();
delete mySimplePtr;
}

위 코드는 Simple 객체를 동적으로 할당해서 사용한 후 delete를 호출한다. 메모리를 제대로 했다고 생각할 수 있지만 go() 메서드 안에서 익센셥이 발생하면 delete가 실행되지 않기 때문에 메모리 누수가 발생할 가능성은 남아 있다.

이 코드를 unique_ptr로 구현하면 객체에 대해 delete를 직접 호출하지 않고도 Simple 객체가 자동으로 해제된다.

void notLeaky()
{
auto mySimpleSmartPtr = make_unique<Simple>();
mySimpleSmartPtr->go();
}

위 코드는 C++ 14부터 제공하는 make_unique()와 auto 키워드를 동시에 적용했다. 만일 make_unique()를 지원하지 않는 컴파일러를 사용한다면 다음과 같이 unique_ptr로 생성한다.

unique_ptr<Simple> mySimpleSmartPtr(new Simple());

C++ 17 이전에는 타입을 단 한 번만 지정하기 위해 뿐만 아니라 안전을 위해 반드시 make_unique()를 사용해야 했다. 다음의 함수를 보자.

foo(unique_ptr<Simple>(new Simple()), unque_ptr<Bar>(new Bar(data())));

Simple이나 Bar의 생성자 또는 data() 함수에서 익셉션이 발생하면 Simple이나 Bar 객체에 메모리 누수가 발생할 가능성이 매우 높다. 하지만 make_unique()를 사용하면 누수가 발생하지 않는다.

foo(make_unique<Simple>(), make_unique<Bar>(data()))

unique_ptr 사용 방법

표준 스마트 포인터의 대표적인 장점은 문법을 새로 익히지 않고도 향상된 기능을 누릴 수 있다는 것이다. 스마트 포인터는 일반 포인터와 똑같이 *나 ->로 역참조 한다.

mySimpleSmartPtr->go();

다음과 같이 일반 포인터처럼 작성해도 된다.

(*mySimpleSmartPtr).go();

get() 메서드를 이용하면 내부 포인터에 직접 접근할 수 있다. 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 유용하다. 예컨대 다음과 같은 함수가 있다고 하자.

void processData(Simple* simple) { /* 스마트 포인터를 사용하는 코드 */ }

그러면 이 함수를 다음과 같이 호출할 수 있다.

auto mySimpleSmartPtr = make_unique<Simple>();
processData(mySimpleSmartPtr.get());

reset()을 사용하면 unique_ptr의 내부 포인터를 해제하고 필요하다면 다른 포인터로 변경할 수 있다.

mySimpleSmartPtr.reset();  // 리소스 해제 후 nullptr로 초기화
mySimpleSmartPtr.reset(new Simple()); // 리소스 해제 후 새로운 Simple 인스턴스로 설정

release()를 이용하면 unique_ptr과 내부 포인터의 고나계를 끊을 수 있다. release() 메서드는 리소스에 대한 내부 포인터를 리턴한 뒤 스마트 포인터를 nullptr로 설정한다. 그러면 스마트 포인터는 그 리소스에 대한 소유권을 잃으며, 리소스를 다 쓴 뒤 반드시 직접 해제해야 한다.

Simple* simple = mySimpleSmartPtr.release();  // 소유권을 해제한다.
// simple 포인터를 사용하는 코드
delete simple;
simple = nullptr;

unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. std::move() 유틸리티를 사용하면 하나의 unique_ptr을 다른 곳으로 이동할 수 있는데, 복사라기 보다는 이동의 개념이다. 다음과 같이 소유권을 명시적으로 이전하는 용도로 많이 사용한다.

class Foo
{
public:
Foo(unique_ptr<int> data) : mData(move(data)) { }
private:
unique_ptr<int> mdDta;
}

auto myIntSmartPtr = make_unique<int>(42);
Foo f(move(myIntSmartPtr));

unique_ptr과 C 스타일 배열

unique_ptr은 기존 C 스타일의 동적 할당 배열을 저장하는데 적합하다. 예컨대 정수 10개를 가진 C 스타일의 동적 할당 배열을 다음과 같이 표현할 수 있다.

auto myVariableSizedArray = make_unique<int[]>(10);

이렇게 unique_ptr로 C 스타일의 동적 할당 배열을 저장할 수는 있지만, 이보다는 std::array나 std::vector와 같은 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.

커스텀 제거자

기본적으로 unique_ptr은 new와 delete로 메모리를 할당하거나 해제한다. 하지만 다음과 같이 방식을 변경할 수 있다.

int* malloc_int(int value)
{
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}

int main()
{
unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
return 0;
}

이 코드는 malloc_int()로 정수에 대한 메모리를 할당한다. unique_ptr은 메모리를 표준 free() 함수로 해제한다. 앞서 C++에서는 malloc()이 아니라 new를 사용해야 한다고 했는데, unique_ptr에서 이 기능을 제공하는 이유는 메모리가 아닌 다른 리소스를 관리하기 편리하기 때문이다. 예컨대 파일이나 네트워크 소켓 등을 가리키던 unique_ptr가 스코프를 벗어날 때 이러한 리소스를 자동으로 닫는데 활용할 수 있다.

아쉽게도 unique_ptr로 커스텀 제거자를 작성하는 문법은 좀 지저분하다. 작성하는 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하기 때문이다. 앞의 예에서 free()의 타입을 알아내기 위해 decltype(free)를 사용했다. 템플릿 타입 매개변수는 반드시 함수에 대한 포인터 타입이어야 한다. 그래서 decltype(free)* 와 같이 *를 더 붙였다. shared_ptr로 커스텀 제거자를 작성하는 문장은 이보다 간단하다.

shared_ptr

shared_ptr의 사용법은 unique_ptr과 비슷하다. shared_ptr은 make_shared()로 생성한다.

auto mySimpleSmartPtr = make_shared<Simple>();

C++ 17부터 shared_ptr도 unique_ptr과 마찬가지로 기존 C 스타일 동적 할당 배열에 대한 포인터를 저장할 수 있다. C++ 17 이전에는 이렇게 할 수 없었는데, 설령 C++ 17에서 지원하더라도 여전히 C 스타일 배열보다는 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.

shared_ptr도 unique_ptr처럼 get()과 reset()을 제공한다. 다른 점은 reset()을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr가 제거되거나 리셋될 때 리소스가 해제된다. 참고로 shared_ptr은 release()를 지원하지 않는다. 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알아낼 수 있다.

shared_ptr도 unique_ptr처럼 메모리 할당 및 해제는 new와 delete 연산자를 C++ 17에서 C 스타일 배열을 저장할 때는 new[]나 delete[]를 사용한다. 이러한 동작도 다음과 같이 변경할 수 있다.

shared_ptr<int> myIntSmartPtr(malloc_int(42), free);

여기서 볼 수 있듯이 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하지 않아도 된다. 그래서 unique_ptr에 대해 커스텀 제거자를 작성할 때보다 훨씬 간편하다.

다음 코드는 shared_ptr로 파일 포인터를 저장하는 예를 보여준다. shared_ptr가 리셋되면(여기서는 스코프를 벗어나면) CloseFile()이 호출되면서 파일 포인터가 자동으로 닫힌다. 참고로 C++는 파일을 다루는 객체지향 클래스를 별도로 제공한다. 이 클래스도 파일을 자동으로 닫아준다. 지금 보는 예제는 shared_ptr를 메모리가 아닌 다른 리소스에도 사용할 수 있다는 것만 보여주기 위해 기본 C 함수인 fopen()과 fclose()를 사용했다.

void CloseFile(FILE* filePtr)
{
if (filePtr == nullptr)
return;
fclose(filePtr);
cout << "File closed." << endl;
}

int main()
{
FILE* f = fopen("data.txt", "w");
shared_ptr<FILE> filePtr(f, CloseFile);

if (filePtr == nullptr)
{
cerr << "Error opening file." << endl;
}
else
{
cout << "File opened." << endl;
// filePtr을 사용하는 코드
}

return 0;
}

shared_ptr 캐스팅하기

shared_ptr를 캐스팅하는 함수로 const_pointer_cast(), dynamic_pointer_case(), static_pointer_cast()가 제공된다. C++ 17부터는 reinterpret_pointer_cast()도 추가됐다.

레퍼런스 카운팅이 필요한 이유

레퍼런스 카운팅은 어떤 클래스의 인스턴스 수나 현재 사용 중인 특정한 객체를 추적하는 메커니즘이다. 레퍼런스 카운팅을 지원하는 스마트 포인터는 실제 포인터를 참조하는 스마트 포인터 수를 추적한다. 그래서 스마트 포인터가 중복 삭제되는 것을 방지한다.

중복 삭제 문제는 재현하기 쉽다. 앞서 소개한 Simple 클래스 코드는 객체가 생성되거나 삭제될 때 단순히 화면에 메시지를 출력하기만 했다. 다음과 같이 표준 shared_ptr 두 개를 만들고 각각 하나의 Simple 객체를 가리키도록 작성하면 두 포인터가 제거될 때 서로 Simple 객체를 삭제하려 시도한다.

void doubleDelete()
{
Simple* mySimple = new Simple();
shared_ptr<Simple> smartPtr1(mySimple);
shared_ptr<Simple> smartPtr2(mySimple);
}

사용하는 컴파일러에 따라 프로그램이 죽어버릴 수 있는데, 제대로 실행된다면 다음과 같은 결과가 출력 된다.

Simple constructor called!
Simple destructor called!
Simple destructor called!

생성자는 한 번 호출되고 소멸자는 두 번 호출되는데, unique_ptr로 작성해도 똑같은 문제가 발생한다. 레퍼런스 카운팅을 지원하는 shared_ptr 클래스로도 이런 일이 발생해서 의아할 수 있지만 C++ 표준에 따른 정상적인 동작이다.

이렇게 shared_ptr을 앞에 나온 doubleDelete() 함수처럼 객체 하나를 shared_ptr 두 개로 가리키지 말고 다음과 같이 복사본을 만들어서 사용해야 한다.

void noDoubleDelete()
{
auto smartPtr1 = make_shared<Simple>();
shared_ptr<Simple> smartPtr2(smartPtr1);
}

그러면 다음과 같은 결과가 출력 된다.

Simple constructor called!
Simple destructor called!

shared_ptr 두 개가 한 Simple 객체를 동시에 가리키더라도 Simple 객체는 딱 한 번만 삭제된다. 참고로 unique_ptr는 레퍼런스 카운팅을 지원하지 않는다. 정확히 말하면 unique_ptr은 원래 복제 생성자를 지원하지 않기 때문에 noDoubleDelete() 함수처럼 사용할 수 없다.

앞에서 본 doubleDelete() 함수처럼 코드를 작성하려면 중복 삭제를 방지하는 스마트 포인터를 직접구현해야 한다. 하지만 이때도 역시 표준 smart_ptr 템플릿으로 리소스를 공유하는 것이 바람직하다. 가능하면 doubleDelete() 함수처럼 작성하지 말고 복제 생성자를 사용한다.

앨리어싱

shared_ptr은 앨리어싱(aliasing)을 지원한다. 그래서 한 포인터(소유한 포인터, owned pointer)를 다른 shared_ptr와 공유하면서 다른 객체(저장된 포인터, stored pointer)를 가리킬 수 있다. 예컨대 shared_ptr가 객체를 가리키는 동시에 그 객체의 멤버도 가리키게 할 수 있다. 코드로 표현하면 다음과 같다.

class Foo
{
public:
Foo(int value) : mData(value) {}
int mData;
}

auto foo = make_shared<Foo>(42);
auto aliasing = shared_ptr<int>(foo, &foo->mData);

여기서 두 shared_ptr(foo와 aliasing)가 모두 삭제될 때만 Foo 객체가 삭제된다.

소유한 포인터는 레퍼런스 카운팅에 사용하는 반면, 저장된 포인터는 포인터를 역참조 하거나 그 포인터에 대해 get()을 호출할 때 리턴된다. 저장된 포인터는 비교 연산을 비롯한 대부분의 연산에 적용할 수 있다. 

이렇게 하지 않고 owner_before() 메서드나 std::owner_less 클래스를 사용하여 소유한 포인터에 대해 비교 연산을 수행해도 된다. 이러한 기능은 shared_ptr를 std::set에 저장할 때와 같이 특정한 상황에 유용하다.

weak_ptr

shared_ptr과 관련하여 C++에서 제공하는 또 다른 클래스로 weak_ptr가 있다. weak_ptr는 shared_ptr가 가리키는 리소스의 레퍼런스를 관리하는데 사용된다. weak_prt은 리소스를 직접 소유하지 않기 때문에 shared_ptr가 해당 리소스를 해제하는데 아무런 영향을 미치지 않는다. weak_ptr은 삭제될 때 (예컨대 스코프를 벗어날 때) 가리키던 리소스를 삭제하지 않고, shared_ptr가 그 리소스를 해제 했는지 알아낼 수 있다.

weak_ptr의 생성자는 shared_ptr나 다른 weak_ptr을 인수로 받는다. weak_ptr에 저장된 포인터에 접근하려면 shared_ptr로 변환해야 한다. 변환 방법은 다음 두 가지가 있다.

  • weak_ptr 인스턴스의 lock() 메서드를 이용하여 shared_ptr를 리턴받는다. 이때 shared_ptr에 연결된 weak_ptr가 해제되면 shared_ptr의 값은 nullptr가 된다.
  • shared_ptr의 생성자에 weak_ptr를 인수로 전달해서 shared_ptr을 새로 생성한다. 이때 shared_ptr에 연결된 weak_ptr가 해제되면 std::bad_weak_ptr 익셉션이 발생한다.

weak_ptr을 사용하는 예는 다음과 같다.

void useResource(weak_ptr<Simple>& weakSimple)
{
auto resource = weakSimple.lock();
if (resource)
{
cout << "Resource still alive." << endl;
}
else
{
cout << "Resource has been freed!" << endl;
}
}

int main()
{
auto sharedSimple = make_shared<Simple>();
weak_ptr<Simple> weakSimple(sharedSimple);

// weak_ptr을 사용한다.
useResource(weakSimple);

// shared_ptr을 리셋한다.
// Simple 리소스에 대한 shared_ptr을 하나뿐이므로 weak_ptr가 살아 있더라도 리소스가 해제된다.
sharedSimple.reset();

// weak_ptr을 한 번 더 사용한다.
useResource(weakSimple);

return 0;
}

이 코드를 실행한 결과는 다음과 같다.

Simple constructor called!
Resource still alive.
Simple destructor called!
Resource has been freed!

이동 의미론

표준 스마트 포인터인 shared_ptr, unique_ptr, weak_ptr은 모두 성능 향상을 위해 이동 의미론을 지원한다. 이동 의미론은 9장에서 자세히 소개하고 여기서는 이동 의미론을 이용하면 함수에서 스마트 포인터를 리턴하는 과정을 굉장히 효율적으로 처리할 수 있다는 점만 알아두자. 예컨대 다음과 같이 crate() 함수를 작성해서 main() 함수에서 호출할 수 있다.

unique_ptr<Simple> create()
{
auto ptr = make_unique<Simple>();
// ptr을 사용하는 코드를 작성한다.
return ptr;
}

int main()
{
unique_ptr<Simple> mySmartPtr1 = create();
auto mySmartPtr2 = create();
return 0;
}

enable_shared_from_this

믹스인 클래스인 std::enable_shared_from_this를 이용하면 객체의 메서드에서 shared_ptr나 weak_ptr을 안전하게 리턴할 수 있다. 믹스인 클래스는 28장에서 자세히 설명한다. enable_shared_from_this 믹스인 클래스는 다음 두 개의 메서드를 클래스에 제공한다.

  • shared_from_this(): 객체의 소유권을 공유하는 shared_ptr을 리턴한다.
  • weak_from_this(): 객체의 소유권을 추적하는 weak_ptr를 리턴한다.

이는 고급 기능으로 간단히 사용 방법을 소개하면 다음과 같다.

class Foo : public enable_shared_from_this<Foo>
{
public:
shared_ptr<Foo> getPointer()
{
return shared_from_this();
}
}

int main()
{
auto ptr1 = make_shared<Foo>();
auto ptr2 = ptr1->getPointer();
}

여기서 객체의 포인터가 shared_ptr에 이미 저장된 상태에서만 객체에 shared_from_this()를 사용할 수 있다는 점에 주의해야 한다. 예제의 main()을 보면 make_shared()로 Foo 인스턴스를 담은 shared_ptr인 ptr1을 생성했다. 그리고 나서야 Foo 인스턴스에 대한 shared_from_this()를 호출할 수 있다.

getPointer() 메서드를 다음과 같이 구현하면 안된다.

class Foo
{
public:
shared_ptr<Foo> getPointer()
{
return shared_ptr<Foo>(this);
}
}

Foo 클래스를 이렇게 구현한 상태에서 앞에 나온 main() 코드처럼 작성하면 중복 삭제가 발생한다. 두 개의 shared_ptr(ptr1과 ptr2)가 동일한 객체를 가리키고 있어서 스코프를 벗어나면 서로 이 객체를 삭제하려 하기 때문이다.

현재는 폐기된 auto_ptr

C++ 11 이전에는 표준 라이브러리에서 스마트 포인터를 간단히 구현한 auto_ptr을 제공했는데 몇 가지 심각한 단점이 있었다. 그중 하나는 vector와 같은 표준 라이브러리 컨테이너 안에서 제대로 작동하지 않았다는 점이다.

C++ 11과 C++ 14부터는 auto_ptr를 공식적으로 지원하지 않는다고 선언했고, C++ 17에서 완전히 삭제되면서 그 빈자리를 unique_ptr과 shared_ptr이 대체했다. 여기서 auto_ptr을 소개하는 이유는 절대 사용하면 안 된다는 것을 강조하기 위해서다.

흔히 발생하는 메모리 문제

스트링 과소 할당 문제

C 스타일 스트링에서 가장 흔히 발생하는 문제는 과소 할당(underallocation)이다. 이 문제는 주로 프로그래머가 스트링의 끝을 나타내는 널문자(‘\0’)가 들어갈 공간을 빼먹고 공간을 할당할 때 발생한다. 또한 프로그래머가 스트링의 최대 크기를 특정한 값으로 미리 정해둘 때도 발생한다. C 스타일 스트링 함수는 크기에 제한을 두지 않기 때문에 스트링에 할다오딘 메모리 공간을 얼마든지 넘어갈 수 있다.

(예시 생략)

메모리 경계 침범

포인터는 단지 메모리 주소일 뿐이어서 메모리에서 아무 곳이나 가리킬 수 있다. 실제로 이렇게 아무 곳이나 가리키는 상황이 종종 발생한다. 

만약 어떤 이유로 스트링의 끝읖 료현하는 ‘\0’ 문자가 사라졌다고 하자. 이때 스트링의 모든 문자를 ‘m’으로 바꾸는 함수를 호출하면 로프의 종료 조건을 만족하지 못하기 때문에 스트링에 할당된 공간을 지나서도 계속해서 ‘m’으로 채운다.

void fillWithM(char* inStr)
{
int i = 0;
while (inStr[i] != '\0')
{
inStr[i] = 'm';
i++;
}
}

이 함수에 종료 문자가 잘못된 스트링을 입력하면 결국 메모리에서 중요한 영역까지 덮어써서 프로그램이 뻗어버린다. 프로그램에서 객체에 관련된 메모리 영역이 갑자기 ‘m’으로 채워지면 분명 좋지 않은 상황이 펼쳐질 것이다.

이렇나 문제가 스트링이 아닌 배열에 발생하는 것을 흔히 버퍼 오버플로 에러(buffer overflow error)라 부른다. 지금까지 알려진 악명 높은 바이러스나 웜 중 상당수는 이 버그를 악용해서 경계를 벗어난 메모리 영역을 덮어쓰는 방식으로 현재 구동 중인 프로그램에 악의적인 코드를 주입한 것이다.

현재 나와 있는 메모리 검사 도구는 버퍼 오버플로 문제를 찾아준다. 또한 string이나 vector와 같은 C++ 고급 기능을 활용하면 C 스타일 스트링이나 배열을 사용할 때 흔히 발생하던 여러 버그를 방지할 수 있다.

메모리 누수

메모리 누수 문제는 C/C++ 프로그래밍 과정에서 발견하거나 해결하기 가장 힘든 작업으로 손꼽힌다. 원하는 결과를 내도록 힘들여 만든 프로그램이 실행될수록 메모리 공간을 잡아먹는다면 메모리 누수 현상이 발생한 것이다. 이럴 때는 가장 먼저 스마트 포인터를 도입하는 것이 좋다.

(이하 예시 및 관련 도구 설명 생략. MS의 비주얼 C++을 사용한다면 디버그 라이브러리에서 기본으로 제공하는 메모리 누수 감지 기능을 사용할 수 있고, 리눅스라면 밸그라인드라는 오픈소스 도구를 사용할 수 있다.)

중복 삭제와 잘못된 포인터

delete로 포인터에 할당된 메모리를 해제하면 그 메모리를 프로그램의 다른 부분에서 사용할 수 있다. 하지만 그 포인터를 계속 쓰는 것을 막을 수는 없다. 이를 댕글링 포인터(dangling pointer)라 부른다. 이때 중복 삭제하면 문제가 발생하는데, 한 포인터에 delete를 두 번 적용하면 이미 다른 객체를 할당한 메모리를 해제해 버리기 때문이다.

중복 삭제 문제와 해제한 메모리를 다시 사용하는 문제를 사전에 찾아내기란 굉장히 힘들다. 짧은 시간 동안 메모리를 삭제하는 연산이 두 번 실행되면 그 사이에 같은 메모리를 재사용할 가능성이 적기 때문에 프로그램이 계속해서 정상ㅈ거으로 실행될 수 있다. 마찬가지로 객체를 삭제한 직후 곧바로 다시 사용하더라도 그 영역이 삭제 전 상태로 계속 남아 있을 가능성이 많기 때문에 문제가 생기지 않을 수 있다.

그렇다 하더라도 문제가 발생하지 않는다고 장담할 수는 없다. 메모리를 할당할 때 삭제된 객체를 보존하지 않기 때문이다. 설령 제대로 작동하더라도 삭제된 객체를 이용하는 것은 바람직한 코드 작성 방식이 아니다.

MS의 비주얼 C++이나 밸그라인드 같은 도구가 해제된 객체를 계속 사용하는 문제를 감지하는 기능도 제공한다.

스마트 포인터를 사용하라는 충고를 무시하고 계속해서 일반 포인터를 사용하려면 메모리르 해제한 후 포인터 값을 nullptr로 초기화하는 작업만이라도 반드시 기억하기 바란다. 그러면 실수로 같은 포인터를 두 번 삭제하거나 해제한 포인터를 계속 사용하는 문제를 막을 수 있다. 참고로 nullptr로 설정된 포인터에 대해 delete를 호출해도 문제가 발생하지 않는다. 그저 아무 일도 하지 않을 뿐이다.

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.