본문 바로가기
프로그래밍/C & C++

[C++] 메모리 관리 (1) - 동적 메모리, 배열과 포인터

by 별준 2022. 2. 7.

References

Contents

  • 동적 메모리(Dynamic Memory) 다루기
  • 배열과 포인터 비교
  • low-level 메모리 연산

C++은 C와 마찬가지로 최대한의 자유를 보장하며, 우리가 무엇을 하고 있는지 잘 알고 있다고 가정합니다. C++ 언어 자체는 매우 유연하며, 안전성이 떨어지는 것을 감수하면서 성능을 추구하기 때문에 심각한 문제가 발생할 가능성이 있는 작업을 할 수도 있습니다.

 

메모리 할당(allocation)과 관리(management)는 C++ 프로그래밍에서도 특히 문제가 발생하기 쉬운 부분입니다. 그래서 높은 수준의 프로그램을 작성하려면 메모리 관리의 내부 작동 방식을 확실히 이해하고 있어야 하는데, 이번 포스팅에서는 C++에서의 메모리 관리에 대해 꽤 자세히 살펴보고자 합니다. 또한, 동적 메모리를 다루면서 어떤 위험에 빠지기 쉬운지, 그리고 이러한 상황을 해결하거나 방지하기 위한 기법에는 어떤 것들이 있는지 알아볼 예정입니다.

 


1. 동적 메모리 다루기

1.1 메모리의 작동 과정

int i { 7 };

예를 들어, 위의 코드를 실행한 후의 메모리 상태를 표현하면 다음과 같이 표현할 수 있습니다.

이러한 로컬 변수 i를 자동 변수(automatic variable)이라고 하며, 스택(stack) 메모리에 할당됩니다. 프로그램이 실행 흐름이 이 변수가 선언된 스코프를 벗어나면 할당된 메모리가 자동으로 해제됩니다.

 

new 키워드를 사용하면, 메모리는 free store에 할당됩니다.

Free Store는 프로그램이 실행하는 동안 동적 할당을 위해 사용하는 할당되지 않은 힙(heap) 메모리 풀이라고 생각하면 될 것 같습니다. new/delete 연산자를 통해 할당되는 메모리 영역입니다.
C++ 표준 wiki에서는 개념적으로 malloc과 new는 서로 다른 heap에 메모리를 할당한다고 하며, 이들은 서로 다른 level 에서 동작한다고 합니다(raw memory vs. constructred objects).

-reference
https://isocpp.org/wiki/faq/freestore-mgmt#mixing-malloc-and-delete
https://isocpp.org/wiki/faq/freestore-mgmt

아래 코드는 ptr 변수를 스택에 생성하고 nullptr로 초기화합니다. 그 후에 free store에 메모리를 할당합니다.

int* ptr { nullptr };
ptr = new int;

다음과 같이 한 줄에 작성할 수도 있습니다.

int* ptr { new int };

아래 그림은 위 코드를 실행한 뒤의 메모리 상태를 보여줍니다.

여기서 ptr 변수는 여전히 스택에 존재하지만, 이 변수가 가리키는 값은 free store에 있습니다. 포인터 역시 일종의 변수이기 때문에 스택이나 힙에 존재하는데, 이 사실을 까먹기 쉽습니다. 반면 동적 메모리는 항상 free store에 할당됩니다.

 

다음 코드는 포인터가 스택과 free store에 모두 있는 예제를 보여줍니다.

int** handle { nullptr };
handle = new int*;
*handle = new int;

여기서 먼저 정수 포인터에 대한 포인터를 handle이라는 변수로 선언했습니다. 그런 다음 정수 포인터를 담는 충분한 크기로 메모리르 할당한 뒤 그 메모리에 대한 포인터를 handle에 저장합니다. 그리고 이 메모리(*handle)에 정수를 담기 충분한 크기의 메모리를 동적으로 할당합니다. 아래 그림처럼 두 포인터 중의 하나(handle)는 스택에, 다른 하나(*handle)은 free store에 있도록 구현한 2-level의 포인터를 보여줍니다.

1.2 Allocation and Deallocation

변수를 위한 공간은 new 키워드로 생성합니다. 다 사용한 공간은 프로그램의 다른 영역에서 사용할 수 있도록 delete 키워드를 사용하여 해제합니다. 물론 간단하지는 않습니다.

 

1.2.1 new와 delete 사용 방법

변수에 필요한 메모리 블록을 할당하려면 그 변수의 타입과 함께 new를 호출합니다. 그러면 new는 할당된 메모리에 대한 포인터를 리턴합니다. 물론 이 포인터를 변수에 저장하는 등 관리하는 작업은 프로그래머의 몫입니다. 만약 new의 리턴값을 무시하거나 포인터 변수가 스코프를 벗어나면 할당했던 메모리에 접근할 수 없습니다. 이를 메모리 누수(memory leak)이라고 합니다.

 

다음 코드는 int를 담을 공간만큼의 메모리 누수가 발생하는 예시를 보여줍니다.

void leaky()
{
  new int; // BUG! leaks memory!
  std::cout << "I just leaked an int!" << std::endl;
}

다음 그림은 위 코드를 실행한 뒤의 메모리 상태를 보여줍니다. 스택에서 직접적으로든 간접적으로든 더 이상 접근할 수 없는 메모리 블록이 free store에 생기면 메모리 누수가 발생합니다.

 

빠른 메모리를 무한히 공급하지 않는 이상, 객체에 할당했던 메모리를 다른 용도로 사용할 수 있도록 해제해주어야 합니다. 메모리를 해제하려면 다음과 같이 delete 키워드에 해제할 메모리를 가리키는 포인터를 지정해주어야 합니다.

int* ptr { new int };
delete ptr;
ptr = nullptr;
new로 메모리를 할당할 때 스마트 포인터가 아닌 일반 포인터로 저장했다면 반드시 그 메모리를 해제하는 delete를 new와 짝을 이루도록 작성해야 합니다.
또한, 메모리를 해제한 포인터는 nullptr로 다시 초기화합니다. 그래야 이미 해제된 메모리를 가리키는 포인터를 모르고 다시 사용하는 실수를 방지합니다.

 

1.2.2 malloc()

C 프로그래머라면 malloc() 함수는 사용하지 않는지 궁금할 수 있습니다. C에서 malloc()은 인수로 지정한 바이트 수만큼의 메모리를 할당합니다. 일반적으로 malloc()을 사용하는 것이 간단하고 이해하기도 쉽습니다. C++에서도 여전히 malloc()을 지원하지만, malloc() 대신 new를 사용하는 것이 바람직합니다. new는 단순히 메모리를 할당하는 것뿐만 아니라 객체까지 생성합니다.

 

예를 들어, 다음과 같이 Foo라는 클래스의 객체를 생성하는 코드를 살펴보도록 하겠습니다.

Foo* myFoo { (Foo*)malloc(sizeof(Foo)) };
Foo* myOtherFoo { new Foo() };

위 문장들을 실행하면 myFoo와 myOtherFoo는 Foo 객체를 저장하기에 충분한 메모리 공간을 가리킵니다. Foo의 데이터 멤버와 메소드는 이 두 포인터로 액세스할 수 있는데, myFoo가 가리키는 객체는 생성자가 호출되지 않았기 때문에 정상적인 객체라고 볼 수 없습니다. malloc() 함수는 오직 특정 사이즈의 메모리만 할당할 뿐 객체에 대해 알지도 못하고 관심도 없습니다. 반면 new를 호출한 문장은 적절한 크기의 메모리 공간이 할당될 뿐만 아니라 Foo의 생성자를 호출해서 객체를 생성합니다.

 

free() 함수와 delete의 관계도 이와 비슷합니다. free()는 객체의 소멸자를 호출하지 않는 반면 delete는 소멸자를 호출해서 객체를 정상적으로 제거합니다. C++에서는 malloc()과 free()를 절대 사용하지 말고 new와 delete만 사용하는 것을 추천합니다.

 

1.2.3 메모리 할당에 실패한 경우

대부분은 아니지만 많은 프로그래머들은 new가 항상 성공적으로 메모리를 할당할 것이라고 가정합니다. 이렇게 생각하는 이유는 메모리가 부족하고 상황이 매우 매우 좋지 않을 때만 new가 실패하기 ㄸ패문입니다. 이런 상황에서 프로그램의 동작을 예측할 수 없기 때문에 정확한 상태를 가늠하기가 힘들 때가 많습니다.

 

기본적으로 new가 실패하면 예외(exception)가 발생합니다. 만약 예외를 캐치하지 않는다면 프로그램은 종료됩니다. 많은 프로그램에서 이러한 동작은 합리적인데, 이 부분에 대한 자세한 내용은 나중에 에러 핸들링과 관련한 포스팅에서 다루어보도록 하겠습니다.

 

예외가 발생하지 않는 버전의 new도 있습니다. 이 버전의 new는 할당이 실패하면 nullptr을 리턴합니다. C의 malloc()과 유사하며, 문법은 다음과 같습니다.

int* ptr = new(nothrow) int; // { new(nothrow) int }

예외를 던지는 new와 마찬가지로 메모리가 부족해서 nullptr을 리턴하더라도 이 상황을 처리해주긴 해야합니다. 물론 이를 검사하는 코드를 작성하지 않아도 컴파일하는 데는 문제가 없습니다. 따라서 예외를 던지는 버전보다 nothrow 버전을 사용할 때 버그가 발생할 확률이 높으며 이러한 이유로 표준 버전의 new를 사용하는 것이 바람직합니다.

 

1.3 배열 Array

배열은 서로 타입이 같은 원소들을 변수 하나에 담아서 각 원소를 인덱스로 액세스할 수 있도록 해줍니다. 배열은 번호가 붙은 칸막이에 값을 넣는다고 생각하면 이해하기 쉬우며, 실제로 내부적으로 메모리에 배열을 저장하는 방식도 이와 크게 다르지 않습니다.

 

1.3.1 기본 타입 배열 Arrays of Primitive Types

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

int myArray[5];

각각의 원소는 초기화가 되지 않습니다. 즉, 메모리 공간에 있던 값을 가지게 됩니다. 스택에 배열이 생성될 때, 배열의 크기는 반드시 상수값이어야 하며, 컴파일 타임에 알고 있는 값이어야 합니다.

어떤 컴파일러는 스택에 가변 사이즈의 배열을 허용합니다. 이는 C++ 표준 특징이 아닙니다.

 

스택에 배열을 생성할 때, initializer list를 사용하여 초기 원소들을 제공할 수 있습니다.

int myArray[5] { 1, 2, 3, 4, 5 };

만약 initializer list가 배열의 크기보다 더 적은 원소들로 구성되면, 남은 원소들은 0으로 초기화됩니다.

int myArray[5] { 1, 2 }; // 1, 2, 0, 0, 0

이를 사용해 모든 원소를 0으로 초기화할 수 있습니다.

int myArray[5] { 0 }; // 0, 0, 0, 0, 0

 

배열을 free store에 할당하는 것도 비슷하며, 배열의 위치를 가리키는 포인터를 사용한다는 것만 다릅니다. 다음 코드는 int 값 다섯 개를 담는 배열에 메모리를 할당해서 그 메모리 공간을 가리키는 포인터를 myArrayPtr 변수에 저장합니다.

int* myArrayPtr { new int[5] };

free store에 저장한 배열은 원소가 저장된 위치만 다를 뿐 스택에 저장한 배열과 유사합니다. myArrayPtr 변수는 배열의 0번째 원소를 가리킵니다.

 

new 연산자처럼 new[]도 nothrow 인수를 받을 수 있으며 만약 할당이 실패하면 예외를 던지는 것이 아닌 nullptr을 리턴합니다.

int* myArrayPtr { new(nothrow) int[5] };

free store에 동적으로 생성된 배열 또한 initializer list를 사용하여 초기화할 수 있습니다.

int* myArrayPtr { new int[] { 1, 2, 3, 4, 5 } };

 

new[]를 호출한 만큼 delete[]를 호출해서 배열에 할당했던 메모리를 해제하도록 코드를 작성해주어야 합니다.

delete[] myArrayPtr;
myArrayPtr = nullptr;

 

free store에 배열을 할당하면, 런타임에 배열의 크기를 결정할 수 있다는 장점이 있습니다. 예를 들어, 다음 코드는 askUserForNumberOfDocuments()란 함수로부터 받은 문서 수만큼의 크기를 가진 Document 객체 배열을 생성합니다.

Document* createDocArray()
{
  size_t numDocs = askUserForNumberOfDocuments();
  Document* docArray = new Document[numDocs];
  
  return docArray;
}

여기서 명심해야되는 것은 new[]를 호출한 만큼 delete[]도 호출해주어야 한다는 것입니다. 따라서 이 예제에서 createDocArray()를 호출한 측에서 배열을 다 쓰고 나면 delete[]를 호출해서 리턴받은 메모리를 해제해주어야 합니다. C 스타일의 배열을 사용할 때 발생할 수 있는 또 다른 문제는 정확한 크기를 알 수 없다는 것입니다. 따라서 리턴된 배열에 담긴 원소 수를 createDocArray()를 호출한 측에서는 알 수 없습니다.

 

위의 코드에서 docArray는 동적으로 할당된 배열입니다. 이는 동적 배열(dynamic array)와는 다릅니다. 배열 자체는 한 번 할당되면 크기가 변하지 않기 때문에 동적이라고 볼 수 없습니다. 단지 할당할 블록의 크기를 런타임에 지정할 수 있을 뿐입니다. 더 많은 데이터를 추가하도록 크기를 조절할 수는 없습니다.

 

C++에서는 realloc()이라는 함수도 지원합니다. 이 함수 역시 C언어로부터 물려받은 함수이며, 따라서 사용하지 않는 것을 권장합니다. C에서 realloc()은 새로 지정한 크기에 맞게 메모리 블록을 새로 할당하는 방식으로 배열의 크기를 동적으로 조절합니다. 이 과정에서 기존 데이터를 새 블록에 복사하고, 원래 블록은 삭제합니다. C++에서 이렇게 처리하면 굉장히 위험한데, 사용자가 정의한 객체는 비트 단위 복사 작업에 알맞지 않기 때문입니다.

 

1.3.2 객체 배열 Arrays of Objects

객체에 대한 배열도 기본 타입 배열과 비슷합니다. N개의 객체로 구성된 배열을 new[N]으로 할당하면 객체를 담기에 충분한 크기의 N개의 블록이 연속된 공간에 할당됩니다. new[]를 호출하면 각 객체마다 zero-argument(=default) 생성자가 호출됩니다. 그래서 new[]로 객체 배열을 할당하면 형식에 맞게 초기화된 객체 배열을 가리키는 포인터가 리턴됩니다.

 

예를 들어 다음 클래스를 살펴보겠습니다.

class Simple
{
public:
  Simple() { std::cout << "Simple constructor called!" << std::endl; }
  ~Simple() { std::cout << "Simple destructor called!" << std::endl; }
};

4개의 Simple 객체로 구성된 배열을 할당하면 위 Simple 생성자가 4번 호출됩니다.

Simple* mySimpleArray { new Simple[4] };

 

1.3.3 배열 삭제

앞서 설명했듯이 배열에 대한 메모리를 new[]로 할당하면 반드시 delete[]를 호출해서 메모리를 해제해주어야 합니다. delete[]를 호출하면 할당된 메모리를 해제할 뿐만 아니라 각 원소의 객체마다 소멸자를 호출합니다.

Simple* mySimpleArray { new Simple[4] };
// using mySimpleArray
delete[] mySimpleArray;
mySimpleArray = nullptr;

delete[]가 아닌 delete를 사용하면 프로그램이 이상하게 동작할 수 있습니다. 어떤 컴파일러는 객체를 가리키는 포인터만 삭제한다고 여기고 배열의 첫 번째 원소에 대한 소멸자만 호출하는데, 그러면 배열의 나머지 원소에 접근할 수 없게 됩니다. 또 어떤 컴파일러는 new와 new[]에 대한 메모리 할당 방식이 서로 달라서 메모리 손상(memory corruption)이 발생하기도 합니다.

 

물론 소멸자는 배열의 원소가 객체일 때만 호출됩니다. 포인터 배열에 대해 delete[]를 호출할 때는 각 원소가 가리키는 객체를 일일이 해제해주어야 합니다. 예를 들면 다음과 같습니다.

const size_t size{ 4 };
Simple** mySimplePtrArray{ new Simple*[size] };

// Allocate an object for each pointer
for (size_t i{ 0 }; i < size; i++)
  mySimplePtrArray[i] = new Simple();

// Use mySimplePtrArray...

// Delete each allocated object
for (size_t i{ 0 }; i < size; i++) {
  delete mySimplePtrArray[i];
  mySimplePtrArray[i] = nullptr;
}

// Delete the array itself
delete[] mySimplePtrArray;
mySimplePtrArray = nullptr;
C++ 최신 버전에서는 C 스타일의 포인터를 잘 사용하지 않습니다. C 스타일 배열에 일반 포인터를 저장하지 말고 최신 표준 라이브러리 컨테이너에 스마트 포인터를 저장하는 방식으로 구현합니다. 추후에 살펴보겠지만 스마트 포인터는 할당된 메모리를 적절한 시점에 알아서 해제해줍니다.

 

1.3.4 다차원 배열 Multidimensional Arrays

다차원 배열은 여러 개의 인덱스 값을 사용하도록 일차원 배열을 확장한 것입니다. 예를 들어, tic-tac-toe 게임은 2차원 배열을 사용하여 3x3 격자를 표현할 수 있습니다. 다음 코드는 3x3 격자를 위한 배열을 스택에 선언하고, 0으로 초기화한 뒤에 테스트 코드로 액세스하는 것을 보여줍니다.

char board[3][3] {};
// Test code
board[0][0] = 'X';
board[2][1] = 'O';

여기서 2차원 배열의 첫 번째 서브스크립트가 x축인지 y축인지 헷갈릴 수 있습니다. 사실 일관성만 유지한다면 그 순서는 상관이 없습니다. 4x7 격자를 char board[4][7]로 선언해도 되고, char board[7][4]로 선언해도 됩니다. 물론 대부분의 경우, 이해하기 쉽도록 첫 번째 서브스크립트를 x축으로하고, 두 번째를 y축으로 자주 사용합니다.

 

스택에 생성한 2차원 배열 board는 다음과 같습니다.

실제로 메모리는 2개의 축으로 구성되지 않기 때문에 일차원 배열처럼 나열됩니다. 실제로 이차원 배열은 내부적으로 일차원 배열처럼 표현되며, 배열의 크기와 이를 접근하는 방법만 다릅니다.

 

다차원 배열의 크기는 각 차원의 크기를 서로 곱한 값에 한 원소의 메모리 크기를 곱한 값과 같습니다. 위 그림에서 나온 3x3 보드를 표현한 배열의 크기는 3x3x1(byte) = 9 bytes 입니다. (char가 1바이트이기 때문)

4x7 보드를 표현한 문자 배열의 크기는 4x7x1 = 28 bytes가 됩니다.

 

다차원 배열의 한 원소에 액세스할 때 각 인덱스는 다차원 배열에 속한 하위 배열에 접근할 인덱스로 사용합니다. 예를 들어, 3x3 격자를 표현하는 배열에서 board[0]은 실제로 아래 왼쪽 그림에 회색으로 표시한 하위 배열을 가리킵니다. 여기서 두 번째 인덱스(board[0][2])를 지정하면 아래 오른쪽 그림에서 표시된 board[0] 하위 배열에서 인덱스 2에 해당하는 원소를 가리킵니다.

이와 같은 방식으로 N차원 배열을 처리합니다.

 

 

다차원 배열의 차원을 런타임에 결정하고 싶다면 free store의 배열로 생성합니다. 동적으로 할당하는 일차원 배열을 포인터로 접근하듯이 동적으로 할당하는 다차원 배열도 포인터로 접근할 수 있습니다. 단지 2차원 배열의 경우 포인터에 대한 포인터로 원소에 접근하는 반면 N차원 배열은 N단계의 포인터로 접근한다는 점만 다릅니다.

얼핏 생각하면 다차원 배열을 동적으로 할당할 때, 다음과 같이 작성해야 한다고 생각하기 쉽습니다.

char** board = new char[i][j]; // BUG! Doesn't compile

free store에 대한 메모리 할당 방식은 스택 배열과 다르기 때문이 위와 같이 작성하면 컴파일 에러가 발생합니다. free store에서는 메모리가 연속적이지 않습니다. 대신, 첫 번째 서브스크립트(인덱스)에 대한 배열을 연속적인 공간에 먼저 할당합니다. 그런 다음 이 배열의 각 원소에 두 번째 인덱스에 해당하는 차원의 배열을 가리키는 포인터를 저장합니다. 아래 그림은 2x2 보드의 배열을 동적으로 할당하는 예를 보여줍니다.

아쉽게도 여기서 하위 배열을 할당하는 작업은 컴파일러에서 자동으로 처리할 수 없습니다. 첫 번째 차원의 배열이 가리키는 각 원소(하위 배열)에 대한 메모리는 마치 일차원 free store 배열을 할당하는 것처럼 직접 하나씩 할당해주어야 합니다. 다음 코드는 이차원 배열을 동적으로 할당하는 예를 보여줍니다.

char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
  char** myArray { new char*[xDimension] }; // allocate first dimension
  for (size_t i{ 0 }; i < xDimension; i++ {
    myArray[i] = new char[yDimension];      // allocate ith subarray
  }
  
  return myArray;
}

비슷하게, free-store에 할당된 메모리를 해제할 때도 마찬가지로 delete[]로 하위 배열의 메모리를 해제할 수 없기 때문에 일일히 해제해주어야 합니다. 다차원 free store 배열을 해제하는 코드는 할당하는 코드와 연산을 반대로 수행할 뿐 형태는 비슷합니다.

char** releaseCharacterBoard(char** myArray, size_t xDimension)
{
  for (size_t i{ 0 }; i < xDimension; i++ {
    delete[] myArray[i]; // Delete ith subarray
  }
  delete[] myArray;      // Delete first dimension
  myArray = nullptr;
}

 

지금까지 배열을 다루는 방법에 관련된 거의 모든 사항들을 살펴봤습니다. 기존 C 스타일 배열은 메모리 안정성이 떨어지므로 가급적이면 사용하지 않는 편이 좋습니다. 기존에 이렇게 작성되어 있는 것은 어쩔 수 없지만, 새로 코드를 작성할 때는 std::array나 std::vector와 같은 C++ 표준 라이브러리에서 제공하는 컨테이너를 사용하는 것이 좋습니다. 

 

1.4 포인터 다루기

포인터는 남용하기 쉬운 기능입니다. 포인터는 단지 메모리 주소이기 때문에 이론상 그 주소를 얼마든지 변경할 수 있고, 심지어 다음과 같이 위험한 작업도 수행할 수 있습니다.

char* scaryPointer { (char*)7 };

위 코드는 메모리 주소 7에 대한 포인터를 생성합니다. 이 포인터는 어떤 값을 가리키거나 어플리케이션의 다른 영역에서 사용하는 공간일 가능성이 높습니다. new를 호출하거나 스택에 생성된 것처럼 별도로 할당된 영역이 아닌 메모리 공간을 사용하면 객체를 저장하거나 free store에서 관리하는 메모리가 손상되어 프로그램이 제대로 작동하지 않을 수 있습니다. 예를 들어, 데이터가 손상되어 잘못된 결과를 나타내거나, 존재하지 않거나 금지된 메모리에 액세스하여 하드웨어 예외가 발생할 수 있습니다. OS나 C++ 런타임 라이브러리에 의해 프로그램이 중단될 정도로 에러가 발생하면 다행이지만, 오히려 별다른 반응없이 잘못된 값을 사용할 때 더 심각한 문제가 발생할 수 있습니다.

 

1.4.1 포인터의 작동 방식

포인터는 두 가지 관점으로 이해할 수 있습니다.

수학적 사고에 익숙하다면 포인터를 주소로 봅니다. 포인터는 메모리를 알 수 없는 방식으로 돌아다니는 통로가 아니며, 메모리의 한 지점을 가리키는 숫자에 불과합니다. 아래 그림은 2x2 격자가 메모리 주소로 어떻게 표현되는지 보여줍니다.

공간적 사고에 익숙한 사람은 포인터를 화살표로 생각하면 이해하기 쉽습니다. 포인터는 손가락으로 가리키는 것처럼 참초 단계를 표현합니다. 이 관점에서 보면 여러 단계로 구성된 포인터에서 각 단계는 데이터에 이르는 경로라고 볼 수 있습니다. 위에서 살펴봤던 아래 그림이 바로 메모리에 있는 포인터를 공간적 개념으로 표현한 것입니다.

 

'*' 연산자로 포인터를 역참조(dereference)하면 메모리에서 한 단계 더 들어가 볼 수 있습니다. 주소 관점으로 바라보면 역참조는 포인터가 가리키는 주소로 점프하는 것과 같습니다. 공간적 관점으로 바라보면 출발 지점에서 목적지로 향하는 화살표로 나타낼 수 있습니다.

 

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

 

1.4.2 포인터에 대한 타입 캐스팅

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

Document* documentPtr { getDocument() };
char* myCharPtr { (char*)documentPtr };

 

정적 캐스팅(static cast)를 사용하면 좀 더 안전합니다. 이는 사용하면 관련없는 데이터 타입으로 포인터를 캐스팅할 때 컴파일 에러가 발생합니다.

Document* documentPtr { getDocument() };
char* myCharPtr { static_cast<char*>(documentPtr) }; // compile error !

 

만약 정적으로 캐스팅하려는 포인터와 캐스팅 결과에 대한 포인터가 가리키는 객체가 서로 상속 관계에 있다면 컴파일 에러가 발생하지 않습니다. 하지만 상속 관계에 있는 대상끼리 캐스팅할 때는 동적 캐스팅(dynamic cast)을 사용하는 것이 더 안전합니다. 상속과 C++ 스타일의 캐스팅 방법은 후에 다른 포스팅으로 알아보도록 하겠습니다.

 


2. 배열과 포인터 비교

배열과 포인터는 비슷합니다. free store에 할당된 배열은 첫 번째 원소를 가리키는 포인터로 참조합니다. 스택에 할당된 배열은 배열 문법([])으로 참조합니다. 이것만 빼면 일반 변수 선언과 같습니다. 하지만 조금 더 자세히 살펴보면 포인터와 배열의 공통점은 더 있으며, 둘 사이의 관계는 조금 복잡하게 얽혀 있습니다.

 

2.1 Arrays are Pointers

free store에 할당되는 배열을 참조할 때만 포인터를 사용하는 것이 아닙니다. 스택에 할당된 배열도 포인터를 사용할 수 있습니다. 배열의 주소는 사실 인덱스가 0인 첫 번째 원소에 대한 주소입니다. 컴파일러는 배열의 변수 이름을 보고 배열 전체를 가리킨다고 생각할 수 있지만, 실제로는 배열의 첫 번째 원소에 대한 주소만을 가리킬 뿐입니다. 그래서 free store 배열과 똑같은 방식으로 포인터를 사용할 수 있습니다. 다음 코드는 0으로 초기화한 스택 배열을 만들고 포인터로 접근하는 예를 보여줍니다.

int myIntArray[10] {};
int* myIntPtr { myIntArray };
// access the array through the pointer
myIntPtr[4] = 5;

스택 배열을 포인터로 참조하는 기능은 배열을 함수의 파라미터로 넘길 때 특히 유용합니다.

다음 함수는 배열을 포인터로 전달받습니다. 여기서 함수를 호출할 때 배열의 크기를 지정해야 합니다. 포인터만으로는 배열의 크기를 알 수 없기 때문입니다. 사실 C++에서 배열은 원소의 타입이 포인터가 아니더라도 크기 정보를 다루지 않습니다. 이러한 이유가 표준 라이브러리에서 제공하는 컨테이너를 사용해야 하는 또 다른 이유이기도 합니다.

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

이 함수를 호출할 때 스택 배열을 전달해도 되고, free store 배열을 전달해도 됩니다. free store 배열을 전달하면 이미 포인터가 존재하기 때문에 함수에 값에 의한 전달(passed by value)을 수행합니다. 스택 배열을 전달하면 배열 변수를 전달하기 때문에 컴파일러가 이를 배열에 대한 포인터로 변환합니다. 이때 프로그래머가 직접 첫 번째 원소의 주소를 넘겨주어도 됩니다.

위의 3가지 경우를 코드로 표현하면 다음과 같습니다.

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

int stackArray[] = { 5, 7, 9, 11 };
arrSize = std::size(stackArray);                         // Since C++17, requires <array>
// arrSize = sizeof(stackArray) / sizeof(stackArray[0]); // Pre C++17
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);

배열을 매개변수로 전달하는 과정은 포인터를 매개변수로 전달하는 것과 유사합니다. 컴파일러는 배열을 함수로 전달할 때 포인터처럼 취급합니다. 인수로 배열을 받은 함수는 값을 변경할 때 복사본이 아닌 원본을 직접 수정합니다. 즉, 포인터와 마찬가지로 배열을 전달하면 pass-by-reference의 효과가 나타납니다.

따라서, 아래의 doubleInt() 함수는 포인터가 아닌 배열을 매개변수로 받더라도 원본 배열이 변경되는 것을 보여줍니다.

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

컴파일러는 이 함수의 프로토타입에서 theArray 뒤의 대괄호 사이에 나온 숫자를 무시합니다. 따라서, 아래의 3가지 방식으로 표현한 문장은 모두 같습니다.

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

 

함수 정의에서 배열 문법을 사용하면 컴파일러가 그 배열을 왜 복사하지 않는지 이유가 궁금할 수 있습니다. 이렇게 하지 않는 이유는 효율과 성능 때문입니다. 배열에 담긴 원소를 모두 복사하는 것은 시간이 오래 걸릴 뿐만 아니라 메모리 공간도 상당히 차지합니다.

 

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

예를 들면, 아래 doubleIntsStack()은 크기가 4인 스택 배열만 인수로 받습니다.

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

나중에 템플릿에 대해 알아볼 때 자세히 알아보겠지만, 함수 템플릿을 사용하면 스택 배열의 크기를 컴파일러가 알아낼 수 있습니다.

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

 

2.2 Not All Pointers Are Arrays

위에서 본 doubleInts()처럼 함수를 호출할 때 포인터 자리에 배열을 넣어도 된다고 해서 포인터와 배열이 같다고 생각하면 안됩니다. 사실 미묘하지만 중요한 차이가 있습니다. 포인터와 배열은 비슷한 점이 많아서 앞서 본 예제처럼 바꿔 써도 되지만, 그렇다고 똑같지는 않습니다.

 

포인터 자체는 의미가 없습니다. 임의의 메로리를 가리킬 수도 있고 객체나 배열을 가리킬 수도 있습니다. 언제든지 포인터에 배열 문법을 적용해도 되지만, 실제로 포인터는 배열이 아니기 때문에 부적절한 경우도 있습니다.

예를 들면, 다음과 같습니다.

int* ptr = new int;

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

 

모든 배열은 포인터로 참조할 수는 있지만, 모든 포인터가 배열은 아닙니다.

 


3. low-level 메모리 연산

C보다 C++이 훨씬 좋은 점 중 하나는 메모리에 신경을 덜 써도 될다는 것입니다. 객체를 이용할 때는 메모리 관리를 클래스 단위로만 신경쓰면 됩니다. 생성자와 소멸자를 통해 메모리 관리 작업을 해야할 시점만 알려주면 나머지 작업은 컴파일러가 도와줍니다. 이렇게 메모리 관리 작업을 클래스 단위로 숨기면 사용성이 크게 높아집니다. 표준 라이브러리에서 제공하는 클래스만 보더라도 쉽게 알 수 있습니다. 다만, 특정 어플리케이션이나 레거시 코드에서 메모리를 low-level로 다루어야 할 때가 있습니다. 레거시 코드, 성능 향상, 디버깅 여러가지 이유가 있겠지만, 메모리를 low-level로 관리하는 테크닉을 알아두면 여러모로 도움이 됩니다.

 

3.1 포인터 연산 Pointer Arithmetic

C++ 컴파일러는 포인터 연산을 수행할 때 포인터에 선언된 타입을 이용합니다. 포인터를 int로 선언하고 그 값을 1만큼 증가시키면, 포인터는 메모리에서 한 바이트가 아닌 int 크기만큼 이동합니다. 이 연산은 주로 배열을 다룰 때 유용한데, 배열에 담긴 데이터는 모두 타입이 같을 뿐만 아니라 메모리에 연속적으로 위치하고 있기 때문입니다.

예를 들어, 다음과 같이 int 타입의 free store 배열을 선언한 경우를 살펴보겠습니다.

int* myArray = new int[8];

이 배열의 인덱스 2에 해당하는 지점에 값을 넣는 방법은 다음과 같습니다.

myArray[2] = 33;

방금 작성한 코드는 포인터 연산으로 표현할 수도 있습니다. 다시 말해 myArray가 시작하는 지점에서 int 값이 담긴 칸을 두 번 건너뛰어 그곳에 담긴 값을 역참조하면 됩니다.

*(myArray + 2) = 33;

각 원소에 액세스하기 위한 방법으로 쓰기에는 위와 같은 포인터 연산이 깔끔하지는 않습니다.

포인터 연산의 강점은 myArray + 2와 같은 표현식으로 포인터를 표현하고, 이를 사용해 더 작은 int 배열을 표현할 수 있다는데 있습니다. 다음과 같은 wide string을 살펴보겠습니다. 여기서 wide string은 간단히 유니코드 문자를 지원해서 한국어와 같은 다국어를 표현할 수 있다는 정도로만 알고 넘어가겠습니다.

 

wchar_t 타입은 유니코드 문자를 지원하는 문자 타입 중 하나로, 대체로 크기가 한 바이트인 char 타입보다 큽니다. 문자열 리터럴이 wide string이라는 것을 컴파일러에게 알려주려면 그 앞에 L을 붙입니다.

const wchar_t* myString = L"Hello, World";

그리고 다음과 같이 wide string을 인수로 받아서 그 값을 대문자로 변환한 스트링을 새로 만들어서 리턴하는 함수가 있다고 가정해봅시다.

wchar_t* toCaps(const wchar_t* inString);

이 함수에 myString을 전달해서 대문자로 변환할 수 있습니다. 이때 myString의 일부분만 대문자로 변환하려면 원하는 부분의 시작점을 포인터 연산으로 표현합니다. 예를 들어, 다음의 코드는 myString에서 World만 대문자로 바꾸기 위해 문자열의 시작점을 가리키는 포인터에 7을 더한 값을 toCaps()로 전달합니다. 

toCaps(myString + 7);

포인트 연산에서 뺄셈도 유용합니다. 한 포인터에서 같은 타입의 포인터를 빼면 두 포인터 사이에 포인터에 지정한 타입의 원소가 몇 개 있는지 알 수 있습니다.

 

3.2 커스텀 메모리 관리

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

 

리소스가 상당히 부족하거나 메모리 관리와 같은 특수한 작업을 수행할 때는 메모리를 직접 다루어야 할 수도 있습니다. 하지만 생각보다 어렵지는 않습니다. 핵심은 클래스에 큰 덩어리의 메모리를 할당해놓고 필요할 때마다 잘라 쓰는 것입니다.

 

그렇다면 이렇게 직접 관리하면 어떤 점이 좋을까요? 먼저 오버헤드를 좀 더 줄일 수 있습니다. 여기서 오버헤드는 new로 메모리를 할당했을 때, 현재 프로그램에서 얼마나 할당했는지 기록하는 데 필요한 공간을 의미합니다. 이렇게 기록해두어야 delete를 호출할 때 딱 필요한 만큼 해제할 수 있습니다. 대부분의 객체에서 이 오버헤드는 할당된 메모리보다 훨씬 작기 때문에 매우 작습니다. 하지만, 크기가 작은 객체나 프로그램에 엄청난 양의 객체가 있는 경우 오버헤드는 큰 영향을 미칠 수 있습니다.

 

메모리를 직접 다룰 때 객체 크기를 사전에 알고 있다면 각 객체에 대한 이러한 오버헤드를 피할 수 있습니다. 크기가 작은 객체가 아주 많다면 이렇게 절약한 효과는 상당합니다. 메모리를 직접 관리하는 구체적인 방법은 추후에 따로 알아보도록 하겠습니다.

 

3.3 가비지 컬렉션 Garbage Collection

가비지 컬렉션을 제공하는 환경이라면, 프로그래머가 객체에 할당된 메모리를 직접 해제할 일은 거의 없습니다. 대신 더 이상 참조되지 않는 객체는 런타임 라이브러리에 의해서 일정한 시점에 자동으로 해제됩니다.

 

C++은 자바나 C#과 달리 가비지 컬렉션이 기본적으로 제공되지 않습니다. 최신 버전의 C++은 스마트 포인터로 메모리를 관리해서 나아졌지만, 예전에는 new와 delete를 이용하여 객체 수준에서 직접 메모리를 관리해야 했습니다. 다음 포스팅에서 살펴보겠지만, shared_ptr과 같은 스마트 포인터는 가비지 컬렉션과 상당히 비슷한 방식으로 메모리를 관리합니다. 다시 말해 어떤 리소스를 참조하든 shared_ptr이 삭제되면 일정 시간 안에 그 포인터가 가리키던 리소스도 제거됩니다. C++에서 가비지 컬렉션을 구현할 수는 있지만 쉽지 않습니다. 대신 메모리를 직접 할당하고 해제하는 작업을 보다 쉽게 처리할 수 있습니다.

 

가비지 컬렉션을 구현하는 기법 중 하나는 mark and sweep이라는 알고리즘입니다. 이 방식에서는 가비지 컬렉터가 프로그램에 존재하는 모든 포인터를 주기적으로 검사한 뒤 여기서 참조하는 메모리를 계속 사용하고 있는지 여부를 표시합니다. 한 주기가 끝날 시점에 아무런 표시가 되지 않은 메모리는 더 이상 사용하지 않는 것으로 간주하고 해제합니다. 이러한 알고리즘을 C++에서 구현하는 것은 간다하지 않고, 잘못하면 delete를 사용하는 것보다 오류가 발생하기 쉽습니다.

 

가비지 컬렉션을 위한 안전하고 쉬운 메커니즘에 대한 시도는 C++에서 이루어졌지만, C++에서 가비지 컬렉션의 완벽한 구현이 이루어졌다고 하더라도 모든 어플리케이션에서 사용할 필요는 없습니다.

가비지 컬렉션은 다음의 단점을 가지고 있습니다.

  • 가비지 컬렉터가 작동하는 동안 프로그램이 멈출 수 있다.
  • 가비지 컬렉터가 있으면 소멸자가 비결정적(non-deterministic)으로 호출된다. 객체는 가비지 컬렉터에서 처리하기 전에는 제거되지 않기 때문에 객체가 스코프를 벗어나더라도 소멸자가 즉시 실행되지 않는다. 즉, 소멸자에서 처리하는 리소스 정리 작업은 일정한 시점이 이르기 전에 실행되지 않는데 언제 실행될 지 미리 알 수 없다.

 

가비지 컬렉션 메커니즘을 구현하는 것은 상당히 어렵습니다. 잘못 구현하기도 쉽고 에러도 많이 발생합니다. 무엇보다도 속도가 느릴 가능성이 높습니다. 굳이 어플리케이션에서 가비지 컬렉션 기능을 구현하고 싶다면 재사용 가능한 형태로 구현되어 있는 가비지 컬레션 라이브러리를 찾아서 쓰는 것을 추천합니다.

 

3.4 객체 풀 Object Pools

가지비 컬렉션은 마치 뷔페식당에서 음식을 먹으면서 다 쓴 접시를 테이블에 올려두면 직원이 치워주는 것과 같습니다. 물론, 이보다 조금 더 효율적인 방법이 있습니다.

 

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

성능 효율을 높이기 위해 객체 풀을 사용하는 것도 추후에 따로 다루어 보도록 하겠습니다.

 

 


지금까지 C++ 메모리 관리에서 배열과 포인터, 그리고 low-level의 메모리 관리 등에 대해 알아봤습니다.

이어지는 포스팅에서는 C++ 최신 버전에서 사용되는 스마트 포인터와 흔히 발생하는 메모리 문제들에 대해서 알아보도록 하겠습니다.

 

[C++] 메모리 관리 (2) - 메모리 문제 유형과 해결 방법

[C++] 메모리 관리 (3) - 스마트 포인터 (Smart Pointer)

댓글