C++ : 7 메모리 관리

seunghyun·2023년 4월 21일
0

전문가를 위한 C++

목록 보기
3/4

목차


소개

배울 내용

  • 다양한 메모리 사용 및 관리법
  • 배열과 포인터의 관계
  • 메모리의 내부 동작 방식
  • 스마트 포인터의 개념과 사용법
  • 메모리 문제 대응법

7.1 동적 메모리 다루기

요즘은 C++에서 로우레벨 메모리 연산은 가급적 피하고 컨테이너 또는 스마트 포인터와 같은 최신 기능을 활용하는 추세이다. 하지만, 메모리의 내부 처리 과정에 대해서는 알아둘 필요가 있다.

7.1.1 메모리의 작동 과정

  • new 키워드를 사용하면 힙 메모리가 할당된다.
// ptr 변수를 스택에 생성 -> nullptr로 초기화 -> ptr가 동적으로 생성된 힙 메모리를 가리킴
int * ptr = new int;
  • 동적 메모리는 항상 힙에 할당된다.

주의할 점

  • 항상 포인터 변수를 선언하자마자 nullptr나 적절한 포인터 변수로 초기화
  • 초기화 하지 않은 상태로 냅두지 않는다.
  • 포인터가 스택과 힙에 모두 있는 예시
// 정수 포인터에 대한 포인터 선언
int** handle = nullptr; 
// 정수 포인터를 담는데 충분한 크기로 메모리 할당후 그 메모리에 대한 포인터를 handle에 저장
handle = new int*; 
// *handle에 정수를 담기 충분한 크기의 힙 메모리를 동적으로 할당
*handle = new int;

7.1.2 메모리 할당과 해제

  • 변수가 사용할 공간 new 키워드로 생성
  • 이 공간을 해제하기 위해서는 delete 키워드로 해제
  1. new와 delete 사용법
  • 변수에 필요한 메모리 블록을 할당을 위해 new에 그 변수의 타입을 지정해서 호출
  • 메모리 누수(memory leak)
    • new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나면 할당했던 메모리에 접근할 수 없음.
// int를 담을 공간만큼 메모리 누수가 발생하는 예
voide leaky() {
    new int; // 메모리 누수 발생
    cout << "메모리 누수 발생!" endl;
}
  • 한 객체에 할당했던 메모리를 다른 용도로 사용할 수 있도록 해제
// 힙 메모리 해제
int* ptr = new int;
delete ptr;
ptr = nullptr;

주의할 점

  • new로 메모리를 할당할 때 스마트 포인터가 아닌 일반 포인터로 저장했다면 반드시 그 메모리를 해제하는 delete 문을 new와 짝을 이루도록 한다.
  • 해제한 포인터는 nullptr로 다시 초기화 한다.
  1. malloc ()
  • 인수로 지정한 바이트 수만큼 메모리를 할당
  • new는 단순히 메모리를 할당하는 데 그치지 않고 객체까지 만들어준다.
// Foo 객체 생성 예시
Foo* myFoo = (Foo*)malloc(sizeof(Foo));
Foo* myOtherFoo = new Foo();
  • new를 호출한 문장은 적절한 크기의 메모리 공간이 할당될 뿐 아니라 Foo의 생성자를 호출해서 객체까지 생성한다.

C++ 에서는 malloc() free()를 사용하지말고 new와 delete 를 사용하자!

  1. 메모리 할당에 실패한 경우
  • new가 실패한다면 프로그램이 종료되는 것이 기본
  • new로 요청한 만큼 메모리가 없어서 익셉션이 발생하면 프로그램이 종료
// 익셉션이 발생하지 않는 new
int* ptr = new(nothrow) int;

7.1.3 배열

  • 서로 타입이 같은 원소들을 변수에 담아 각 원소를 인덱스로 접근
  1. 기본 타입 배열
  • 배열에 대한 메모리를 할당하면 실제 메모리에서도 연속된 공간을 할당.
int arr[5];
  • 배열을 힙에 선언할 때도 비슷함.
  • 배열의 위치를 가리키는 포인터를 사용한다는 점만 다름.
  • 다음은 int 값 다섯 개를 담는배열에 메모리를 할당하여 그 공간을 가리키는 포인터를 myArrPtr 변수에 저장
int* myArrPtr = new int[5];
  • myArrPtr의 변수는 배열의 0번째 원소를 가리킴
  • new []를 호출하면 delete [] 를 호출하여 배열에 할당했던 메모리를 해제
delete [] myArrPtr;
myArrPtr = nullptr;
  • 배열을 힙에 할당하면 실행 시간에 크기를 정할 수 있다는 장점이 있다.
Document* createDocArray() {
    size_t numDocs = askUserForNumberOfDocuments();
    Document* doArray = new Document[numDocs];
    return docArray;
}
  • 주의할 점은 new[] 를 호출한 만큼 delete[]도 호출해야 함
  • 위의 doArray는 동적으로 할당된 배열임 (동적 배열이 아님에 주의)
  • 배열 자체는 한 번 할당되면 크기가 변하지 않기 때문에 동적으로 볼 수 없다.
  • 단지, 할당할 블록의 크기를 실행 시간에 지정할 수 있을 뿐이다.
  1. 객체 배열
  • 객체에 대한 배열도 기본 타입 배열과 비슷하다.
class Simple{
    public:
        Simple() { cout << "Simple constructor called" << endl; }
        ~Simple() { cout << "Simple destructor called" << endl; }
};

Simple* mySimpleArr = new Simple[4]; // 위의 생성자 4번 호출
  1. 배열 삭제하기
  • 앞서 new [] 만큼 delete [] 를 호출해야 한다고 했다.
Simple* mySimpleArr = new Simple[4];
// mySimpleArr 사용
delete [] mySimpleArr;
mySimpleArr = nullptr;
  • 만약 delete [] 를 사용하지 않는다면 프로그램이 이상하게 동작할 가능성이 있다.
  • 다음 예제는 포인터 배열에 대해 delete[]를 호출할 때 각 원소가 가리키는 객체를 일일이 해제하는 예시이다.
const size_t size = 4;
Simple**mySimplePtrArr = new Simple*[size];

// 포인터마다 객체 할당
for (size_t i = 0; i < size; i++) { mySimplePtrArr[i] = new simple(); }

// mySimplePtrArr 사용

// 할당된 객체를 삭제
for (size_t i = 0; i < size; i ++ ) { delete mySimplePtrArr[i]; })

// 배열 삭제
delete [] mySimplePtrArr;
mySimplePtrArr = nullptr;
  1. 다차원 배열
  • 여러 인덱스 값을 사용하도록 일차원 배열의 확장판
  • 예를 들어 3x3의 격자모양 배열
char board[3][3] = {};
// 테스트 코드
board[0][0] = 'X';
board[2][1] = 'O';
  • 보통 첫 인덱스가 x축, 두 번째 인덱스는 y축임
  • 다차원 스택 배열
    • 다차원 배열의 크기는 각 차원의 크기를 서로 곱한 값에 한 원소의 메모리 크기를 곱한 값과 같다.
    • 단, 3차원을 넘어가면 잘 사용하지 않음.
  • 다차원 힙 배열
    • 차원 수를 실행 시간에 결정하고 십다면 힙 배열로 생성!
    • 힙에서는 메모리 공간이 연속적으로 할당되지 않기 때문에 스택 방식의 다차원 배열처럼 메모리를 할당하면 안 된다.
// 이차원 배열을 동적으로 할당하는 예
char** allocateCharacterBoard(size_t xDimension, size_t yDimension){
    char** myArr = new char*[xDimension]; // 첫 번째 차원의 배열 할당
    for (size_t i = 0; i < xDimension; i++){
        myArr[i] = new char[yDimension]; // i번째 하위 배열 할당
    }
    return myArr;
}

// 다차원 힙 매열을 해제하는 예
void releaseCharacterBoard(char** myAyy, size_t xDimension){
    for (size_t i = 0; i < xDimension; i++) {
        delete [] my Arr[i]; // i번째 하위 배열을 해제
    }
    delete [] myArr; // 첫 번째 차원의 배열 해제
}
  • 기존 C 스타일 배열은 메모리 안전성이 떨어지므로 가급적 사용하지 말자.
  • std::array 또는 std::vector와 같은 컨테이너를 사용하자.

7.1.4 포인터 다루기

  • 포인터는 단지 메모리 주소이기 때문에 이론상 마음대로 주소를 바꿀 수 있고 해당 메모리 값에 대한 포인터를 만들 수 있다.
// 메모리 주소 7에 대한 포인터 생성
char* dangerPtr = (chat*)7;
  • 별도로 할당된 영역이 아닌 메모리 공간을 사용하면 객체를 저장하거나 힙으로 관리하는 메모리가 손상돼 프로그램이 제대로 작동하지 않게 됨.
  1. 포인터의 작동 방식
    • "* 연산자" : 포인터를 역참조 하면 포인터가 가리키는 주소로 점프한다.
    • "& 연산자" : 특정 지점의 주소를 얻을 수 있음
  1. 포인터에 대한 타입 캐스팅
  • 포인터는 메모리 주소에 불과
  • 타입을 엄격히 따지지 않음 (XML 문서를 가리키는 포인터 크기 == 정수를 가리키는 포인터 크기)
  • 정적 캐스팅을 이용하면 관련 없는 데이터 타입으로 포인터를 캐스팅할 시 에러를 발생
Document* documentPtr = getDocument();
char* myCharPtr = static_cast<char*>(documentPtr); // 컴파일 에러
  • 상속 관계에 있는 대상끼리 캐스팅할 때에는 동적 캐스팅을 사용하면 안전하다.

7.2 배열과 포인터의 두 얼굴

  • 배열과 포인터는 서로 비슷하다고 설명하였음.
  • 포인터와 배열의 공통점이 더 있다.

7.2.1 배열 = 포인터

  • 배열의 주소는 사실 첫 번째 원소에 대한 주소이다.
// 0으로 초기화한 스택 배열을 만들고 접근하는 예시
int myIntArr[10];
int* myIntPtr = myIntArr;
// 포인터로 배열 접근
myIntPtr[4] = 5;
  • 스택 배열을 포인터로 접근하는 기능은 배열을 함수에 넘길 때 유용함
// 이 함수는 스택 배열을 전달해도 Ok, 힙 배열을 전달해도 Ok
void doubleInts(int* theArr, size_t size) {
    for (size_t i = 0; i < size; i++) {
        theArr[i] *= 2;
    }
}

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

int stackArr[] = {5, 7, 9, 11} ;
arrSize = std::size(stackArr); // <array> 사용
// arrSize = sizeof(stackArr) / sizeof(stackArr[0]) // C++17 이전 방식
doubleInts(stackArr, arrSize);
doubleInts(&stackArr[0], arrSize);
  • 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급
  • 해당 함수는 복사본이 아닌 원본을직접 수정함
  • 레퍼런스(참조) 전달 방식의 효과
  • 함수에 전달한 값이 복사본이 아닌 원본 배열의 주소이기 때문!
// 포인터가 아닌 배열 매개변수를 받더라도 원본배열 변경
void doubleInts(int theArr[], size_t size){
    for (size_t i =0; i < size; i++){
        thrArr[i] *= 2;
    }
}

// 아래의 세 표현 방식은 모두 같음
void doubleInts(int* theArr, size_t inSize);
void doubleInts(int theArr[], size_t inSize);
void doubleInts(int theArr[2], size_t inSize);
  • 이처럼 항상 포인터를 전달하므로 컴파일러가 배열을 복사하는 코드를 추가할 필요 없음
  • 배열 문법으로 선언한 배열은 포인터로도 접근 가능
  • 컴파일러는 함수로 전달하는 배열을 항상 포인터로 취급

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

  • 배열이 아닌 포인터를 배열 문법으로 표현하면 버그가 발생
int* ptr = new int;
  • ptr은 정상적인 포인터이지만 배열은 아님.
  • ptr[1]에 있는 메모리에어떤 값이 있을지 모르기 때문

모든 배열은 포인터로 참조가 가능하지만, 반대로 모든 포인터가 배열은 아니다!

7.3 로우레벨 메모리 연산

  • C++은 메모리에 신경을 덜 쓸 수 있다는 것이다.
  • 메모리 관리 작업을 클래스 단위로 나누기 때문에 사용성이 크게 높아진다.

7.3.1 포인터 연산

  • 포인터를 int로 선언 후 그 값을 1만큼 증가시키면 메모리에서 한 바이트가 아닌, int 크기 만큼 이동한다.
  • 이 연산은 배열을 다룰 때 용이
// int 타입의 힙 배열 선언
int* myArr = new int[8];

// index 2에 값 넣기
myArr[2] = 333;

// 포인터 연산으로 표현
*(myArr + 2) = 333;
  • 포인터 연산에서 뺄셈도 유용함.
  • 같은 타입의 포인터를 빼면 두 포인터 사이에 몇 바이트가 있는지가 아니라,
  • 지정한 타입의 원소가 몇 개 있는지 알 수 있음

7.3.2 커스템 메모리 관리

  • 기본으로 제공하는 메모리 할당 기능만으로 대부분의 일을 처리할 수 있음.
  • 메모리를 직접 관리하면 좋은점?
    • 오버헤드를 줄일 수 있음.
    • 오버헤드 : new로 메모리를 할당하면 현재 프로그램에서 얼마나 할당했는지 기록하는데 필요한 공간을 말함.
    • 이를 통해 필요한 만큼만 delete를 호출(15장에서 계속..)

7.3.3 가비지 컬렉션

  • 메모리를 정상 상태로 유지하기 위한 최후의 보루
  • 프로그래머가 객체에 할당된 메모리를 직접 해제할 일은 거의 없음
  • 참조하지 않는 객체는 런타임 라이브러리에 의해 일정한 시점에 자동으로 해제
  • 구현하는 기법 중 표시 후 쓸기(mark and sweep) 알고리즘이 있다.
  • 프로그램에 있는 모든 포인터를 주기적으로 검사한 뒤, 참조하는 메모리를 사용하고 있는지에 대한 여부를 표시.
  • 만약 한 주기가 끝날 시점에 아무런 표시가 없다면 해제

7.3.4 객체 풀

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

7.4 스마트 포인터

  • 동적으로 할당한 메모리를 관리하기 쉽다.
  • 메모리 누수를 방지하려면 스마트 포인터를 적극 활용하는 것이 좋다.
  • 스마트 포인터가 스코프를 벗어나거나 리셋되면 할당된 모든 리소스가 자동으로 해제
  • 표준 라이브러리에서 제공하는 std::unique_ptr가 고유(단독) 소유권 방식을 지원
  • 리소스의 소유자를 추적하도록 레퍼런스 카운팅(참조 횟수 계산 방식) 을 구현한 스마트 포인터도 있음.
  • 이는 레퍼런스 카운트가 0이되면 리소스를 사용하는 곳이 없기 때문에 스마트 포인터에 의해 자동으로 해제.
  • std::shared_ptr가 이 방식을 사용한 공유 소유권 방식을 지원함
  • 이 두 ptr는 <memory> 헤더 파일을 인클루드 해야 함.

7.4.1 unique_ptr

  1. unique_ptr 생성 방법
// Simple 객체를 힙에 할당한 뒤, 해제하지 않고 끝내서 메모리 누수 현상 발생
void leaky() {
    Simple* mySimplePtr = new Simple(); // 메모리를 해제하지 않음
    mySimplePtr->go();
}

// Simple 객체를 동적으로 할당, 사용 후 delete 호출
// 이 경우에도 메모리 누수 발생 가능성 있음
void couldBeLeaky() {
    Simple* mySimplePtr = new Simple();
    mySimplePtr->go();
    delete mySimplePtr;
}

// make_unique() & auto 키워드 적용
void notLeaky(){
    auto MySimpleSmartPtr = make_unique<Simple>();
    mySimpleSmartPtr->go();
}

unique_ptr를 생성할 때, 항상 make_unique()를 사용하자.

  1. unique_ptr 사용 방법
  • 위의 예제에서 처럼 go() 메서드를 호출할 때 "->" 연산자를 사용함.
(*mySimpleSmartPtr).go();
  • 일반 포인터처럼 작성 가능
// get() 메서드 이용 예시
void processData(Simple*simple) { /* do something*/ }

auto mySimpleSmartPtr = make_unique<Simple>();
processData(mySimpleSmartPtr.get());

// reset()을 사용하면 unique_ptr의 내부 포인터해제
mySimpleSmartPtr.reset(); // 리소스 해제 후 nullptr로 초기화
mySimpleSmartPtr.reset(new simple()); // 리소스 해제 후 새로운 Simple 인스턴스 설정

// release()를 사용하면 unique_ptr와 내부 포인터의 관계를 끊을 수 있음.
Simple* simple = mySimpleSmartPtr.release(); // 소유권 해제
// simple 포인터 사용 ..
delete simple;
simple = nullptr;
  • unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다.
  1. unique_ptr와 C 스타일 배열
  • 기존 C 스타일의 동적 할당 배열을 저장하는데 적합
auto myVariableSizedArray = make_unique<int[]>(10);
  • 이보다는 std::array, std::vector와 같은 컨테이너를 사용하는 것이 바람직
  1. 커스텀 제거자
// unique_ptr 방식 변경
int* mallock_int(int value){
    int* p = (int*)malloc(sizeof(int));
    *p = value;
    return p;
}

int main() {
    unique_ptr<int, decltype(free*)> myIntSmartPtr(malloc_int(42), free);
    return 0;
}
  • malloc_int()로 정수에 대한 메모리 할당
  • 예로 파일이나 네트워크 소켓 등을 가리키던 unique_ptr가 스코프를 벗어날 때 이러한 리소스를 자동으로 닫는 데 활용 가능

7.4.2 shared_ptr

  • 반드시 make_shared( )로 생성함
auto mySimpleSmartPtr = make_shared<Simple>();
  • shared_ptr도 get()과 reset() 메서드를 제공함. (release()는 지원 X)
  • 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 확인
// shared_ptr로 파일 포인터를 저장하는 예시
void CloseFile(FILE* filePtr){
    if (filePtr == nullptr)
        return;
    fclose(filePtr);
    cout << "File closed." << endl; 
}

int main() {
    FILE* f = fopen("data.txt", "w");
    shared_ptr<FILE> filePtr(f, CloseFile);
    if (filePtr == nullptr){
        cerr << "Error opening file." << endl;
    } else {
        cout << "File opened." << endl;
        // filePtr를 사용 ...
    }
    return 0;
}
  • 위의 예제에서 shared_ptr의 스코프를 벗어나면 CloseFile()이 호출되면서 파일 포인터가 자동으로 닫힘.
  1. 레퍼런스 카운팅이 필요한 이유
  • 레퍼런스 카운팅은 어떤 클래스의 인스턴스 수나 현재 사용 중인 특정한 객체를 추적하는 매커니즘이다.
  • 실제 참조하는스마트 포인터 수를 추적하여 스마트 포인터가 중복 삭제되는 것을 방지한다.
  1. 앨리어싱
  • shared_ptr는 앨리어싱을 지원한다.
  • 한포인터를 다른 shared_ptr와 공유하면서 다른 객체를 가리킬 수 있음.
  • 예를들어 shared_ptr가 객체를 가리키는 동시에 그 객체의 멤버도 가리키게 할 수 있다.
class Foo {
    public:
        Foo(int value) : mData(value) {}
        int mData;
};

auto foo = make_shared<Foo>(42);
auto aliasing = shared_ptr<int>(foo, &foo->mData);
  • 두 shared_ptr(foo, aliasing)가 모두 삭제될 때만 Foo 객체가 삭제됨!

7.4.3 weak_ptr

  • shared_ptr가 가리키는 리소스의 레퍼런스를 관리하는데 사용

  • 리소스를 직접 소유하지 않음 -> shared_ptr가 해당 리소스를 해제하는데 영향을 미치지 않음

  • 삭제될 때, 가리키던 리소스를 삭제하지 않고 shared_ptr가 리소스를 해제했는지 알아낼 수 있음.

  • 생성자는 shared_ptr나 다른 weak_ptr를 인수로 받음

  • weak_ptr 포인터에 접근하려면 shared_ptr로 변환!

    • 변환 방법
    1. weak_ptr의 lock( ) 메서드를 이용하여 shared_ptr를 리턴받는다.
    2. shared_ptr의 생성자에 weak_ptr를 인수로 전달해서 shared_ptr를 새로 생성
// weak_ptr 사용 예제
void useResource(weak_ptr<Simple>& weakSimple){
    auto resource = weakSimple.lock();
    if (resource) {
        cout << "Resource still alive." << endl;
    } else {
        cout << "Resource has been freed!" << endl;
    }
}

int main() {
    auto sharedSimple = make_shared<Simple>();
    weak_ptr<Simple> weakSimple(SharedSimple);

    // use weak_ptr
    useResource(weakSimple);

    // shared_ptr 리셋
    // Simple 리소스에 대한 shared_ptr는 하나뿐
    // weak_ptr가 살아 있더라도 리소스 해제
    sharedSimple.reset();

    // weak_ptr를 한 번 더 사용.
    useResource(weakSimple);

    return 0;
}
  • 실행결과는 다음과 같다.
    • Simple constructor called!
    • Resource still alive.
    • Simple destructor called!
    • Resource has been freed!

7.4.4 이동 의미론

  • 스마트 포인터는 모두 성능 향상을 위해 이동 의미론을 지원
  • 이동 의미론을 이용하면 함수에서 스마트 포인터를 리턴하는 과정을 효율적으로 처리할 수 있음!
  • 아래의 예제에서 create( ) 함수를 작성 후 main( ) 함수에서 호출 가능
unique_ptr<Simple> create(){
    auto ptr = make_unique<Simple>();
    // ptr 사용 ...
    return ptr;
}

int main() {
    unique_ptr<Simple> mySmartPtr1 = create();
    auto mySmartPtr2 = create();
    return 0;
}

7.4.5 enable_shared_from_this

  • 믹스 클래스인 enable_shared_from_this 를 이용하면 shared_ptr, weak_ptr을 안전하게 리턴 가능

제공하는 메서드

  • shared_from_this() : 객체의 소유권을 공유하는 shared_ptr 리턴
  • weak_from_this() : 객체의 소유권을 추적하는 weak_ptr 리턴
  • 사용방법은 다음 예제와 같다
class Foo : public enable_shared_from_this<Foo>{
    public:
        shared_ptr<Foo> getPointer() {
            return shared_from_this();
        }
};

int main() {
    //make_shared()로 Foo 인스턴스를 담은 shared_ptr인 ptr1을 생성
    auto ptr1 = make_shared<Foo>();
    auto ptr2 = ptr1 -> getPointer();
}
  • 객체의 포인터가 shared_ptr에 이미 저장된 상태에서만 객체에 shared_from_this()를 사용할 수 있다는 점에 주의

7.4.6 현재는 폐기된 auto_ptr

  • C++11과 C++14 부터는 auto_ptr를 공식적으로 지원하지 않는다고 선언
  • C++17에서는 완전히 삭제되어지면서 빈자리를 unique_ptr과 shared_ptr가 대체
  • 절대 사용하면 안된다는 것.

7.5 흔히 발생하는 메모리 문제

  • 메모리 누수나 잘못된 포인터가 발생하는 원인은 코드마다 다양
  • 흔히 발생하는 문제의 유형이 알려져 있고 이를 해결하는 데 도움이 되는 몇가지 도구가 존재

7.5.1 스트링 과소 할당 문제

  • C 스타일 스트링에서 가장 흔히 발생하는 문제 과소 할당(underallocation) 이다.
  • 스트링 끝을 나타내는 널문자가 들어갈 공간을 빼먹고 공간을 할당할 때 발생
  • 프로그래머가 스트링의 최대 크기를 특정 값으로 미리 정해둘 때도 발생
// 과소 할당 예시
char buffer[1024] = {0}; // 버퍼 공간 확보
while (1) {
    char* nextChunk = getMoreData();
    if (nextChunk == nullptr) {
        break;
    } else {
        strcat(buffer, nextChunk); // 버퍼 공간이 넘칠 수 있음.
        delete [] nextChunk;
    }
}
  • 읽은 데이터를 C 스타일 스트링에 저장. (반복문으로 처리하는 이유 : 네트워크를 통해 한 번에 받을 수 있는 데이터의 양이 제한되기 때문)
  • getMoreData() : 동적으로 할당한 메모리에 대한 포인터 리턴
  • 데이터를 다 받고 나면, getMoreData()는 nullptr를 리턴
  • C 함수인 strcat ()은 스트링 인수 두 개를 받아 첫 번째 스트링 뒤에 두 번째 스트링 연결
  • 이 과정에서 버퍼의 크기가 결과로 나올 스트링의 크기보다 커야함!

과소 할당 문제를 해결하는 방법

  1. C++ 스타일 스트링 사용하기
  2. 버퍼를 전역 변수 or 스택 변수로 만들지 말고 힙 공간에 할당하기
  3. 최대 문자 수를 입력받아서 그 길이를 넘어선 부분은 리턴하지 않고 현재 버퍼에 남은 공간과 현재 위치를 항상 추적하기

7.5.2 메모리 경계 침범

  • 포인터는 단지 메모리 주소일 뿐, 메모리에서 아무 곳이나 가리킬 수 있음.
  • 하지만 정말 아무 곳이나 가리키면 문제 발생
// 스트링의 모든 문자를 'm'으로 바꾸는 함수를 호출하면 루프의 종료 조건을 만족하지 못함.
// 스트링에 할당된 공간을 지나도 계속 'm'으로 채움
void fillWithM(char* inStr){
    int i = 0;
    while (inStr[i] != '\0') {
        inStr[i] = 'm';
        i++;
    }
}
  • 문제가 스트링에서 발생하는 것이 아니라 배열에서 발생하는 것을 버퍼 오버플로 에러 라고 부름.
  • 이 버그로 벗어난 메모리 영역을 덮어쓰는 방식으로 현재 구동 중인 프로그램에 악의적인 코드를 주입
  • C++의 고급 기능을 사용하면 C 스타일의 스트링 or 배열을 사용할 때 흔히 발생하는 버그 방지

string, vector 와 같이 메모리 관리 기능을 제공하는 C++ 기능을 활용하도록 하자!

7.5.3 메모리 누수

  • 이는 프로그래밍 과정에서 발견하거나 해결하기 어려운 작업임
  • 이때, 스마트 포인터를 사용하자!
  • 이 현상은 할당했던 메모리를 제대로 해제하지 않을 때 발생
  • new에 대응하도록 delete를 작성하여도 메모리 누수 현상이 발생하는 경우가 있음.

class Simple {
    public:
        Simple() { mIntPtr = new Int(); }
        ~Simple() { delete mIntPtr; }
        void setValue(int value) { *mIntPtr = value; }
    
    private:
        int* mIntPtr;
};

void doSomething(Simple*& outSimplePtr){
    outSimplePtr = new Simple(); // 원본 객체를 삭제하지 않음!

}

int main() {
    Simple* simplePtr = new Simple(); // Simple 객체 할당
    doSomething(simplePtr);
    delete simplePtr; // 두 번째 객체만 해제
    return 0;
}
  • doSomething()을 호출할 때 포인터 변수가 레퍼런스로 전달된다는 사실을 모르고, 포인터에 다른 값을 할당할 수 있다는 사실을 예상치 못함.
  • doSomething()의 주석이 이 사실을 명시하지 않았기 때문에 문제가 발생

위의 예제는 메모리 누수 현상을 재현하기 위한 목적으로 작성함. 실전에서는 mIntPtr와 simplePtr를 unique_ptr로 만들고 outSimplePtr를 unique_ptr에 대한 레퍼런스로 만들자.

  1. 비주얼 C++을 이용한 Window App 메모리 누수 탐지 및 수정법
  • 마이크로소프트 비주얼 C++을 사용한다면 디버그 라이브러리에서 기본으로 제공하는 메모리 누수 감지 기능을 활용할 수 있다.
  • MFC 프로젝트를 생성할 때 자동으로 활성화
  • MFC가 아닌 다른 프로젝트에서 이 기능을 사용하려면 다음과 같이 코드의 첫머리에 세 문장 추가
#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>
  • 반드시 위의 순서대로 작성! 또한 new 연산자를 다음과 같이 새로 정의
#ifdef _DEBUG
    #ifndef DBG_NEW
        #define DBG_NEW new ( _NORMAL_BLOCK, __FILE__, __LINE__ )
        #define new DBG_NEW
    #endif
#endif // _DEBUG
  • new 연산자를 #ifndef DBG_NEW 구문 안에 정의.
  • 디버그 모드로 컴파일 해야 새로 정의한 new가 적용되어 진다.
  • 마지막으로 main() 함수 첫 부분에 작성
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
  • app이 종료될 때, CRT(C RunTime) 라이브러리는 감지된 모든 메모리 누수 현상을 디버그 콘솔에 출력하게 된다.
  • 결과를 통해 메모리가 할당되었지만 해제되지 않은 부분이 어느 파일, 어느 부분에 있는지 알 수 있음.
  • 이 도구는 문제를 찾기만 할 뿐 고쳐주지는 않음.
  1. 밸그라인드를 이요한 리눅스 애플리케이션의 메모리 누수 탐지 및 해결법
  • 벨그라인드는 무료로 제공되는 리눅스용 오픈소스 도구
  • 할당된 객체를 해제하지 않는 지점을 줄 단위로 찾아주는 점이 특징

메모리 누수 현상이 발생하지 않도록 최대한 스마트 포인터를 적용하도록 하자.

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

  • 댕글링 포인터(dangling pointer)

    • delete로 포인터에 할당된 메모리를 해제하면 그 메모리를 프로그램의 다른 부분에서 사용할 수 있다.
    • 하지만 그 포인터를 계속 쓰는 것을 막을 수는 없다.
  • 이때, 중복 삭제를 하면 문제가 발생함

  • 한 포인터에 delete를 두 번 적용하면 이미 다른 객체를 할당한 메모리를 해제하기 때문임

  • 메모리 누수 감지 기능을 제공하는 도구는 중복 삭제 문제와 해제된 객체를 계속 사용하는 문제를 감지하는 기능도 함께 제공

7.6 요약

  • 동적 메모리에 관련된 문제를 방지하기 위한 두 가지 사항

    1. 포인터 내부 작동 방식 이해하기
    2. 동적 메모리 관련 문제가 발생하지 않도록 가능하면 C++의 string 클래스, vector 컨테이너, 스마트 포인터 객체를 사용하자
  • C 스타일 구문, 함수를 최대한 피하고 C++ 구문을 사용하도록 하자

profile
game client programmer

0개의 댓글