Skip to content

C++ 메모리 관리 기초 — 스택·힙·RAII

C++에서 메모리는 크게 세 영역으로 나뉩니다. 어떤 영역을 사용하느냐에 따라 객체의 수명, 성능, 관리 방식이 완전히 달라집니다.

영역할당 시점해제 시점크기 제한관리 주체
스택(Stack)변수 선언 시스코프 종료 시 자동수 MB (OS 제한)컴파일러
힙(Heap)new 호출 시delete 호출 시수 GB (가용 메모리)개발자
정적/전역(Static)프로그램 시작 시프로그램 종료 시제한적컴파일러

스택은 함수 호출 시 자동으로 할당되고, 함수가 리턴되는 순간 자동으로 해제됩니다. 개발자가 별도로 메모리를 관리할 필요가 없습니다.

#include <iostream>
void StackExample()
{
int x = 10; // 스택에 4바이트 할당
double y = 3.14; // 스택에 8바이트 할당
char name[64] = {}; // 스택에 64바이트 할당
std::cout << x << ", " << y << "\n";
// 함수 종료 시 x, y, name 모두 자동 해제
}
int main()
{
StackExample(); // 호출 후 스택 자동 정리
return 0;
}

장점

  • 할당/해제가 매우 빠름 (스택 포인터 이동만으로 완료)
  • 메모리 누수 없음 (자동 해제)
  • 캐시 친화적 (연속 메모리)

단점

  • 크기가 컴파일 타임에 결정되어야 함
  • 스택 크기 초과 시 스택 오버플로우 발생
  • 함수 리턴 후 데이터 참조 불가 (Dangling Pointer 위험)
// 위험한 패턴: 스택 주소를 리턴
int* DangerousFunction()
{
int localVar = 42;
return &localVar; // 경고: 함수 종료 후 이 주소는 무효
}
int main()
{
int* ptr = DangerousFunction();
// ptr 역참조는 Undefined Behavior (크래시 가능성)
std::cout << *ptr << "\n"; // 절대 하지 말 것
return 0;
}

힙은 런타임에 크기를 결정할 수 있고, 함수 범위를 넘어서 데이터를 유지할 수 있습니다. 대신 개발자가 직접 해제해야 합니다.

#include <iostream>
int main()
{
// 단일 객체 할당
int* p = new int(42);
std::cout << *p << "\n"; // 42
delete p; // 반드시 해제
p = nullptr; // 해제 후 nullptr 대입 (안전 습관)
// 배열 할당
int n = 10;
int* arr = new int[n]; // 런타임 크기 결정 가능
for (int i = 0; i < n; ++i)
{
arr[i] = i * i;
}
delete[] arr; // 배열은 반드시 delete[] 사용
arr = nullptr;
return 0;
}

new로 할당한 메모리를 delete하지 않으면 메모리 누수(Memory Leak)가 발생합니다. 프로그램이 종료될 때까지 해당 메모리를 다른 용도로 사용할 수 없습니다.

#include <stdexcept>
// 메모리 누수 예시 1: delete 누락
void LeakExample1()
{
int* p = new int(100);
// delete p; 를 빠뜨림 → 메모리 누수
}
// 메모리 누수 예시 2: 예외 발생 시 누수
void LeakExample2()
{
int* p = new int(100);
// 아래 코드에서 예외가 발생하면 delete에 도달하지 못함
SomeFunctionThatMayThrow();
delete p; // 예외 발생 시 이 줄은 실행되지 않음
}
// 메모리 누수 예시 3: 배열을 delete로 해제
void LeakExample3()
{
int* arr = new int[10];
delete arr; // 잘못됨: delete[] arr 이어야 함
// Undefined Behavior
}

3. RAII — 리소스 안전 관리 원칙

Section titled “3. RAII — 리소스 안전 관리 원칙”

RAII(Resource Acquisition Is Initialization)는 C++의 핵심 설계 원칙입니다.

리소스(메모리, 파일 핸들, 락 등)의 획득은 객체 초기화 시점에, 해제는 소멸자에서 자동으로 수행한다.

스택 객체는 스코프 종료 시 소멸자가 자동 호출된다는 C++ 보장을 활용합니다. 예외가 발생하더라도 스택 언와인딩(Stack Unwinding) 과정에서 소멸자가 반드시 실행됩니다.

// RAII 없는 방식 (위험)
void BadResourceManagement()
{
FILE* file = fopen("data.txt", "r");
if (SomeCondition())
{
return; // 여기서 리턴하면 fclose 누락
}
if (ErrorCondition())
{
throw std::runtime_error("Error"); // 예외 시 fclose 누락
}
fclose(file); // 모든 경로에서 도달한다는 보장 없음
}

소멸자에서 리소스를 해제하도록 클래스를 설계하면, 스코프 종료·예외·조기 리턴 모든 상황에서 안전하게 해제됩니다.

#include <iostream>
#include <stdexcept>
#include <cstdio>
// 파일 핸들을 위한 RAII 래퍼
class FileHandle
{
public:
explicit FileHandle(const char* path, const char* mode)
: m_File(fopen(path, mode))
{
if (!m_File)
{
throw std::runtime_error("Failed to open file");
}
std::cout << "FileHandle: opened\n";
}
// 소멸자에서 자동 해제
~FileHandle()
{
if (m_File)
{
fclose(m_File);
std::cout << "FileHandle: closed\n";
}
}
// 복사 금지 (리소스 이중 해제 방지)
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// 이동은 허용
FileHandle(FileHandle&& other) noexcept
: m_File(other.m_File)
{
other.m_File = nullptr;
}
FILE* Get() const { return m_File; }
private:
FILE* m_File;
};
void GoodResourceManagement()
{
FileHandle file("data.txt", "r"); // 생성자에서 획득
if (SomeCondition())
{
return; // 소멸자가 자동 호출 → fclose 보장
}
// 예외 발생 시에도 소멸자 호출 → fclose 보장
ProcessFile(file.Get());
// 스코프 종료 시 자동으로 fclose 호출
}
#include <iostream>
#include <utility>
// 단일 객체 소유권을 관리하는 RAII 클래스 (std::unique_ptr의 단순 버전)
template<typename T>
class ScopedPtr
{
public:
explicit ScopedPtr(T* ptr = nullptr)
: m_Ptr(ptr) {}
~ScopedPtr()
{
delete m_Ptr;
}
// 복사 금지
ScopedPtr(const ScopedPtr&) = delete;
ScopedPtr& operator=(const ScopedPtr&) = delete;
// 이동 허용
ScopedPtr(ScopedPtr&& other) noexcept
: m_Ptr(other.m_Ptr)
{
other.m_Ptr = nullptr;
}
ScopedPtr& operator=(ScopedPtr&& other) noexcept
{
if (this != &other)
{
delete m_Ptr;
m_Ptr = other.m_Ptr;
other.m_Ptr = nullptr;
}
return *this;
}
T* operator->() const { return m_Ptr; }
T& operator*() const { return *m_Ptr; }
T* Get() const { return m_Ptr; }
explicit operator bool() const { return m_Ptr != nullptr; }
private:
T* m_Ptr;
};
struct Player
{
std::string Name;
int Health = 100;
Player(const std::string& name) : Name(name)
{
std::cout << "Player created: " << Name << "\n";
}
~Player()
{
std::cout << "Player destroyed: " << Name << "\n";
}
};
int main()
{
{
ScopedPtr<Player> player(new Player("Alice"));
std::cout << player->Name << " HP: " << player->Health << "\n";
// 스코프 종료 시 ~ScopedPtr() 호출 → delete player
}
// 출력: "Player destroyed: Alice"
return 0;
}

class ResourceManager
{
public:
ResourceManager()
: m_Buffer(new char[1024])
, m_File(fopen("log.txt", "w"))
{
}
// 소멸자는 virtual로 선언 (상속 시 안전한 삭제를 위해)
virtual ~ResourceManager()
{
// 역순으로 해제 (생성 역순)
if (m_File)
{
fclose(m_File);
m_File = nullptr;
}
delete[] m_Buffer;
m_Buffer = nullptr;
}
// 복사와 대입도 올바르게 처리 (Rule of Three/Five)
ResourceManager(const ResourceManager&) = delete;
ResourceManager& operator=(const ResourceManager&) = delete;
private:
char* m_Buffer;
FILE* m_File;
};

소멸자를 정의했다면 복사 생성자, 복사 대입, 이동 생성자, 이동 대입도 명시적으로 처리해야 합니다.

class Buffer
{
public:
// 1. 생성자
explicit Buffer(size_t size)
: m_Size(size)
, m_Data(new int[size])
{
}
// 2. 소멸자
~Buffer()
{
delete[] m_Data;
}
// 3. 복사 생성자 (깊은 복사)
Buffer(const Buffer& other)
: m_Size(other.m_Size)
, m_Data(new int[other.m_Size])
{
std::copy(other.m_Data, other.m_Data + other.m_Size, m_Data);
}
// 4. 복사 대입 연산자
Buffer& operator=(const Buffer& other)
{
if (this == &other) return *this;
delete[] m_Data;
m_Size = other.m_Size;
m_Data = new int[m_Size];
std::copy(other.m_Data, other.m_Data + m_Size, m_Data);
return *this;
}
// 5. 이동 생성자
Buffer(Buffer&& other) noexcept
: m_Size(other.m_Size)
, m_Data(other.m_Data)
{
other.m_Size = 0;
other.m_Data = nullptr;
}
// 6. 이동 대입 연산자
Buffer& operator=(Buffer&& other) noexcept
{
if (this == &other) return *this;
delete[] m_Data;
m_Size = other.m_Size;
m_Data = other.m_Data;
other.m_Size = 0;
other.m_Data = nullptr;
return *this;
}
size_t Size() const { return m_Size; }
int* Data() { return m_Data; }
private:
size_t m_Size;
int* m_Data;
};

5. 실전 패턴 — RAII로 리소스 묶음 관리

Section titled “5. 실전 패턴 — RAII로 리소스 묶음 관리”
#include <iostream>
#include <mutex>
#include <fstream>
// 뮤텍스 락을 RAII로 관리하는 예시 (std::lock_guard의 단순 버전)
class LockGuard
{
public:
explicit LockGuard(std::mutex& m) : m_Mutex(m)
{
m_Mutex.lock();
}
~LockGuard()
{
m_Mutex.unlock();
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
std::mutex& m_Mutex;
};
// 여러 리소스를 묶어서 RAII로 관리하는 게임 세션 클래스
class GameSession
{
public:
GameSession(const std::string& playerName, const std::string& logPath)
: m_PlayerName(playerName)
, m_LogFile(logPath)
, m_Score(0)
{
if (!m_LogFile.is_open())
{
throw std::runtime_error("Cannot open log file");
}
m_LogFile << "Session started for: " << m_PlayerName << "\n";
std::cout << "GameSession created\n";
}
~GameSession()
{
m_LogFile << "Session ended. Final score: " << m_Score << "\n";
// m_LogFile (std::ofstream)은 자동으로 close() 호출
std::cout << "GameSession destroyed\n";
}
void AddScore(int points)
{
LockGuard guard(m_ScoreMutex); // 스코프 종료 시 자동 해제
m_Score += points;
m_LogFile << "Score added: " << points << ", total: " << m_Score << "\n";
}
GameSession(const GameSession&) = delete;
GameSession& operator=(const GameSession&) = delete;
private:
std::string m_PlayerName;
std::ofstream m_LogFile;
int m_Score;
std::mutex m_ScoreMutex;
};
int main()
{
try
{
GameSession session("Alice", "game_log.txt");
session.AddScore(100);
session.AddScore(250);
// 스코프 종료 시 ~GameSession() 자동 호출 → 파일 닫기, 최종 점수 기록
}
catch (const std::exception& e)
{
std::cerr << "Error: " << e.what() << "\n";
}
return 0;
}

개념핵심 요약
스택자동 할당/해제, 빠름, 크기 제한 있음, 스코프 종료 시 소멸
수동 new/delete, 유연한 크기, 수명 직접 제어, 누수 위험
RAII소멸자에서 해제 보장 → 예외·조기 리턴에도 안전
Rule of Five소멸자 정의 시 복사/이동 생성자·대입 연산자도 명시 처리
  • 스마트 포인터: std::unique_ptr, std::shared_ptr로 RAII를 직접 구현하지 않고 활용
  • 이동 시맨틱: 불필요한 복사를 피해 힙 객체를 효율적으로 전달
  • STL 컨테이너: std::vector 등 내부적으로 RAII를 적용한 표준 라이브러리