본문 바로가기
프로그래밍/C & C++

[C++] 메모리 관리 (2) - 메모리 문제 유형과 해결 방법

by 별준 2022. 2. 7.

References

Contents

  • 흔히 발생하는 메모리 문제 유형
  • 메모리 누수 감지 도구 (Visual C++, Valgrind)

이전 포스팅에 이어서 C++의 메모리 관리에 대해서 알아보도록 하겠습니다.

[C++] 메모리 관리 (1) - 동적 메모리, 배열과 포인터

이번 포스팅에서는 먼저 흔하게 발생하는 메모리 문제 유형들과 이러한 문제들을 피하는 방법들에 대해 알아보겠습니다.

 


4. Common Memory Ptifalls

new/delete/new[]/delete[]와 low-level 메모리 연산을 사용하여 동적 메모리를 관리하면 오류가 발생하기 쉽습니다. 메모리 관련 버그가 발생하는 상황을 명확하게 정리하는 것도 쉽지 않습니다. 메모리 누수나 잘못도니 포인터가 발생하는 원인은 코드마다 다양하며, 이러한 문제들을 한 번에 해결할 수 있는 방법도 없습니다. 다만, 흔히 발생하는 문제 유형들이 알려져 있고, 이러한 문제를 찾아서 해결하는 데 도움이 되는 도구들이 몇 가지 있습니다.

 

4.1 Data Buffer Underallocation and Out-of-Bounds Memory Access

Underallocation(과소할당)은 C 스타일의 문자열에서 흔히 발생하는 문제입니다. 이 문제는 수로 프로그래머가 문자열의 끝을 나타내는 널문자 '\0'가 들어갈 공간을 빼먹고 공간을 할당할 때 발생합니다. 또한 문자열의 최대 크기를 특정 값으로 미리 정해둘 때도 발생합니다.

 

아래 코드는 underallocation을 잘 보여주고 있습니다.

char buffer[1024] {0};
while (true) {
  char* nextChunk { getMoreData() };
  if (nextChunk == nullptr) {
    break;
  }
  else {
    strcat(buffer, nextChunk); // BUG! No guarantees against buffer overrun!
    delete[] nextChunk;
  }
}

이 코드는 먼저 네트워크에서 읽을 데이터를 C 스타일의 문자열에 저장합니다. 이 작업은 반복문으로 처리되는데, 네트워크를 통해 한 번에 받을 수 있는 데이터의 양이 제한되어 있기 때문입니다. 루프를 한 번 돌 때마다 getMoreData()를 호출하는데, 이 함수는 동적으로 할당한 메모리에 대한 포인터를 리턴합니다. 데이터를 다 받았다면 getMoreData()는 nullptr을 리턴합니다. C 함수인 strcat()은 C 스타일의 문자열 두 개를 받아서 첫 번째 문자열 뒤에 두 번째 문자열을 이어 붙입니다. 이 과정에서 버퍼의 크기는 결과로 나올 문자열의 크기보다 커야합니다.

 

이 예제에서 underallocation 문제를 해결할 수 있는 방법은 다음의 3가지입니다. 가장 선호되는 순서로 나열되어 있습니다.

  1. C++ 스타일의 문자열을 사용합니다. 그러면 문자열을 연결하는 작업에 필요한 메모리를 알아서 관리합니다.
  2. 버퍼를 전역 변수나 스택(로컬) 변수로 만들지 말고, free store에 할당합니다. 공간이 부족하면 현재 문자열을 저장하는 데 부족한 만큼만 추가로 할당하고, 원본 버퍼를 새 버퍼로 복사한 뒤, 문자열을 연결하고 나서 원본 버퍼를 삭제합니다.
  3. 최대 문자 수('\0' 포함)를 입력받아서 그 길이를 넘어선 부분은 리턴하지 않고, 현재 버퍼에 남은 공간과 현재 위치를 항상 추적합니다.

 

데이터 버퍼의 underallocation은 보통 out-of-bounds memory access (메모리 경계 침범)을 유발합니다. 예를 들어, 메모리 버퍼에 데이터를 채우는 경우, 버퍼가 실제 크기보다 크다고 가정할 때 할당된 데이터 버퍼 외부에 쓰기를 시작할 수 있습니다. 그렇게 되면 메모리에서 중요한 부분을 덮어쓰고 프로그램이 죽는 것은 시간 문제입니다. 항상 프로그램에서 객체와 연관된 메모리가 덮어쓰여지는 일이 발생할 수 있다고 고려해야합니다.

 

Out-of-bounds memory access는 C 스타일의 문자열에서 '\0' 문자가 없어졌을 때 발생할 수도 있습니다. 예를 들어, 만약 적절히 끝나지 않는 문자가 아래의 함수에 의해서 처리된다고 가정해봅시다. 다음의 함수는 문자열을 'm' 문자로 채우는데 종료 조건을 만족하지 못하기 때문에 문자열에 할당된 공간을 지나서도 계속 'm'으로 채웁니다.

void fillWithM(char* inStr)
{
  int i = 0;
  while (inStr[i] != '\0') {
    inStr[i] = 'm';
    i++;
  }
}

이렇게 배열의 끝을 지나 메모리에 쓰는 버그를 buffer overflow error 라고 합니다. 바이러스나 웜 중의 상당수는 이 버그를 악용하여 경계를 벗어난 메모리 영역을 덮어쓰는 방식으로 현재 구동 중인 프로그램에 악의적인 코드를 주입합니다.

 

4.2 Memory Leaks 메모리 누수

메모리 누수(memory leaks)는 C나 C++ 프로그래밍 과정에서 발견하거나 해결하기 가장 힘든 작업 중의 하나입니다. 프로그램이 최종적으로 원하는 결과를 내도록 만들어졌지만, 시간이 지나 실행될수록 메모리 공간을 잡아먹는다면 메모리 누수가 발생한 것입니다.

 

메모리 누수는 할당했던 메모리를 제대로 해제하지 않을 때 발생합니다. 얼핏보면 조금만 주의를 기울이면 쉽게 해결될 것이라고 여기기 쉽습니다. 하지만 new에 대응되는 delete를 빠짐없이 작성하더라도 누수 현상이 발생하는 경우가 있습니다. 

아래 코드에서 Simple 클래스는 할당했던 메모리를 적절하게 해제하도록 작성되어 있습니다.

#include <iostream>
#include <string>

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

private:
  int* m_intPtr;
};

void doSomething(Simple*& outSimplePtr)
{
  outSimplePtr = new Simple{};       // BUG! Doesn't delete the original.
}

int main()
{
  Simple* simplePtr{ new Simple{} }; // Allocate a Simple object.
  doSomething(simplePtr);
  delete simplePtr;                  // Only cleans up the second object.

  return 0;
}

하지만 doSomthing()을 보면 outSimplePtr 포인터가 다른 Simple 객체를 가리키도록 변경했는데, 기존에 가리키던 객체를 삭제하지 않아서 메모리 누수가 발생합니다. 객체를 가리키고 있던 포인터를 놓치면 그 객체를 삭제할 방법이 없습니다.

 

위 예제처럼 메모리 누수는 프로그래머 간의 커뮤니케이션에 문제가 있거나 코드에 대한 문서가 잘못됬을 가능성이 높습니다. doSomething()을 호출할 때 포인터 변수가 레퍼런스로 전달된다는 사실을 눈치재지 못하고, 포인터에 다른 값을 할당할 수 있다는 사실을 예상하지 못했기 때문입니다. 포인터에 대한 레퍼런스인 매개변수가 const 타입이 아니란 것을 알았다면 문제가 발생할 수 있다고 의심했을 것입니다.

 

4.2.1 Visual C++을 사용하여 윈도우에서 메모리 누수 탐지 및 수정 방법

메모리 누수를 추적하기 어려운 이유는 메모리가 할당됬지만 현재 사용하지 않는 객체를 메모리만 보고서 찾아내기 힘들기 때문입니다. 하지만 이 작업을 처리해주는 도구들이 다양하게 있습니다. 마이크로소프트의 Visual 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);

그러면 어플리케이션이 종료할 때 Visual C++의 CRT(C Runtime) 라이브러리는 현재 감지된 모든 메모리 누수 현상을 디버그 콘솔에 출력합니다. 

아래처럼 코드를 작성하고 실행하면,

#include <iostream>
#include <string>

#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>

#ifdef _DEBUG
#ifndef DBG_NEW
#define DBG_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__)
#define new DBG_NEW
#endif // DBG_NEW
#endif // _DEBUG

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

private:
  int* m_intPtr;
};

void doSomething(Simple*& outSimplePtr)
{
  outSimplePtr = new Simple{};       // BUG! Doesn't delete the original.
}

int main()
{
  _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
  Simple* simplePtr{ new Simple{} }; // Allocate a Simple object.
  doSomething(simplePtr);
  delete simplePtr;                  // Only cleans up the second object.

  return 0;
}

다음와 같은 출력을 디버그 콘솔에서 확인할 수 있습니다.

이 결과를 살펴보면, 메모리가 할당됬지만, 해제되지 않은 부분이 어느 파일의 어느 줄에 있는지 확실히 알 수 있습니다. 줄 번호는 파일 이름 뒤에 소괄호로 묶여 있습니다. 중괄호로 묶인 숫자는 메모리 할당 횟수입니다. 예를 들어, {147}은 프로그램이 실행한 후 147번째로 할당되었다는 것을 의미합니다. VC++에서 제공하는 _CrtSetBreakAlloc() 함수를 호출하면 메모리가 할당되는 순간 실행을 중단하고 디버거를 구동하도록 VC++ 디버그 런타임을 설정할 수 있습니다. 예를 들어, main() 함수 시작 부분에 다음 문장을 작성하면 메모리를 147번째 할당하는 시점에 실행을 중단하고 디버거를 구동합니다.

_CrtSetBreakAlloc(147);

 

앞서 본 Simple 클래스 예제에서 메모리 누수 현상이 발생하는 지점은 두 군데입니다. 하나는 Simple 객체를 삭제하지 않은 위 코드에서 34번째 줄이며, 다른 하나는 이 객체가 정수를 free store에 할당하는 18번째 줄 입니다.

 

물론 방금 설명한 Visual C++이나 다음에 소개할 Valgrind와 같은 도구는 메모리 누수 문제를 찾기만 할 뿐 고쳐주지는 않는다. 이런 도구는 문제의 원인을 찾는데 도움이 되는 정보만 제공할 뿐입니다. 실제로 문제를 수정하려면 코드를 한 단계씩 실행하면서 객체를 가리키는 포인터가 원본 객체를 삭제하지 않은 채 다른 값으로 변경되는 부분을 찾아야 합니다.

 

참고로 위 코드에서 메모리 누수를 수정하려면, 다음과 같이 unique_ptr을 사용하면 더 이상 메모리 누수가 발생하지 않습니다.

#include <iostream>
#include <string>
#include <memory>

#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>

#ifdef _DEBUG
#ifndef DBG_NEW
#define DBG_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__)
#define new DBG_NEW
#endif // DBG_NEW
#endif // _DEBUG

class Simple
{
public:
  Simple() { m_intPtr = std::make_unique<int>(); }
  ~Simple() { }
  void setValue(int value) { *m_intPtr = value; }

private:
  std::unique_ptr<int> m_intPtr;
};

void doSomething(std::unique_ptr<Simple>& outSimplePtr)
{
  outSimplePtr = std::make_unique<Simple>();
}

int main()
{
  _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
  std::unique_ptr<Simple> simplePtr{ std::make_unique<Simple>() };
  doSomething(simplePtr);

  return 0;
}

 

4.2.2 Valgrind를 이용한 리눅스 어플리케이션의 메모리 누수 탐지 및 수정 방법

valgrind(밸그라인드)는 무료로 제공되는 리눅스용 오픈소스 도구로, 할당된 객체를 해제하지 않는 지점을 줄 단위로 정확히 찾아준다는 특징이 있습니다.

#include <iostream>
#include <string>

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

private:
  int* m_intPtr;
};

void doSomething(Simple*& outSimplePtr)
{
  outSimplePtr = new Simple{};       // BUG! Doesn't delete the original.
}

int main()
{
  Simple* simplePtr{ new Simple{} }; // Allocate a Simple object.
  doSomething(simplePtr);
  delete simplePtr;                  // Only cleans up the second object.

  return 0;
}

앞서 본 Simple 클래스 예제를 valgrind로 분석한 결과는 다음과 같습니다.

(leaky는 예제 코드를 컴파일한 실행파일이며, -g 옵션을 추가하여 컴파일했습니다.)

출력된 문장을 살펴보면 할당된 메모리가 해제되지 않은 지점을 정확히 알 수 있습니다. (Visual C++로 찾은 지점과 동일)

 

4.3 Double-Deletion 과 Invalid Pointer

delete로 포인터에 할당된 메모리를 해제하면, 그 메모리를 프로그램의 다른 부분에서 사용할 수 있습니다. 하지만 그 포인터를 계속 쓰는 것은 막을 수 없으며, 이 포인터를 댕글링 포인터(dangling pointer)라고 합니다. Double Deletion 또한 문제가 됩니다. 만약 한 포인터에 delete를 두 번 사용하면 프로그램은 이미 다른 객체를 할당한 메모리를 해제해버릴 수 있습니다.

 

Double deletion과 이미 해제한 메모리를 다시 사용하는 문제는 현상이 즉시 나타나지 않기 때문에 추적하기 어렵습니다. 비교적 짧은 시간에 메모리를 삭제하는 연산이 두 번 실행되면 그 사이에 같은 메모리를 재사용할 가능성이 적기 때문에 프로그램이 계속해서 정상적으로 실행될 수 있습니다. 마찬가지로 객체를 삭제한 직후 곧바로 다시 사용하더라도 그 영역이 삭제 전 상태로 계속 남아 있을 가능성이 있어서 문제가 생기지 않을 수 있습니다.

 

물론 문제가 발생하지 않는다고 장담할 수는 없습니다. 메모리를 할당할 때 삭제된 객체를 보존하지 않기 때문입니다. 설령 제대로 동작하더라도 삭제된 객체를 이용하는 것은 바람직한 코드 작성 방법이 아닙니다.

 

double deletion과 이미 해제된 메모리를 사용하는 것을 피하기 위해서는 메모리를 해제한 직후 nullptr를 설정해주는 것이 좋습니다. 

 


다음 포스팅에서는 이어서 스마트 포인터에 대해 알아보도록 하겠습니다.

 

[C++] 메모리 관리 (1) - 동적 메모리, 배열과 포인터

[C++] 메모리 관리 (3) - 스마트 포인터 (Smart Pointer)

댓글