Skip to main content

10. 동적 메모리와 안전한 자원 관리의 기초

지난 장에서 우리는 포인터가 "주소를 저장하는 변수"라는 감각을 익혔습니다. 이제 한 걸음 더 들어가 볼 차례입니다. 주소를 안다는 것만으로는 아직 반쪽짜리입니다. C++에서는 프로그램이 실행되는 도중에 직접 메모리를 만들고, 다 쓰면 스스로 정리할 수도 있기 때문입니다.

이 기능은 아주 강력합니다. 하지만 동시에 꽤 위험합니다. 칼이 요리를 도와주기도 하지만, 손을 베게도 하듯이 말입니다. C++에서 메모리를 제대로 다루지 못하면, 강력함은 곧바로 위험으로 바뀝니다.

이번 장에서는 new와 delete가 무엇을 하는지, 왜 메모리 누수와 댕글링 포인터가 생기는지, 그리고 왜 현대 C++이 RAII와 스마트 포인터를 중요하게 여기는지를 기초부터 차근차근 살펴보겠습니다.

동적 메모리와 안전한 자원 관리의 기초

왜 굳이 동적 메모리가 필요할까

지금까지는 대체로 크기가 미리 정해진 변수와 배열을 사용했습니다. 예를 들어 학생 30명의 점수를 저장하려면 int scores[30];처럼 쓸 수 있습니다. 그런데 현실의 프로그램은 늘 이렇게 친절하지 않습니다.

사용자가 몇 명인지 실행 중에 입력할 수도 있고, 파일 크기를 열어 보기 전까지 모를 수도 있고, 필요한 데이터 개수가 상황에 따라 계속 달라질 수도 있습니다. 이럴 때는 프로그램이 돌아가는 중간에 필요한 만큼 메모리를 확보하는 능력이 필요합니다. 이런 메모리를 보통 동적 메모리라고 부르며, 일반적으로 힙(heap) 영역에서 관리합니다. 반면 함수 안의 지역 변수처럼 범위를 벗어나면 자동으로 사라지는 메모리는 보통 스택(stack) 쪽 감각으로 이해하면 됩니다.1

쉽게 말하면 이렇습니다.

자동 변수는 "책상 위에 잠깐 올려둔 물건"이고, 동적 메모리는 "필요할 때 창고를 빌려 쓰는 물건"입니다. 책상 위 물건은 자리를 뜨면 치워지지만, 창고는 내가 반납하기 전까지 계속 비용이 나갑니다. C++의 무서움과 매력은 바로 여기에 있습니다.

new: 실행 중에 메모리를 만드는 연산자

C++에서는 동적 메모리를 만들 때 new를 사용합니다. 가장 단순한 형태는 이렇게 생겼습니다.2

int* p = new int;

이 코드는 정수 하나를 저장할 공간을 실행 중에 만들고, 그 주소를 p에 넣습니다. 이제 p는 힙에 있는 정수 하나를 가리킵니다.

다만 이 상태의 정수는 아직 어떤 값으로 쓸지 분명하지 않을 수 있으니, 초보자에게는 할당과 동시에 초기화하는 습관이 훨씬 좋습니다.3

int* p = new int{42};
cout << *p << 'n';

이제 *p는 42입니다.

배열도 만들 수 있습니다.

int n;
cin >> n;

int* scores = new int[n]{};

여기서는 사용자가 입력한 n개만큼 정수 배열을 실행 중에 만듭니다. {} 를 붙였기 때문에 각 원소는 0으로 초기화됩니다. 이 점이 초보자에게 꽤 든든합니다. 안 그러면 쓰레기값 때문에 마음이 먼저 흔들리기 쉽기 때문입니다.3

이렇게 보면 new는 참 편리합니다. 프로그램이 미리 다 정해진 세상이 아니라, 실행하면서 필요한 만큼 공간을 확보하는 세상을 열어 주니까요.

delete: 빌린 메모리를 돌려주는 연산자

하지만 창고를 빌렸으면 반납도 해야 합니다. new로 만든 메모리는 자동으로 사라지지 않습니다. 다 썼다면 반드시 직접 해제해야 합니다.2

정수 하나를 new로 만들었다면 delete로 정리합니다.

int* p = new int{42};

cout << *p << 'n';

delete p;
p = nullptr;

배열을 new[]로 만들었다면 delete[]로 정리해야 합니다.

int* scores = new int[10]{};

// 사용

delete[] scores;
scores = nullptr;

여기서 아주 중요한 규칙이 하나 있습니다.

  • new로 만들었으면 delete

  • new[]로 만들었으면 delete[]

  • 둘을 섞어 쓰면 안 됨

이 규칙은 외우는 수준이 아니라, 몸에 배어야 합니다. 문짝 열쇠를 자전거 자물쇠에 꽂는다고 열리지 않듯이, 짝이 맞지 않으면 문제가 생깁니다.

또 하나, delete 뒤에 포인터를 nullptr로 바꾸는 습관은 도움이 됩니다.3 메모리가 살아 있는 것처럼 착각하고 다시 쓰는 실수를 줄여 주기 때문입니다. 물론 이것만으로 모든 문제가 사라지지는 않지만, 적어도 "이미 버린 주소"를 들고 있는 상태를 덜 위험하게 만들어 줍니다.

new와 delete를 한 번에 보는 예제

직접 한 번 흐름을 보겠습니다.

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "학생 수를 입력하세요: ";
    cin >> n;

    int* scores = new int[n]{};

    for (int i = 0; i < n; ++i) {
        cout << i + 1 << "번 학생 점수: ";
        cin >> scores[i];
    }

    int sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += scores[i];
    }

    cout << "총점: " << sum << 'n';
    cout << "평균: " << static_cast<double>(sum) / n << 'n';

    delete[] scores;
    scores = nullptr;

    return 0;
}

이 프로그램의 핵심은 배열 크기를 미리 10, 100, 1000 중 하나로 억지로 정하지 않았다는 점입니다. 사용자 입력에 맞춰 딱 필요한 만큼만 메모리를 확보했습니다. 이게 동적 메모리의 가장 대표적인 장점입니다.1

하지만 동시에 마지막의 delete[] scores;가 없다면? 프로그램은 눈앞에서는 잘 돌아갈 수도 있지만, 뒤에서는 메모리를 반납하지 않고 끝내 버립니다. 바로 여기서 문제가 시작됩니다.

첫 번째 위험: 메모리 누수

메모리 누수(memory leak) 는 프로그램이 동적으로 확보한 메모리를 더 이상 쓸 방법도 없는데, 해제하지도 못한 채 남겨 두는 현상입니다. 쉽게 말해 창고 열쇠를 잃어버렸는데, 창고 임대는 계속되는 상황입니다.4

가장 단순한 누수는 new는 있는데 delete가 없는 경우입니다.

void f() {
    int* arr = new int[100];
    // 사용
} // delete[]가 없음

함수가 끝났다고 해서 힙 메모리가 자동으로 사라지지 않습니다. arr라는 포인터 변수만 사라졌을 뿐, 실제로 확보했던 메모리는 남아 버립니다.

조금 더 교묘한 누수도 있습니다.

int* p = new int{10};
p = new int{20};

delete p;

겉으로 보기에는 마지막에 delete p;가 있으니 괜찮아 보입니다. 하지만 아닙니다. 두 번째 줄에서 p는 새로운 주소를 가리키도록 바뀌었습니다. 그 순간 처음 new int{10}으로 만들었던 메모리 주소는 잃어버렸습니다. 이제 더 이상 그 메모리를 해제할 방법이 없습니다. 첫 번째 메모리는 누수입니다.4

조건문이나 조기 종료가 껴도 누수는 쉽게 생깁니다.

void process(bool error) {
    int* data = new int[100];

    if (error) {
        return; // 누수!
    }

    delete[] data;
}

초보자 때는 이런 코드가 정말 많이 나옵니다. 이유는 단순합니다. 사람은 "메모리 해제"보다 "문제 해결"에 더 먼저 집중하기 때문입니다. 그래서 C++에서는 메모리 관리 습관이 실력의 일부가 됩니다.

두 번째 위험: 댕글링 포인터

메모리 누수가 "해제하지 못한 메모리" 문제라면, 댕글링 포인터(dangling pointer) 는 이미 유효하지 않은 메모리를 계속 가리키는 포인터 문제입니다.4

둘은 비슷해 보이지만 다릅니다.

메모리 누수는 "집은 있는데 주소를 잃어버린 상태"이고, 댕글링 포인터는 "집이 철거됐는데 예전 주소를 아직 들고 있는 상태"입니다.

가장 흔한 예는 delete 후에 그 포인터를 다시 쓰는 경우입니다.5

int* p = new int{5};
int* q = p;

delete p;
p = nullptr;

// q는 여전히 예전 주소를 기억하고 있다.
// *q = 10;  // 위험!

p를 지웠다고 해서 q까지 자동으로 안전해지는 것은 아닙니다. q는 여전히 이미 해제된 메모리를 가리킵니다. 이런 포인터를 역참조하면 정의되지 않은 동작이 일어납니다. 어떤 날은 조용히 이상한 값이 나오고, 어떤 날은 바로 프로그램이 죽고, 어떤 날은 멀쩡한 척하다가 나중에 더 큰 사고를 냅니다.

지역 변수의 주소를 함수 밖으로 내보내는 것도 대표적인 댕글링 포인터 사례입니다.5

int* wrong() {
    int value = 10;
    return &value;
}

value는 함수가 끝나는 순간 사라집니다. 그런데 그 주소를 반환했으니, 호출한 쪽은 이미 사라진 대상을 가리키는 포인터를 받게 됩니다. 이건 마치 철거 예정 건물의 호수를 친구에게 알려 주는 것과 비슷합니다.

블록 범위를 벗어난 변수 주소를 들고 있는 경우도 마찬가지입니다.5

int* ptr;
{
    int x = 20;
    ptr = &x;
}

// x는 이미 사라졌다.
// cout << *ptr << 'n';  // 위험!

댕글링 포인터는 특히 무섭습니다. 프로그램이 바로 망가지지 않을 수도 있기 때문입니다. 그래서 디버깅할 때 더 까다롭습니다.

세 번째로 같이 기억할 것: 이중 해제와 잘못된 짝

이번 장의 핵심 목표는 누수와 댕글링 포인터지만, 초보자라면 이웃 위험도 함께 눈에 익혀 두는 게 좋습니다.

첫째, 이미 해제한 메모리를 또 delete하는 이중 해제(double delete) 는 매우 위험합니다.

int* p = new int{3};
delete p;
delete p; // 위험!

둘째, 앞에서 본 것처럼 new[]로 만든 배열을 delete로 지우거나, 반대로 new로 만든 단일 객체를 delete[]로 지우는 것도 문제입니다.

이 부분은 마치 "반납은 했는데 엉뚱한 창구에 반납한 상태"라고 생각하면 됩니다. 정리해 보려던 행동이 오히려 버그를 만들 수 있습니다.

왜 손으로 관리하는 코드는 자주 무너질까

여기쯤 오면 자연스럽게 이런 생각이 듭니다.

"그럼 new 했으면 나중에 delete만 잘 쓰면 되는 것 아닌가요?"

말은 맞습니다. 그런데 실제 프로그램은 그렇게 단순하지 않습니다. 함수 안에는 조건문도 있고, 반복문도 있고, 중간 return도 있고, 나중에는 예외도 등장합니다. 코드는 길어지고 갈라집니다. 그러다 보면 메모리를 얻은 곳과 해제하는 곳이 멀어지고, 누가 책임져야 하는지도 흐릿해집니다.

예를 들어 아래 코드는 겉보기에는 평범하지만, 문제가 숨어 있습니다.

void work() {
    int* data = new int[100];

    // 여기저기서 여러 작업 수행

    if (/* 어떤 조건 */) {
        return;
    }

    // 또 다른 작업

    delete[] data;
}

코드가 10줄일 때는 눈에 띄던 delete[]가, 100줄이 되면 갑자기 안 보이기 시작합니다. C++가 "위험하다"는 말을 듣는 이유 중 상당수는 문법 자체보다도, 이런 수동 관리의 부담에서 옵니다.

해결의 방향: RAII라는 사고방식

이 부담을 줄이기 위해 C++가 아주 중요하게 여기는 철학이 있습니다. 바로 RAII(Resource Acquisition Is Initialization) 입니다. 이름은 무섭지만 핵심은 단순합니다.

자원을 얻는 순간, 그 자원을 관리할 객체도 함께 만들고, 그 객체가 사라질 때 자원도 자동으로 정리되게 하자는 생각입니다.6

여기서 자원은 메모리만 뜻하지 않습니다. 파일, 소켓, 뮤텍스 잠금 같은 것도 모두 포함됩니다.6

핵심은 "수명을 묶는다"는 데 있습니다.

  • 객체가 살아 있는 동안 자원도 살아 있다.

  • 객체가 범위를 벗어나면 자원도 정리된다.

  • 사람이 중간에 delete를 깜빡할 가능성을 줄인다.

즉, "내가 나중에 꼭 정리해야지"가 아니라, "범위를 벗어나면 자동으로 정리되게 설계하자" 쪽으로 사고를 바꾸는 것입니다.

이 방식이 좋은 이유는 조기 return이 있든, 코드가 길어지든, 심지어 나중에 예외가 생기든, 객체의 수명이 끝날 때 정리 코드가 실행되기 때문입니다.6

스마트 포인터: 메모리에 RAII를 적용한 도구

메모리 관리에서 RAII를 가장 대표적으로 보여 주는 도구가 스마트 포인터(smart pointer) 입니다. 스마트 포인터는 이름 그대로 "똑똑한 포인터"인데, 사실은 포인터처럼 쓰이지만 메모리 해제를 자동으로 관리하는 객체입니다.6

가장 먼저 기억할 것은 std::unique_ptr입니다. 이름 그대로 하나의 소유자만 가지는 포인터입니다. 현대 C++에서는 동적 메모리를 직접 관리해야 할 때 기본 선택지로 자주 권장됩니다.67

#include <iostream>
#include <memory>
using namespace std;

int main() {
    auto p = make_unique<int>(42);
    cout << *p << 'n';

    return 0;
}

이 코드는 new int{42}를 쓴 것과 비슷한 목적을 가집니다. 하지만 직접 delete를 쓰지 않습니다. p가 범위를 벗어나면 자동으로 메모리가 정리됩니다.

배열도 비슷하게 다룰 수 있습니다.

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

이렇게 하면 함수가 중간에 끝나더라도 사람이 delete[]를 챙기지 않아도 됩니다. 마치 메모리에 자동 반납 타이머를 붙여 놓은 느낌입니다.

여러 곳이 같은 객체를 함께 소유해야 하는 특별한 상황에서는 std::shared_ptr도 있습니다. 이 포인터는 참조 개수를 세다가, 마지막 소유자가 사라질 때 메모리를 정리합니다.7 다만 초보자 단계에서는 우선 이렇게 기억해 두면 충분합니다.

공유가 정말 필요하지 않다면, 기본은 unique_ptr 쪽이다.

그리고 shared_ptr끼리는 서로를 강하게 잡아 두면 순환 참조 같은 문제도 생길 수 있어서, 그럴 때는 weak_ptr이 필요해집니다. 하지만 이 부분은 지금 당장 깊게 파기보다, "스마트 포인터도 종류에 따라 소유 방식이 다르다" 정도만 잡아 두면 충분합니다.7

new를 배우는 이유와, new를 남발하지 말아야 하는 이유

조금 아이러니하게 들릴 수 있습니다.

우리는 지금 new와 delete를 배우고 있는데, 동시에 현대 C++에서는 직접 쓰는 일을 줄이려 한다고 말하고 있습니다. 왜 그럴까요?

이유는 간단합니다. 원리를 알아야 안전한 도구의 필요성도 이해할 수 있기 때문입니다.

직접 자전거 체인을 다뤄 본 사람은 자동 변속기의 고마움을 더 잘 압니다. 마찬가지로 new와 delete를 손으로 관리해 보면, 왜 RAII와 스마트 포인터가 단순한 유행이 아니라 "버그를 줄이기 위한 설계"인지 실감하게 됩니다.

즉,

  • new와 delete는 메모리 관리의 원리를 보여 준다.

  • RAII와 스마트 포인터는 그 원리를 더 안전하게 실전에 적용하게 해 준다.

이 순서로 이해하면 C++가 훨씬 덜 혼란스럽습니다.

초보자를 위한 안전 수칙

이 장에서 꼭 몸에 익혀야 할 수칙을 짧게 정리해 보겠습니다.

  • new를 썼다면, 그 메모리를 누가 해제할지 바로 생각하기

  • new[]와 delete[], new와 delete를 반드시 짝 맞추기

  • delete 후에는 가능하면 포인터를 nullptr로 바꾸기

  • 이미 해제한 메모리를 다시 쓰지 않기

  • 지역 변수의 주소를 함수 밖으로 넘기지 않기

  • 가능하면 수동 new/delete보다 RAII 도구와 스마트 포인터를 우선 고려하기

이 규칙들은 외워서 끝나는 규칙이 아닙니다. 코드 한 줄 쓸 때마다 떠올라야 하는 생활 습관에 가깝습니다.

마무리

동적 메모리는 C++의 힘이 가장 또렷하게 드러나는 지점 중 하나입니다. 프로그램이 실행되는 중에 필요한 만큼 메모리를 확보할 수 있다는 것은 정말 강력합니다. 하지만 그 힘에는 책임이 따라옵니다.

new는 메모리를 만들지만, 자동으로 치워 주지 않습니다. delete는 정리 도구이지만, 사람이 실수하면 누수와 댕글링 포인터 같은 위험을 부릅니다. 그래서 현대 C++은 점점 더 "손으로 직접 정리하는 코드"보다, 수명과 정리를 함께 설계하는 코드를 선호합니다. 그 생각의 중심에 RAII가 있고, 메모리 분야의 대표 도구가 스마트 포인터입니다.

이번 장에서 꼭 붙잡아야 할 문장은 이것입니다.

메모리를 제대로 다루지 못하면 C++의 강력함은 곧바로 위험으로 바뀐다.

다음 장에서는 이런 메모리 위에 올라가는 사용자 정의 타입, 즉 구조체와 클래스의 세계로 들어갑니다. 그리고 그때부터는 "값"만이 아니라 "객체의 수명"이라는 감각이 더 본격적으로 살아나기 시작할 것입니다.

참고

1Static and Dynamic Memory Allocation in C

2Dynamic memory

3C++ Memory Management (With Examples)

4Difference between dangling pointer and memory leak

5Dangling Pointers in C++

6스마트 포인터(최신 C++) | Microsoft Learn

7C++ RAII와 스마트 포인터

책 목록

초보자를 위한 C++ 수업: 기초부터 현대 C++까지 한 권으로 끝내기

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기