Heap VS Stack 무엇을 써야 하나 고민될 때

C++은 프로그래머가 직접 메모리를 관리할 수 있어, 매우 저수준의 영역까지 다룰 수 있는 언어이다. 그런만큼 C++ 프로그래머들은 컴퓨터 메모리의 구조를 잘 알고 있어야 한다. 특히 Heap memory, Stack Memory, Static Memory는 잘 알아야 한다. 또한 메모리 관리를 실패한다면, 병렬 처리를 할 때 False Sharing (두 개 이상의 스레드가 같은 블록 영역의 메모리에 접근하면서, 캐시 성능 저하가 발생하는 현상) 이 일어나 성능이 더 떨어지거나, 혹은 Data Racing (두 개 이상의 스레드가 동시에 같은 메모리에 대해 쓰기 접근하면서, 잘못된 연산을 하거나 서로 데이터에 대한 소유권을 주장하면서 성능이 저하되는 현상) 이 발생하기도 쉽다. 무엇이 됐든 C++을 할 것이면 그 메모리 관리는 알면 알수록 실수할 가능성을 줄여준다.

아마 스택과 힙 메모리 영역은 많이 들어보았을 것이다. 왜냐면 C++을 처음 배울 때 동적 할당을 배웠다면, 가변 크기의 배열을 할당하기 위해서는 힙 메모리를 써야 한다는 것을 깨달았을 것이기 때문이다.

그러나 C++에서 힙 메모리 영역을 사용할 때는 주의를 요한다. 언어 자체에서 가비지 컬렉션을 지원하지 않아 프로그래머가 실수할 경우 메모리 누수(Memory Leak)이 일어나기 때문이다. 물론 가비지 컬렉터가 없으니깐 C++은 구조 자체가 다른 언어가 따라올 수 없는 성능이란 이점을 뽑아낼 수 있다. 그리고 현대의 C++에서는 프로그래머가 직접 메모리를 관리할 일은 많이 없고, 주로 RAII에 입각해서 메모리 관리를 자동으로 맡긴다. 그럼에도 힙 메모리를 쓸 때는 조심 또 조심해야 한다.

그런데 C++을 사용하다보면, 여기서 스택을 써야 할 지 힙 메모리를 써야할 지 고민이 될 때가 간혹 있다. 나도 그 기준이 애매해서 고민을 많이 하였는데, 이 고민을 해결하기 위해 조사한 바를 적고자 한다.

메모리 도식화

그전에 미리 혹시 몰라서, 메모리 구조에 대한 간단한 설명을 포함했다. 아래 메모리 구조 도식도를 PPT로 그렸다. 실제 물리적 환경의 메모리가 이렇게 그려진다기 보단, 이해를 위한 구조도라고 봐주면 되겠다.

사실 이와 비슷한 그림은 아마 C언어를 처음 배울 때, 혹은 C++을 처음 배울 때 많이 보았을 것이다. 위쪽이 스택이고, 아래쪽이 힙이다. 스택은 위에서 아래로 쌓여가듯이 할당되고, 해제될 때는 자동으로 아래에서 위로 해제된다. 하나의 닫는 대괄호가 끝날 때 안쪽에 있던 스택 메모리들이 해제되는 순서 역시 이와 관련이 있다.

힙은 아래쪽에 분포한다. 힙은 반드시 스택 위에 포인터가 힙에 할당된 공간을 가리키고 있어야 한다. 그래야 관리가 된다. 만약 힙을 가리키고 있던 포인터가, 힙이 할당 해제되지 않은 채로 소멸한다면, 그때가 바로 메모리 누수가 일어나는 지점이다.

static 메모리는 그 아래 존재한다. 여기서 global 객체들, 혹은 static 객체들이 존재한다.

마지막으로 문자열이나 코드 상에서 사용되는 절대 메모리들은 아래 text/code 영역으로 들어간다. const char * str = "string"에서 "string"이라는 문자열이 저 text 메모리 상으로 들어가고, str은 이 "string" 문자열을 가리키는 식으로 프로그램은 동작한다.

int main()
{
    int a = 10;
    int *arr = new int[4]{0, 1, 2, 3};
    const char *str = "nx006";

    delete[] arr

    return 0;
}

 

예시로 위 프로그램을 메모리 구조로 나타낸다면 아마 아래 그림처럼 될 것이다.

힙과 스택의 선택 기준

1. 힙을 써야 하는 경우

동적 할당

일단 당연하겠지만, 동적으로 변하는 배열과 같이 동적 할당에는 당연히 힙을 써야 한다. 이건 당연한 거다.

std::vector<int> arr_int = { 1, 2, 3 }; // heap
std::vector<double> arr_double = { 1.0, 2.0, 3.0}; // heap
std::vector<User> users; // heap

위와 같이 가변으로 변하는 길이의 데이터들을 담아야 할 때는 당연하겠지만 힙을 써야 한다. 다만 현대 C++에서 포인터를 사용해서는 안 되고, 벡터와 같은 STL을 사용하는 것이 권장된다.

같은 이유로 데이터 컨테이너는 힙을 사용하게 될 것이다. STL을 생각해보자. 내부적으로 스택을 사용하는 컨테이너는 std::array 밖에 없다. 사실 std::array는 C-style 배열을 STL화 시켜놓은 템플릿 객체일 뿐이다. 당장 가변으로 크기가 변하는 std::vector도 그렇고, 더블 링크드 리스트로 구현된 std::list 역시 동적 할당이 필요하다. 또 해시맵, 해시셋, 트리 구조의 맵과 셋 역시 동적으로 크기가 변해야 한다. 이때는 당연히 힙을 사용한다.

만약 이러한 STL을 사용하지 않고 직접 데이터 컨테이너를 구현할 때에도 특별한 경우가 아니라면 아마 힙을 사용하게 될 것이다.

크기가 큰 오브젝트 혹은 데이터 할당

크기가 매우 큰 데이터를 담을 때에도 힙을 사용해야 한다. 이때 크기가 매우 크다는 기준이 애매한데, 약 몇 백 kb, 몇 mb 정도부터는 힙을 사용한다고 보면 된다.

그 이유는 스택의 크기 때문인데, 컴퓨터 아키텍쳐마다 다르겠지만, 보통 스택의 크기는 약 1MB정도이다. MSVC의 글에 따르면 ARM64, x86 및 x64 컴퓨터의 경우 기본 스택 크기는 1MB라고 한다. 다른 컴퓨터 아키텍쳐도 크게 다르지 않을 것이다.

즉 예를 들어서 int를 한 10만 개 정도 할당한다고 하자. 이때 int하나가 4byte 이므로, 4 * 100000 = 400kb 정도 나온다. 이정도부터는 힙에 할당하는 것을 고려하자. 더 나아가서 250만 개 할당한다고 치면 1mb가 나오는데, 여기서부터는 애초에 스택의 크기를 넘어서므로 스택 오버플로우가 일어나 할당이 불가능하다. 이때는 컴파일러가 친절히 스택에 할당이 불가능하다고 에러를 띄우며 알려줄 것이다.

std::vector<int> v(10000); // heap

 

크기가 아주 큰 오브젝트를 할당하는 경우

std::unique_ptr<VeryBigObj> obj = std::make_unique<VeryBigObj>();

크기가 수 십에서 수 백 kb 정도 되는 오브젝트부터는, 힙에 할당해야 한다. 참고로 스택에서도 다시 한 번 이야기하겠지만, C++에서 힙에 올리냐 스택 위에 올리냐 결정하는 것은 데이터의 개수가 아니다. 데이터가 아무리 많아도 그 총 크기가 작다면 스택에 올리는게 맞다. 반면 오브젝트가 단 하나만 존재하더라도 그 크기가 매우 크다면, 힙에 올리는 게 맞다.

오브젝트 라이프 사이클의 제어 시

오브젝트를 스코프 바깥에서 사용하고 싶은 경우에도 힙에 할당해야 한다.
오브젝트를 선언된 위치의 스코프에서 벗어나서 사용한다는 것은, 오브젝트의 라이프 사이클을 직접 제어하겠다는 의미이다. 오브젝트 라이프 사이클을 스택에 맡기지 않고 직접 제어하고 싶다면, 힙에 할당해야 한다.

소유권의 이동과 공유
이건 앞서 얘기한 스코프 바깥에서의 오브젝트 사용하고도 연결되는데, 오브젝트의 소유권이 이동하거나 공유되는 경우에도 힙에 할당해야 한다. 즉 이미 할당된 std::unique_ptr의 소유권을 std::move()를 통해 타 객체에게 이전시키고 싶은 경우, 혹은 std::shared_ptr과 같이 공유된 소유권을 획득하려 하는 경우, 그 대상이 되는 오브젝트는 힙에 할당이 되어 있어야 한다.

폴리모피즘(Polymorphism, 다형성)

폴리모피즘이란 간단히 말해서 리스코프 치환을 만족하는 객체를 사용하는 것을 의미한다. 예를 들어 다음을 생각해보자.

#include <iostream>
#include <memory>

class Animal 
{
public:
    virtual void makeSound() = 0; 
    virtual ~Animal() {}           
};

class Dog : public Animal 
{
public:
    void makeSound() override 
    {
        std::cout << "Woof!\n";
    }
};

class Cat : public Animal 
{
public:
    void makeSound() override 
    {
        std::cout << "Meow!\n";
    }
};

int main() 
{
    std::unique_ptr<Animal> myAnimal = std::make_unique<Dog>();
    myAnimal->makeSound();  // Outputs: Woof!

    myAnimal = std::make_unique<Cat>();
    myAnimal->makeSound();  // Outputs: Meow!

    return 0;
}

이렇게 런타임 중간에 폴리모피즘이 이용되고 있다. 여기서는 힙을 사용해야 한다.

2. 스택을 쓰는 경우

의외로 간단하다. 위에서 제시한 상황 외에는 스택을 쓰면 된다. 애초에 힙을 써야할 특별한 이유가 없다면, 굳이 힙을 사용하지 않는 것이 좋다. 왜냐면 힙에 할당하는 것은 스택에 할당하는 것 대비 아주 비싼 연산이기 때문이다. 스택은 힙보다 가볍고 빠르며, 심지어 자동으로 메모리가 해제가 되기에 메모리 누수가 일어나지도 않는다.

그래서 integer 400개, 500개 정도는 그냥 스택 array에 담아주는 것이 좋다. 참고로 이때에도 int arr[400]과 같이 포인터를 이용하는 것보다는, STL의 array를 사용하는 것이 더 좋다.

std::array<int, 400> arr;

 

그리고 오브젝트를 할당할 때에도, 크기가 작다면 그냥 스택에 올리는 게 좋다. 여기서 특히 자바 계열을 다루는 프로그래머가, C++를 할 때 자주보이는 실수가 바로 new를 사용하는 것이다. 습관적으로 오브젝트를 한 개를 생성할 때에도 힙에다가 올려버린다.

Monster monster = new Monster(); // heap, bad choice

 

그러나 C++에서는 오브젝트를 몇 개를 할당하든 상관없이, 그 총 용량이 수 kb 미만이라면 스택에 생성하는 것이 더 좋다.

Monster monster; // stack

 

그리고 위 예시에서 또한 new를 사용했는데, 프로그램의 메모리 상태 관리 전반을 생각해야 하는 C++ 프로그래머들은 new의 사용을 기겁할 줄 알아야 한다. new를 사용한다면 사용자가 직접 메모리 해제의 책임을 떠맡게 되는데, 이는 RAII 원칙에 위배된다. 위에 예시 또한 특별한 이유가 없다면 스마트 포인터를 사용해서 관리하는 것이 더 일반적이다.

std::unique_ptr<Monster> monster = std::make_unique<Monster>();

 

기억하자. C++은 자바가 아니라는 것을. 자바처럼 메모리 관리를 언어 자체가 알아서 척척 해주지 않는다. 메모리를 할당하는 것에 있어서도 최적화는 프로그래머의 몫이다.

나도 얼마 전에 예약 프로그램을 만들면서, 다음과 같이 아무 생각 없이 클래스를 구성했다. 하지만 저러면 아니 된다.

class RestaurantManager : public ProgramManager
{
public:
    // ... some methods
private:
    std::unique_ptr<TableManager> table_manager; // heap
    std::unique_ptr<AuthManager> auth_manager; // heap
    std::unique_ptr<UserManager> user_manager; // heap
};

TableManager, AuthManager, UserManager 모두 내부에서 많은 양의 데이터들을 담고 있을 수는 있지만, 이는 모두 내부적으로 vector, unordered_map를 통해서 처리한다. 즉 새 manager 클래스들 모두 그 자체 크기는 수십 바이트 수준밖에 안 된다. 그런데 이를 굳이 비싼 연산인 힙에 할당해서 쓸 일이 없다. 위 코드는 아래와 같이 고치는 게 합리적이다.

class RestaurantManager : public ProgramManager
{
public:
    // ... some methods
private:
    TableManager tableManager; // stack
    AuthManager authManager; // stack
    RestaurantUserManager userManager; // stack, good choice
};

참고로 또한 매니저 예시에서 말했듯이, 많은 데이터를 다룬다고 하더라도 스택 위 사이즈와 힙에 할당되는 실제 사이즈는 구분할 줄 알아야 한다.

class RestaurantUserManager : public UserManager
{
public:
    // .. some methods;
private:
    std::vector<User> users;
};

RestaurantUserManager::users가 유저 정보를 수십 수백 메가 바이트를 들고 있다 하더라도, users라는 벡터 객체 자체는 스택 위에 올라가 있다. 컴파일러의 구현마다 다르겠지만, 보통 std::vector는 내부적으로 세 개의 포인터 변수를 담고 있는데(힙 영역에서 할당된 메모리의 시작 주소, 끝 주소, 그리고 실제 내용물의 끝 주소) x64 비트 시스템에서 한 포인터 변수의 크기는 8 bytes이므로 실제로는 24 바이트밖에 없는 것이다. 즉 RestaurantUserManager의 크기가 크다고 가정하면 안 된다. 실제로는 벡터로 데이터들은 관리되고 있고, 유저 매니저 클래스 그 자체는 24 바이트 정도밖에 차지하지 않는다.

타 언어들은 굳이 스택과 힙을 구분할 필요 없는데(그냥 힙에 할당되는 경우가 대부분이라고 알고 있다), C++에서는 선택지가 두 개 존재하다보니 고민이 될 때가 많다. 이왕 더 효율적인 선택지가 존재하는 김에 알고 쓰면 더 좋지 아니한가. 배운 김에 정리해봤다.