[전문가를 위한 C++] 7장 : 메모리 관리

2026. 2. 23. 23:56

7. 1 동적 메모리 다루기

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

int i{7};
  • i를 자동 변수라 부르며 스택에 저장된다
  • 프로그램의 실행 흐름이 이 변수가 선언된 스코프를 벗어나면 할당된 메모리가 자동으로 해제됨
int* ptr {nullptr};
ptr = new int;

int* ptr = {new int};
  • new 키워드를 사용하면 free store에 메모리가 할당된다.
  • 다음 코드는 ptr 변수를 스택에 생성하고 nullptr로 초기화한 뒤 프리스토어에 할당된 메모리를 ptr이 가리키도록 설정한다.
  • ptr 변수는 여전히 스택에 있지만 프리스토어의 메모리를 기리킴.
int** handle {nullptr};
handle = new int*;
*handle = new int;
  • 포인터가 스택과 프리스토어에 모두 있는 예

7.1.2 메모리 할당과 해제

new와 delete 사용법

    • new를 호출하면 할당된 메모리에 대한 포인터가 리턴된다.
    • new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나게 되면 할당된 메모리에 접근할 수 없게 된다. 이를 메모리 누수(memory leak)이라고 부른다.
      delete ptr;

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

  •  

malloc()

  • c에서는 malloc() 사용
  • 하지만 c++에서는 malloc보다 new를 사용하는 것이 더 바람직하다.
    Foo* myFoo {(Foo*)malloc(sizeof(Foo))};
    Foo* myOtherFoo {new Foo()};

메모리 할당에 실패한 경우

  • new가 실패하면 익셉션을 던진다. 예를 들어 요청한 만큼 메모리가 없을 때 그렇다
int* ptr {new(nothrow) int};
  • 익셉션을 던지지 않는 new

7.1.3 배열

기본 타입 배열

int myArray[5] {};

int* myArrayPtr{new int[5]};
  • myArrayPtr은 스택에 저장
  • 배열의 원소는 프리스토어에 저장
Document* createDocArray() {
    size_t numDocs { askUserForNumberOfDocuments()};
    Document* docArray {new Document[numdocs]};
    return docArray;
}
  • new를 호출한 만큼 delete도 호출해야한다.
  • docArray는 동적으로 할당된 배열이다. 이는 동적배열과 다르다. 배열을 할당하고 나면 원소 개수가 변하지 않기 때문에 동적이지 않다.

객체 배열

Simple* mySimpleArray { new Simple[4]};

배열 삭제하기

Simple* mySimpleArray{ new Simple[4]};
delete [] mySimpleArray;
mySimpleArray = nullptr;
  • 배열 버전 delete [] 사용
const size_t size {4};
Simple** mySimplePtrArray { new Simple*[size]};

for(size_t i {0}; i<size; i++) { mySimplePtrArray[i] = new Simple{};}

for(size_t i {0}; i<size;i++) {
    delete mySimplePtrArray[i];
    mySimplePtrArray[i] = nullptr;
}

delete [] mySimplePtrArray;
mySimplePtrArray = nullptr;
  • 배열의 원소가 객체일 때만 소멸자가 호출된다.
  • 포인터 배열에 대해 delete[]를 호출할 때는 각 원소가 가리키는 객체를 일일이 해제해야 한다.

다차원 배열

char board[3][3] {};
다차원 스택 배열
다차원 프리스토어 배열
char** board { new char[i][j]}; // 컴파일 에러
  • 프리스토어 배열에 대한 메모리 할당 방식은 스택 배열과 다르기 때문에 이렇ㄱ ㅔ작성하면 에러
  • 프리스토어 배열에서는 메모리 공간이 연속적으로 할당되지 않음
char** allocateCharagterBoard(size_t xDimension, size_t yDimension)
{
    char** myArray { new char*[xDimension]};
    for(size_t i{0}; i<xDimension; i++) {
        myArray[i] = new char[yDimension];
    }
    return myArray;
}

기존 c 스타일의 배열은 메모리 안전성이 떨어지도록 가급적 사용하지 않는 것이 좋다

7.1.4 포인터 다루기

char* scaryPointer {(char*) 7};
  • 포인터 남용
  • 메모리 주소 7에 대한 포인터를 만든다. 이 포인터는 어떤 값을 가리키거나 애플리케이션의 다른 영역에서 사용하는 공간일 가능성이 높다.
  • new를 호출하거나 스택에 생성된 것처럼 별도로 할당된 것이 아닌 메모리 공간을 사용하면 객체를 저장하거나 프리스토어 관리에 사용되는 메모리가 손상되어 프로그램이 제대로 작동하지 않게 된다.

포인터의 작동 방식

  • 포인터는 메모리의 한 지점을 가리키는 숫자
  • * 연산자로 포인터를 역참조하면 메모리에서 한단계 더 들어가볼 수 있다. 포인터를 주소 관점에서 보면 역참조는 포인터가 가리키는 주소로 점프하는 것
  • & 연산자를 사용하여 특정 지점의 주소를 구하면 메모리에 대한 참조 단계가 하나 더 늘어난다. 이 연산자를 주소관점으로 보면 특정 지점을 숫자로 표현한 주소이다. 공간 관점에서 보면 & 연산자는 표현식으로 지정한 지점을 기리키는 화살표를 생성한다고 볼 수 있다.

포인터에 대한 타입 캐스팅

  • 포인터는 단지 메모리 주소에 불과해서 타입을 엄격히 따지지 않는다.
  • 포인터의 타입은 C 스타일 캐스팅으로 얼마든지 바꿀 수 있다.
Docmuent* documentPtr {getDocument()};
char* myCharPtr {static_cast<char*>(documentPtr)};

7.2 배열과 포인터의 두 얼굴

  • 배열과 포인터가 서로 비슷하다. 프리스토어에 할당된 배열은 첫번째 원소를 기리키는 포인터로 참조한다. 스택에 할당된 배열은 배열문법([])으로 참조한다. 이 부분만 빼면 일반 변수 선언과 같다.

7.2.1 배열 = 포인터

  • 프리스토어 배열을 참조할 때만 포인터를 사용하는 것은 아니다. 스택 배열에 접근할 때도 포인터를 사용할 수 있다.
    int myIntArray[10] {};
    int* myIntPtr {myIntArray};
    myIntPtr[4] = 5;
void doubleInts(int* theArray, size_t size)
{
    for(size_t i {0}; i< size;i++) {theArray[i] *= 2;}
}

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

int stackArray[] {5,7,9,11};
arrSize = std::size(stackArray);
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);
  • 이 함수를 호출할 때 스택 배열을 전달해도 되고 프리스토어 배열을 전달해도 된다.
  • 프리스토어 베열을 전달하면 이미 포인터가 있어서 값으로 전달
  • 스택 배열의 경우 호출하는 측에서 배열 변수를 전달하면 컴파일러가 알아서 배열에 대한 포인터로 변환한다.
  • 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급한다.
  • 배열을 인수로 받아서 그 안에 담긴 값을 변경하는 함수는 복사본이 아닌 원본을 직접 수정한다.
  • 포인터와 마찬가지로 배열을 전달하면 실제로 pass-by-reference의 효과를 나타낸다. 함수에 전달한 값이 배열의 복사본이 아닌 원본을 가리키는 주소이기 때문
void doubleInts(int* theArray, size_t inSize);
void doubleInts(int* theArray[], size_t inSize);
void doubleInts(int* theArray[2], size_t inSize);

// 세가지 모두 같다
  • 컴파일러는 이 함수의 프로토타입에서 theArray 뒤의 대괄호 사이에 나온 숫자를 무시한다. 그러므로 다음과 같이 세 가지 방식으로 표현한 문장은 모두 같다.
  • 함수 정의 부분에 배열 문법을 사용하면 컴파일러가 그 배열을 복사해야 한다고 생각할 수 있다.
  • 하지만 성능 때문에 배열에 담긴 원소를 모두 복사하는 데 시간이 걸리고 메모리 공간도 상당히 차지함
  • 따라서 항상 포인터를 전달. -> 컴파일러가 배열을 복사하는 코드를 추가할 수 없다.

c++20부터는 함수에 c 스타일 배열을 전달할 때 직접 c 스타일 배열로 전달하지 말고, 18장에서 설명하는 std::span을 사용하는 것이 좋다

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

  • 모든 배열은 포인터로 참조할 수 있지만 그렇다고 포인터가 배열인 것은 아니다

7.3 로우레벨 메모리 연산

  • c++에서는 메모리에 대해 신경 쓸 일이 훨씬 적다
  • 하지만 레거시, 효율, 디버깅, 또는 호기심으로 원시 바이트를 사용하는 몇가지 테크닉을 알아두면 도움이 된다7.3.1 포인터 연산
  • myArray[2] = 33;

*(myArray + 2) = 33;


### 7.3.2 커스텀 메모리 관리
- 메모리를 직접 관리하기 위해 new와 delete를 오버로딩하는 방법이 있음 15장에서 자세히 소개

### 7.3.3 가비지 컬렉션
- c++은 자바나 c#과 달리 가비지 컬렉션을 제공하지 않는다. c++에서는 스마트 포인터로 메모리를 관리할 수 도록 개선되었지만 예전에는 new와 delete 사용
- 뒤에서 설명하는 shared_ptr과 같은 스마트 포인터는 가비지커렉션과 상당히 비슷한 방식으로 메모리를 관리한다. 

- 가비지컬렉션을 구현하는 기법 중 *mark and sweep* 이란 알고리즘이 있다. 이 알고리즘을 c++로 구현하기란 쉽지 않다. 

### 7.3.4 객체 풀
29장에서 자세히 설명

## 7.4 흔히 발생하는 메모리 관련 문제

### 7.4.1 데이터 버퍼 과소 할당과 경계를 벗어난 메모리 접근
c 스타일 스트링에서 가장 흔히 발생하는 문제로 과소할당이 있다.

과소 할당 문제를 해결하는 방법
1. c++ 스타일 스트링을 사용한다. 그러면 스트링을 연결하는 작업에 필요한 메모리를 알아서 관리한다.
2. 버퍼를 글로벌 변수나 스택 변수로 만들지 말고 프리스토어 공간에 할당한다. 공간이 부족하면 현재 스트링보다 큰 공간을 추가로 할당하고, 원본 버퍼를 새 버퍼로 복사한 뒤, 두 스트링을 연결하고 나서 원본 버퍼를 삭제
3. 최대 문자 수를 입력받아서 그 길이를 넘어선 부분은 리턴하지 않고, 현재 버퍼에 남은 공간과 현재 위치를 항상 추적하도록 getMoreData()를 만든다

### 7.4.2 메모리 누수(memory leak)
-  작성한 프로그램이 의도한 대로 결과를 내다가 실행 횟수가 늘어날수록 메모리 공간을 잡아먹는다면 메모리 누수 현상이 발생할 것이다.
- 메모리 누수 현상은 할당했던 메모리를 제때 해제하지 않을 때 발생한다.
    - 객체를 가리키는 포인터를 놓치면 그 객체를 삭제할 방법이 없다

```cpp
void doSomething(Simple*& outSimplePtr) // Simple* 에 대한 참조
{
    outSimplePtr = new Simple{}; // 버그! 원본 객체를 삭제하지 않았다
    // 원본 객체를 delete 안함. 원본 아무도 가리키지 않음. 접근 불가능. 메모리 누수
}

int main() {
    Simple* simplePtr { new Simple{} };
    doSomething(simplePtr);
    delete simplePtr; // 두번째 객체만 해제한다
}
  • doSomething에서 포인터 변수 원본이 넘어감. 포인터 변수가 바뀜

7.4.3 중복 삭제와 잘못된 포인터

  • 포인터에 할당된 메모리를 delete로 해제하면 그 메모리를 프로그램의 다른 부분에서 사용가능
  • 하지만 이 상태에서도 여전히 그 포인터를 게속 사용할 수 있다. 이를 dangling pointer라고 한다
  • 이때 중복 삭제 하면 문제가 발생한다.
  • 중복 삭제로 해제된 메모리의 재사용을 방지하려면 메모리를 해제한 후에는 항상 포인터 값을 nullptr로 초기화!!

7.5 스마트 포인터

리소스를 할당한 결과를 절대로 일반 포인터로 표현하면 안됨. 스마트포인터 or RAII 클래스를 사용

7.5.1 unique_ptr

  • unique_ptr은 단독 소유권을 제공한다. unique_ptr이 제거되거나 리셋되면 이 포인터가 가리키던 리소스가 자동으로 해제됨.
  • return문을 실행하거나 익셉션이 발생했을 때도 해제된다.

unique_ptr 생성 방법

void notLeaky()
{
    auto mySimpleSmartPtr { make_unique<Simple>()};
    mySimpleSmartPtr->go();
}
  • 이 코드는 make_unique()와 auto 키워드 동시에 적용
  • make_unique는 값 초기화를 사용한다.
unique_ptr<Simple> mySimpleSmartPtr { new Simple() };
// 이렇게도 호출 가능

unique_ptr을 생성할 때는 항상 make_unique()를 사용한다

unique_ptr 사용방법

mySimpleSmartPtr->go();

(*mySimpleSmartPtr).go();
void processData(Simple* simple) { }

processData(mySimpleSmartPtr.get());
  • get() 메서드를 이용하면 내부 포인터에 직접 접근할 수 있다. 이는 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 유용하다
mySimpleSmartPtr.reset(); // 리소스 해제 후 nullptr로 초기화
mySimpleSmartPtr.reset(new Simple{}); // 리소스 해제 후 새로운 Simple 인스턴스로 설정
  • reset()을 이용하면 unique_ptr의 내부 포인터를 해제하고, 필요하다면 다른 포인터로 변경할 수 있다.
    Simple* simple { mySimpleSmartPtr.release() };
    // simple 포인터를 사용하는 코드
    

delete simple;
simple = nullptr;

- release()를 이용하면 unique_ptr과 내부 포인터의 관계를 끊을 수 있다.

- unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. 하지만 9장에서 소개하는 std::move() 이동시킬 수 있다.

#### unique_ptr 과 C 스타일 배열

```cpp
auto myVariableSizedArray { make_unique<int[]>(10)};
  • unqiue_ptr은 C 스타일의 동적 할당 배열을 저장하는데 적합하다.
  • make_unique는 배열이 아닐 때와 마찬가지로 배열의 모든 원소에 대해 값 초기화를 사용한다. 기본 타입인 경우 0으로 초기화된다.

커스텀 제거자

  • unique_ptr은 new와 delete로 메모리를 할당하거나 해제한다.
  • unique_ptr로 커스텀 제거자를 작성하는 문법은 좀 지저분하다. 작성하는 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하기 때문이다.

7.5.2 shared_ptr

  • unique_ptr은 복제할 수 없기 때문에 이런 용도로 사용할 수 없다.
  • 대신 std::shared_ptr이란 스마트 포인터를 통해 복제 가능한 공유 소유권을 제공한다.

shared_ptr을 생성해서 사용하기

auto mySimpleSmartPtr { make_shared<Simple>() };
  • make_shared()도 make_unique()처럼 값 초기화를 사용한다. 값 초기화를 사용하고 싶지 않다면 make_shared_for_overwrite()로 디폴트 초기화를 할 수 있다.
  • shared_ptr도 unique_ptr처럼 get()과 reset() 메서드를 제공한다. 다만 reset()을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr이 제거되거나 리셋될 때 리소스가 해제된다는 점이 다르다.
  • release()를 지원하지 않는다.
  • 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알아낼 수 있다.

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

  • 레퍼런스 카운팅 : 클래스의 인스턴스 수나 현재 사용중인 특정 객체를 추적하는 메커니즘
  • 레퍼런스 카운팅을 지원하는 스마트 포인터는 실제 포인터를 참조하는 스마트 포인터의 개수를 추적한다.
  • 카운트가 0에 다다르면 해당 리소스를 아무도 갖고 있지 않다는 뜻이므로 마지막 남은 스마트 포인터가 그 리소스를 해제한다.
auto smartPtr1 { make_shared<Simple>()};
auto smartPtr2 {smartPtr1}; // 포인터를 복제한다
  • 이렇게 두 스마트 포인터가 모두 스코프를 벗어나거나 리셋되더라도 Simple 인스턴스가 단 한번만 해제된다

shared_ptr 캐스팅하기

  • const_pointer_cast(), dynamic_pointer_cast(), static_pointer_cast(), reinterpret_pointer_cast()로 캐스팅할 수 있다.

앨리어싱

  • shared_ptr은 앨리어싱을 지원한다.
  • 한 포인터(소유한 포인터)를 다른 shared_ptr과 공유하면서 다른 객체를 가리킬 수 있다. 예를 들어 shared_ptr이 어떤 객체를 소유하는 동시에 그 객체의 멤버도 가리키게 할 수 있다.
auto foo { make_shared<Foo>(42) };
auto aliasing { shared_ptr<int> {foo, &foo->m_data }};
  • 형태
    shared_ptr<T>(const shared_ptr<U>& owner, T* ptr);

7.5.3 weak_ptr

7.5.4 함수에 전달하기

  • 매개변수에서 포인터를 받는 함수는 소유권을 전달하거나 공유할 경우에만 스마트 포인터를 사용해야 한다.
  • shared_ptr을 소유권을 공유하려면 shared_ptr을 값으로 전달받으면 된다.

7.5.5 함수에서 리턴하기

  • 함수에서 스마트 포인터를 효율적으로 리턴한다는 사실만 기억하면 충분
    • 리턴값 최적화(return value optimization RVO)
    • 리턴값 최적화(named return value optimization NRVO)
unique_ptr<Simple> create()
{
    auto ptr { make_unique<Simple>() };
    return ptr;
}

7.5.6 enable_shared_from_this

  • std::enable_shared_from_this를 상속해서 클래스를 만들면 객체에 대해 호출한 메서드가 자신에게 shared_ptr이나 weak_ptr을 안전하게 리턴할 수 있다.
  • shared_from this() : 객체의 소유권을 공유하는 shared_ptr을 리턴하다
  • weak_from_this() : 객체의 소유권을 추적하는 weak_ptr을 리턴한다.

7.5.7 현재는 폐기된 auto_ptr

  • c++11 이전에는 표준 라이브러리에서 스마트 포인터를 간단히 구현한 auto_ptr을 제공했다
  • 단점이 많아서 폐기됨
    • vector와 같은 표준 라이브러리 컨테이너 안에서는 제대로 작동하지 않는다는 점이다.

절대로 사용 XXXX

BELATED ARTICLES

more