[C++] 항목 50: new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자

업데이트:     Updated:

카테고리:

태그:

이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역

📦 8. new와 delete를 내 맘대로

👉🏻 항목 50: new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자

✅ operator new/delete를 바꾸는 이유

1. 잘못된 힙 사용을 탐지하기 위해

상황 1: new로 할당한 메모리를, 2번 이상 delete
→ 미정의 동작

  • 해결법
    • 할당된 메모리 주소 목록 변수를 둔다.
    • operator new/delete에서 해당 목록 변수를 관리하도록 한다.


상황 2: 데이터 오버런/언더런

  • 데이터 오버런: 할당된 메모리 블록을 넘어, 뒤에 기록하는 것
  • 데이터 언더런: 할당된 메모리 블록을 넘어, 앞에 기록하는 것
// new는 정확히 5 * sizeof(int) = 20바이트 할당
int* p = new int[5];
p[5] = 100; // ❌ 오버런
p[-1] = 100; // ❌ 언더런
  • 해결법
    • 메모리의 앞뒤로 메모리를 조금 더 할당하고,
    • 오버런/언더런 탐지용 바이트 패턴(시그니처)를 적어둔다.
    • 그러면, 잘못된 값이 들어갔는지 확인할 수 있다.

2. 효율을 향상시키기 위해

  • operator new/delete는 일반적인 쓰임새에 맞추어 설계되었다.
  • 본인의 프로그램의 동적 메모리가 어떻게 사용될지 이해한다면,
    메모리를 최적화 시킬 수 있다.

3. 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해

수집 정보

  • 할당된 메모리 블록의 크기는 어떤 분포를 보이는지?
  • 사용 시간은 어떤 분포를 보이는지?
  • 메모리가 할당되고 해제되는 순서가 FIFO인지, LIFO인지,
    아니면 마구잡이 순서에 가까운지?
  • 시간 경과에 따라 메모리 사용 패턴이 바뀌는지?
    → 각 실행 단계마다 소프트웨어가 보이는 메모리 할당/해제 패턴이 차이를 보이는지?
  • 동적 할당 메모리의 최대량(최고 수위선)은 어느 정도인지?

✅ 데이터 오버런/언더런 해결

예시 코드

static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;

// 고쳐야 할 부분이 있음
void* operator new(size_t size) throw(bad_alloc) {
	size_t realSize = size + 2 * sizeof(int);
	
	// 시그니처 2개를 앞뒤에 붙일수 있을만큼,
	// 메모리 크기를 늘림
	void* pMem = malloc(realSize);
	if(!pMem) throw bad_alloc();
	
	// 메모리 시작/끝 부분에 시그니처 기록
	*(static_cast<int*>(pMem)) = signature;
	*(
		reinterpret_cast<int*>(
			static_cast<Byte*>(pMem)+realsize-sizeof(int)
		)
	) = signature;
	
	// 앞쪽 시그니처 바로 다음의 메모리를 가리키는 포인터 반환
	return static_cast<Byte*>(pMem) + sizeof(int);
}
  • 문제점
    1. operator new에는 new 처리자 함수를 호출하는 루프가 없음
      (항목 51 참조)
    2. 바이트 정렬 문제

🗃️ 바이트 정렬

문제점 1: 아키텍처적으로 특정 타입의 데이터는
특정 종류 메모리 주소를 시작 주소로 하여 저장되어야 한다.

  • 예로, 포인터는 4의 배수부터, double은 8의 배수부터 정렬해야 한다.
  • 그렇지 않으면, 하드웨어 예외를 일으킬 수 있다.
  • 인텔 x86 아키텍처는 바이트 단위로 정렬하면,
    런타임 접근 속도가 훨씬 빨라진다.


문제점 2: 모든 operator new 함수는 어떤 데이터 타입에도
바이트 정렬을 만족하는 포인터를 반환해야 한다.

  • malloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 안전하다.
  • 하지만, 위 코드에서는 int 크기만큼 뒤로 어긋난 주소를 포인터로 반환한다.
  • 이러한 경우 프로그램이 다운되거나, 실행 속도가 느려질 수 있다.

❓ 굳이 만들어 써야 할까?

  • 꼭 만들어 쓸 이유가 없다면, 굳이 필요가 없다.

  • 시중 컴파일러 중에는 메모리 관리 함수에 디버깅 및 로깅 기능을 넣어 놓고,
    필요에 따라 전환할 수 있도록 해둔 것도 있다.
  • 여러 플랫폼을 지원하는, 메모리 관리 함수 전문 상업용 제품도 있다.
  • 여러 플랫폼을 지원하는, 메모리 관리자 패키지도 오픈 소스로 공개되어 있다.
    • 이 중, 풀(Pool) 라이브러리가 있다.(항목 55 참조)
    • 예로, 크기가 작은 객체(소형 객체)를 많이 할당할 경우 도움을 얻을 수 있다.

📌 총 정리: operator new/delete를 바꾸는 이유

1. 잘못된 힙 사용 탐지

  • 메모리 오류(누수, 이중 해제, 오버런 등)를 조기에 감지하기 위해 사용한다.

→ 커스텀 헤더나 체크섬을 삽입하여 오류를 추적할 수 있다.


2. 동적 할당 통계 수집

  • 실제 메모리 사용 패턴을 분석할 수 있다.

→ 할당/해제 빈도, 피크 사용량 등을 로깅하여 메모리 사용 효율을 파악한다.


3. 할당/해제 속도 향상

  • 기본 범용 할당자는 범용성 + 스레드 안전성 때문에 상대적으로 느리다.
  • 이를 개선하기 위해 다음과 같은 전략을 사용한다:
    • 특정 객체 전용으로 최적화된 사용자 정의 할당자 사용
    • 고정 크기 객체는 boost::pool 같은 풀 할당자 사용
    • 단일 스레드 앱에서는 락 없는 구현으로 성능 향상

✅ 단, 병목이 실제로 존재하는지 프로파일링을 통해 반드시 확인해야 한다.


4. 공간 오버헤드 감소

  • 범용 할당자는 블록당 메타데이터와 패딩이 많아,
    작은 객체가 많을 경우 오버헤드가 누적된다.

→ 튜닝된 할당자는 오버헤드를 1~4바이트 수준으로 줄일 수 있다.


5. 바이트 정렬 보장

  • 일부 컴파일러는 double 등 8바이트 정렬을 보장하지 않는다.

→ 사용자 정의 operator new를 통해 8B 또는 16B 정렬을 강제하면, 캐시 효율이 향상된다.


6. 메모리 군집화 (Locality)

  • 관련 객체들을 물리적으로 인접하게 배치하면 페이지 폴트가 감소한다.

placement new/delete를 활용해 객체를 원하는 메모리 위치에 직접 배치한다.


7. 임의 동작 삽입

  • 컴파일러가 제공하지 않는 부가 로직을 삽입할 수 있다.
    • 공유 메모리 영역에 객체를 할당 (C API 연동)
    • deletememset(0)으로 보안을 강화
    • 디버그 패턴 삽입으로 오류 추적성 향상

🧐 정리

  • 개발자가 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 타당한 이유가 있다.
    메모리 오류 탐지, 성능 최적화, 통계 수집 등이 대표적이다.
  • 바이트 정렬은 반드시 고려해야 한다.
    정렬 요구사항을 만족하지 못하면 프로그램이 다운되거나 성능이 저하될 수 있다.
  • 꼭 필요한 경우가 아니라면 기존 솔루션을 활용하자.
    컴파일러 내장 기능, 상업용 제품, 오픈소스 라이브러리 등 검증된 대안이 많다.
  • 성능 최적화를 위해 사용자 정의 할당자를 만들 때는 반드시 프로파일링을 먼저 하자.
    실제 병목이 메모리 할당에 있는지 확인하는 것이 중요하다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기