C++ 힙 손상: 버퍼 오버플로우·이중 delete·use-after-free 원인과 ASan·Valgrind 탐지

이 글의 핵심

힙 손상은 잘못 쓴 순간이 아니라 한참 뒤의 malloc·free에서 크래시가 나기 때문에, 크래시 위치만 봐서는 원인을 찾기 어렵습니다. 버퍼 오버플로우·이중 해제·delete/delete[] 혼용·use-after-free가 할당자 메타데이터를 어떻게 망가뜨리는지, glibc와 MSVC가 내는 실제 에러 메시지를 어떻게 읽는지, AddressSanitizer로 원인 지점을 바로 잡는 방법을 정리합니다.

Heap Corruption이란?

힙 메모리 관리 구조가 손상되는 문제

힙 할당자는 각 할당된 블록 앞뒤에 그 블록의 크기와 다음/이전 자유 블록에 대한 정보를 담은 메타데이터를 함께 저장해 관리합니다 — 이는 사용자에게 보이는 int* arr 같은 포인터 바로 옆(대개 직전)에 위치한, 프로그래머가 직접 건드릴 의도가 전혀 없는 내부 부기(bookkeeping) 데이터입니다. arr[10] = 42처럼 할당받은 범위를 넘어 쓰기를 하면, 그 한 줄이 실제로 덮어쓰는 것은 다음 블록의 메타데이터일 가능성이 매우 높습니다 — 그 순간에는 아무 에러도 나지 않고 프로그램이 계속 실행되다가, 한참 뒤에 그 손상된 메타데이터를 참조하는 malloc이나 free 호출에서야 크래시가 발생합니다. 이것이 힙 손상이 스택 오버플로우 같은 다른 메모리 버그보다 훨씬 진단하기 어려운 이유입니다 — 실제 원인(잘못된 쓰기)과 증상이 드러나는 지점(전혀 관련 없어 보이는 나중의 할당·해제 호출) 사이에 코드상, 시간상 상당한 거리가 있습니다.

// ❌ 힙 손상 예시
int* arr = new int[10];
arr[10] = 42;  // 범위 초과 (힙 손상)
delete[] arr;

실제로 이 손상이 드러날 때 보게 되는 메시지는 플랫폼마다 다릅니다. Linux의 glibc는 free()나 malloc() 안에서 메타데이터가 이상하면 free(): double free detected in tcache 2, free(): invalid pointer, malloc(): corrupted top size, double free or corruption (out) 같은 메시지를 찍고 abort()로 프로그램을 종료합니다(셸에는 Aborted (core dumped)가 보입니다). Windows의 MSVC 디버그 빌드는 HEAP CORRUPTION DETECTED: after Normal block (#123) at 0x... 대화상자를 띄우는데, 이 경고는 블록 뒤쪽의 보호 영역이 덮어써졌다는 뜻이라 “해제하는 그 블록의 끝을 넘어서 쓴 코드”를 찾으라는 신호입니다. 릴리스 빌드에서는 이런 검사가 없어 조용히 넘어가거나 전혀 무관한 곳에서 0xC0000374(STATUS_HEAP_CORRUPTION)로 종료됩니다. 공통점은 메시지가 나온 스택이 원인이 아니라는 것이며, 이 메시지를 봤다면 디버거로 크래시 지점을 파고들기보다 Sanitizer 빌드로 다시 돌리는 편이 훨씬 빠릅니다.

발생 원인

네 가지 원인은 모두 결국 “할당자가 기대하는 계약을 어긴다”는 하나의 근본 문제로 수렴합니다. 버퍼 오버플로우는 할당받은 크기를 넘어 쓰는 것이 문제이고, 이중 delete는 이미 자유 목록(free list)에 반환된 블록을 다시 반환하려 시도해 그 목록의 연결 구조를 손상시키는 것이며, new[]로 할당한 배열을 delete(단일 객체용)로 해제하는 것은 배열 크기 정보가 저장된 위치와 다른 위치에서 메타데이터를 찾으려는 시도이고, 해제 후 사용은 이미 재사용 가능하다고 표시된(어쩌면 다른 할당 요청에 의해 이미 재사용된) 메모리에 계속 접근하는 것입니다. 이 네 가지가 실무에서 위험한 공통된 이유는, 문제가 발생하는 시점(잘못된 접근)과 그 결과가 드러나는 시점(할당자가 손상을 감지하거나, 다른 코드가 손상된 데이터를 읽는 시점) 사이에 항상 시차가 있어 원인 추적이 어렵다는 것입니다.

// 1. 버퍼 오버플로우
int* arr = new int[10];
arr[15] = 42;  // 범위 초과

// 2. 이중 delete
int* ptr = new int(10);
delete ptr;
delete ptr;  // 이중 해제

// 3. 잘못된 delete
int* arr = new int[10];
delete arr;  // delete[] 사용해야 함

// 4. Use After Free
int* ptr = new int(10);
delete ptr;
*ptr = 42;  // 해제 후 사용

실전 예시

예시 1: 버퍼 오버플로우

strcpy가 위험한 근본 이유는 이 함수가 대상 버퍼의 크기를 전혀 알지 못한 채, 원본 문자열이 널 종료 문자(\0)를 만날 때까지 무조건 복사를 계속하기 때문입니다 — "This is too long"이 buffer의 10바이트를 훨씬 초과하는데도 strcpy는 이를 검사할 방법이 없어 그대로 힙의 인접 영역까지 계속 써 내려갑니다. strncpy가 상대적으로 안전한 이유는 최대 복사 길이를 명시적으로 제한할 수 있기 때문이지만, 원본 문자열이 그 길이보다 길면 널 종료 문자를 아예 쓰지 않을 수 있다는 함정이 있어 buffer[19] = '\0'처럼 항상 수동으로 종료 문자를 보장해야 안전합니다. std::string이 근본적으로 더 나은 이유는 이런 크기 관리 자체를 클래스 내부에서 자동으로 처리해, 프로그래머가 버퍼 크기를 직접 계산하고 경계를 검사하는 책임 자체를 없애 준다는 데 있습니다.

#include <iostream>
#include <cstring>

// ❌ 버퍼 오버플로우
void bufferOverflow() {
    char* buffer = new char[10];
    strcpy(buffer, "This is too long");  // 범위 초과
    delete[] buffer;
}

// ✅ 안전한 복사
void safeBuffer() {
    char* buffer = new char[20];
    strncpy(buffer, "This is safe", 19);
    buffer[19] = '\0';
    delete[] buffer;
}

// ✅ std::string 사용
void useString() {
    std::string str = "This is safe";
}

예시 2: 이중 delete

첫 번째 delete ptr는 그 메모리 블록을 할당자의 자유 목록에 정상적으로 반환하지만, 두 번째 delete ptr는 이미 반환된 (그리고 어쩌면 이미 다른 new 호출에 재할당되어 전혀 다른 객체가 그 자리를 차지하고 있을 수도 있는) 블록을 또 반환하려 시도합니다 — 이는 자유 목록이라는 연결 구조 자체를 손상시켜, 그 이후의 아무 관련 없는 new/delete 호출에서 크래시나 예측 불가능한 동작을 일으킬 수 있습니다. ptr = nullptr을 해제 직후에 설정하는 습관이 안전한 이유는 delete nullptr가 표준에 의해 명시적으로 안전한 무연산(no-op)으로 정의되어 있기 때문입니다 — 이 한 줄을 습관화하면 실수로 같은 포인터를 다시 delete하려는 코드가 있어도 실제 이중 해제가 아니라 아무 일도 일어나지 않는 안전한 상태가 됩니다. 근본적인 해법은 unique_ptr처럼 소유권을 명확히 하는 스마트 포인터를 쓰는 것인데, 이런 타입은 애초에 원시 포인터를 프로그래머가 직접 delete할 필요 자체를 없애 이중 해제라는 실수의 범주를 원천 차단합니다.

#include <memory>

// ❌ 이중 delete
void doubleFree() {
    int* ptr = new int(10);
    delete ptr;
    delete ptr;  // 크래시
}

// ✅ nullptr 설정
void safeFree() {
    int* ptr = new int(10);
    delete ptr;
    ptr = nullptr;
    delete ptr;  // 안전 (무시됨)
}

// ✅ 스마트 포인터
void smartPointer() {
    auto ptr = std::make_unique<int>(10);
    // 자동 해제
}

예시 3: 잘못된 delete

new[]로 배열을 할당하면, 컴파일러는 그 배열의 원소 개수를 별도의 메타데이터로 어딘가에(대개 반환된 포인터 바로 앞에) 저장해 둡니다 — delete[]는 이 정보를 읽어 배열의 각 원소에 대해 소멸자를 정확한 횟수만큼 호출한 뒤 전체 블록을 해제하지만, delete(단일 객체용)는 이 배열 메타데이터의 존재를 아예 모른 채 마치 단일 객체인 것처럼 처리하려 시도합니다. int처럼 소멸자가 없는 타입이라면 눈에 보이는 문제 없이 넘어갈 수도 있지만, 이는 우연히 운이 좋은 것일 뿐 여전히 정의되지 않은 동작이며, 소멸자가 있는 타입(std::string 배열 등)이라면 첫 번째 원소만 소멸자가 호출되고 나머지는 누수되거나, 배열 크기 메타데이터를 잘못 해석해 힙 손상으로 이어질 수 있습니다. make_unique<T[]>가 안전한 이유는 unique_ptr가 배열 특수화 버전에서 자동으로 delete[]를 호출하도록 내부적으로 구현되어 있어, 이 new/delete 짝을 프로그래머가 직접 맞출 필요 자체를 없애기 때문입니다.

// ❌ delete vs delete[] 혼용
void wrongDelete() {
    int* arr = new int[10];
    delete arr;  // 힙 손상
}

void wrongDelete2() {
    int* ptr = new int(10);
    delete[] ptr;  // 힙 손상
}

// ✅ 올바른 delete
void correctDelete() {
    int* arr = new int[10];
    delete[] arr;
    
    int* ptr = new int(10);
    delete ptr;
}

// ✅ 스마트 포인터
void smartPointer() {
    auto arr = std::make_unique<int[]>(10);
    auto ptr = std::make_unique<int>(10);
}

예시 4: Use After Free

delete ptr 이후 *ptr을 읽는 이 코드가 특히 까다로운 이유는, 대부분의 할당자가 해제된 메모리를 즉시 다른 용도로 재사용하지 않고 잠시 그대로 두는 경우가 많아 *ptr이 여전히 이전 값(10)을 우연히 반환할 수 있다는 데 있습니다 — 이는 코드가 “작동하는 것처럼 보이는” 가장 위험한 형태이며, 프로그램이 더 복잡해지거나 다른 스레드가 그 사이에 할당을 수행하면 그 메모리가 다른 목적으로 재사용되어 완전히 다른 값이 나타나거나 크래시로 이어집니다. 힙 손상 탐지 절에서 다룰 AddressSanitizer 같은 도구가 이런 “우연히 작동하는 것처럼 보이는” 버그를 실행 즉시 확실하게 잡아내는 이유는, 해제된 메모리에 접근하는 순간 그 자체를 감지해 도구가 개입하기 때문입니다 — 육안 검사나 일반 테스트로는 이런 버그를 신뢰성 있게 발견하기 매우 어렵습니다.

#include <iostream>

// ❌ 해제 후 사용
void useAfterFree() {
    int* ptr = new int(10);
    delete ptr;
    std::cout << *ptr << std::endl;  // 위험
}

// ✅ 해제 전 사용
void safeUse() {
    int* ptr = new int(10);
    std::cout << *ptr << std::endl;
    delete ptr;
}

// ✅ 스마트 포인터
void smartPointer() {
    auto ptr = std::make_unique<int>(10);
    std::cout << *ptr << std::endl;
}

힙 손상 탐지

이 네 가지 방법은 각각 손상을 감지하는 시점이 다릅니다 — AddressSanitizer는 컴파일 시점에 코드에 검사 로직을 직접 삽입(instrumentation)해, 잘못된 메모리 접근이 일어나는 바로 그 명령어에서 즉시 프로그램을 중단시키고 정확한 원인 위치를 알려줍니다. Valgrind는 별도의 계측 없이 프로그램을 가상 CPU 위에서 에뮬레이션하며 모든 메모리 접근을 감시하는 방식이라 더 느리지만 재컴파일이 필요 없다는 장점이 있습니다. Windows Debug Heap은 디버그 빌드에서 할당된 블록의 앞뒤에 특정 패턴을 채워 두고 해제 시점에 그 패턴이 그대로인지 검사하는 방식으로, 마지막의 “커스텀 가드” 클래스가 정확히 이 원리를 최소한으로 재현한 예시입니다 — 0xAA/0xBB 패턴을 블록 앞뒤에 심어 두고, deallocate 시점에 이 패턴이 손상되지 않았는지 확인하면 그 블록에 대한 오버플로우가 있었는지 사후에 알 수 있습니다. 프로덕션 코드에는 성능 오버헤드 때문에 이런 검사를 넣지 않지만, 개발·테스트 빌드에서는 이런 가드 패턴이나 AddressSanitizer를 상시 활성화해 두는 것이 힙 손상을 조기에 발견하는 실무적인 방법입니다.

AddressSanitizer 보고서는 읽는 법만 알면 원인을 거의 바로 알려 줍니다. 첫 줄의 오류 종류(heap-buffer-overflow, heap-use-after-free, attempting double-free, alloc-dealloc-mismatch)가 이 글의 네 가지 원인과 그대로 대응하고, 그 아래에는 잘못된 접근이 일어난 스택, 해당 메모리를 할당한 스택, (해제 관련 오류라면) 해제한 스택이 차례로 나옵니다. 예를 들어 0x602000000038 is located 0 bytes to the right of 40-byte region이라는 줄은 “40바이트짜리 블록(int[10])의 바로 다음 칸에 썼다”는 뜻이라 오프바이원 실수임을 짐작할 수 있습니다. -g로 디버그 정보를 넣고 -fno-omit-frame-pointer를 함께 주면 스택이 정확해지며, 오버헤드는 대략 실행 시간 두 배 수준이라 테스트 스위트를 상시 ASan으로 돌리는 팀이 많습니다. 단, 커스텀 풀 할당자는 ASan이 모르는 방식으로 메모리를 재사용하므로 풀 내부의 오버플로우는 잡히지 않는다는 점에 주의해야 합니다.

재컴파일이 어려운 상황에서는 표준 라이브러리 차원의 검사도 쓸 수 있습니다. glibc는 MALLOC_CHECK_=3 환경 변수로 추가 검사를 켤 수 있고, libstdc++는 -D_GLIBCXX_ASSERTIONS로 vector::operator[] 같은 접근에 범위 검사를 넣어 줍니다. MSVC에서는 _CrtSetDbgFlag에 _CRTDBG_CHECK_ALWAYS_DF를 추가하면 할당·해제 때마다 힙 전체를 검사해, 손상이 일어난 직후의 호출에서 멈추도록 범위를 좁힐 수 있습니다(대신 매우 느려집니다).

// 1. AddressSanitizer
// g++ -fsanitize=address -g program.cpp

// 2. Valgrind
// valgrind --tool=memcheck ./program

// 3. Windows Debug Heap
#ifdef _DEBUG
#include <crtdbg.h>
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
#endif

// 4. 커스텀 가드
class HeapGuard {
public:
    static void* allocate(size_t size) {
        const size_t guardSize = 16;
        void* ptr = malloc(size + guardSize * 2);
        
        // 앞뒤에 가드 패턴
        memset(ptr, 0xAA, guardSize);
        memset((char*)ptr + guardSize + size, 0xBB, guardSize);
        
        return (char*)ptr + guardSize;
    }
    
    static void deallocate(void* ptr) {
        if (!ptr) return;
        
        // 가드 검사
        char* realPtr = (char*)ptr - 16;
        // 검사 로직
        free(realPtr);
    }
};

자주 발생하는 문제

문제 1: 배열 경계 초과

i <= 10이라는 흔한 오프바이원(off-by-one) 실수가 위험한 이유는, 유효한 인덱스가 0부터 9까지(10개)인데 i == 10일 때 arr[10]에 접근해 정확히 할당 범위 바로 다음 바이트를 건드리기 때문입니다 — 이 지점이 앞서 설명한 힙 메타데이터나 인접 블록이 위치할 가능성이 높은 자리이므로, 오프바이원 실수는 힙 손상의 매우 흔한 원인입니다. std::vector를 쓰면 이런 실수를 원천적으로 막을 수는 없지만(vec[10]도 여전히 정의되지 않은 동작입니다), vec.at(10)을 쓰면 범위를 벗어난 접근 시 예외를 던져 조용한 힙 손상 대신 즉각적이고 진단하기 쉬운 실패로 바꿀 수 있습니다 — 디버그 단계에서는 at()을, 성능이 검증된 핫패스에서만 []를 쓰는 것이 실무적인 절충안입니다. 코드를 일일이 at()으로 바꾸기 어렵다면 앞에서 소개한 _GLIBCXX_ASSERTIONS나 MSVC 디버그 빌드의 반복자 검사(vector subscript out of range 단언)가 [] 접근에도 같은 검사를 넣어 줍니다. vec.size()와 int i를 비교하는 위 루프는 부호 비교 경고를 내므로 size_t나 범위 기반 for를 쓰는 편이 깔끔합니다.

// ❌ 범위 초과
int* arr = new int[10];
for (int i = 0; i <= 10; i++) {  // <= 10은 오류
    arr[i] = i;
}
delete[] arr;

// ✅ 올바른 범위
int* arr = new int[10];
for (int i = 0; i < 10; i++) {
    arr[i] = i;
}
delete[] arr;

// ✅ std::vector
std::vector<int> vec(10);
for (int i = 0; i < vec.size(); i++) {
    vec[i] = i;
}

문제 2: 메모리 재할당

new와 realloc을 섞어 쓰는 것이 위험한 이유는 이 둘이 서로 다른 할당 메커니즘에 속하기 때문입니다 — new로 할당된 메모리는 C++ 런타임의 할당 경로(생성자 호출 포함)를 거치지만, realloc은 C 표준 라이브러리의 malloc 계열 함수로 그 메모리가 애초에 malloc으로 할당되었다고 가정하고 동작합니다. 두 할당자가 내부적으로 같은 힙을 공유하는 구현도 있지만, 이는 표준이 보장하는 사항이 아니며 메타데이터 형식 자체가 다를 수 있어 realloc(arr, ...)을 new로 만든 포인터에 호출하는 것은 정의되지 않은 동작입니다. std::vector::resize가 안전한 이유는 내부적으로 항상 일관된 할당자(기본적으로 std::allocator, 즉 new/delete 기반)만 사용하도록 캡슐화되어 있어, 이런 이종 할당자 혼용 문제 자체가 발생할 수 없기 때문입니다.

// ❌ realloc 오용
int* arr = new int[10];
arr = (int*)realloc(arr, 20 * sizeof(int));  // new와 혼용 불가
delete[] arr;

// ✅ 새로 할당
int* arr = new int[10];
int* newArr = new int[20];
std::copy(arr, arr + 10, newArr);
delete[] arr;
delete[] newArr;

// ✅ std::vector
std::vector<int> vec(10);
vec.resize(20);

문제 3: 포인터 산술

arr + 15처럼 할당 범위를 넘어서는 포인터를 만드는 것 자체가 이미 표준상 정의되지 않은 동작입니다(포인터 산술은 배열의 마지막 원소 바로 다음 위치까지만 허용되며, 그 이상으로 포인터를 이동시키는 것 자체가 문제입니다) — 이는 그 포인터를 역참조하기 전부터 성립하는 문제이므로, *ptr = 42로 실제 쓰기가 일어나는 순간에는 이미 두 단계의 위반이 겹쳐 있는 셈입니다. 배열 인덱싱(arr[i])과 포인터 산술(*(arr + i))이 문법적으로는 동등하지만, 인덱스를 변수로 다루는 배열 접근 방식이 실무에서 더 안전한 이유는 그 인덱스 값을 조건문으로 검사하기가 포인터 값 자체를 검사하는 것보다 훨씬 직관적이기 때문입니다 — index < 10이라는 검사는 그 의미가 명확하지만, ptr < arr + 10처럼 포인터 비교로 같은 검사를 하는 것은 실수하기 더 쉽습니다.

// ❌ 잘못된 포인터 산술
int* arr = new int[10];
int* ptr = arr + 15;  // 범위 초과
*ptr = 42;
delete[] arr;

// ✅ 범위 검사
int* arr = new int[10];
int index = 5;
if (index < 10) {
    arr[index] = 42;
}
delete[] arr;

문제 4: 구조체 멤버

delete node는 Node 구조체 자체가 차지하는 메모리(포인터 두 개 크기)만 해제할 뿐, 그 포인터 멤버 data가 별도로 가리키고 있는 힙 메모리는 전혀 건드리지 않습니다 — 포인터를 멤버로 가진 구조체를 해제할 때는 “구조체 자신의 메모리”와 “그 구조체가 소유권을 갖는 다른 메모리들”이 서로 다른 별개의 할당이라는 것을 항상 의식해야 하며, 후자를 해제하는 책임은 오직 프로그래머의 몫입니다(이는 이 글에서 이미 여러 번 다룬 메모리 누수 패턴과 정확히 같은 문제입니다). 이 실수를 원천적으로 없애는 방법이 NodeSafe처럼 스마트 포인터를 멤버로 쓰는 것인데, unique_ptr<int[]>나 unique_ptr<NodeSafe> 멤버는 그 소유자인 NodeSafe 객체가 소멸될 때 자동으로 함께 소멸되므로, deleteNode 같은 수동 정리 함수 자체가 필요 없어지고 그 함수를 작성하며 저지를 수 있는 실수(멤버 해제 누락)도 함께 사라집니다.

struct Node {
    int* data;
    Node* next;
};

// ❌ 멤버 해제 누락
void deleteNode(Node* node) {
    delete node;  // data 해제 안 됨
}

// ✅ 멤버 먼저 해제
void deleteNode(Node* node) {
    delete[] node->data;
    delete node;
}

// ✅ 소멸자 사용
struct NodeSafe {
    std::unique_ptr<int[]> data;
    std::unique_ptr<NodeSafe> next;
};

방지 방법

이 네 가지 방법을 관통하는 하나의 원칙은 “원시 메모리 관리를 프로그래머의 규율이 아니라 타입 시스템과 언어 기능에 위임하라”는 것입니다 — 스마트 포인터는 소유권과 해제 시점을, 컨테이너는 크기 관리와 경계 검사를, RAII는 생성자/소멸자 쌍으로 자원 획득과 해제를 항상 짝지어지도록 강제하며, 범위 검사가 내장된 커스텀 래퍼는 배열 접근 자체를 안전하게 만듭니다. 이 글에서 다룬 모든 힙 손상 사례(버퍼 오버플로우, 이중 delete, 잘못된 delete, use-after-free, 배열 경계 초과)는 사실 원시 포인터와 수동 메모리 관리라는 하나의 공통 원인에서 파생된 것이며, 이 네 가지 방지책을 습관적으로 적용하면 그 근본 원인 자체를 코드베이스에서 제거할 수 있습니다 — 현대 C++ 코드에서 new/delete를 직접 쓰는 코드가 점점 드물어지는 것도 바로 이런 이유 때문입니다.

// 1. 스마트 포인터
auto ptr = std::make_unique<int>(10);
auto arr = std::make_unique<int[]>(10);

// 2. 컨테이너 사용
std::vector<int> vec(10);
std::string str = "Hello";

// 3. RAII 패턴
class Resource {
    int* data;
public:
    Resource(int size) : data(new int[size]) {}
    ~Resource() { delete[] data; }
    
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
};

// 4. 범위 검사
template<typename T>
class SafeArray {
    std::vector<T> data;
public:
    T& operator[](size_t index) {
        if (index >= data.size()) {
            throw std::out_of_range("인덱스 초과");
        }
        return data[index];
    }
};

디버깅 도구

# AddressSanitizer
g++ -fsanitize=address -g program.cpp
./a.out

# Valgrind
valgrind --leak-check=full --show-leak-kinds=all ./program

# Dr. Memory (Windows)
drmemory.exe -- program.exe

# Electric Fence
LD_PRELOAD=libefence.so ./program

FAQ

Q1: 크래시 스택에 제 코드가 아니라 malloc·free만 보입니다. 어디부터 봐야 하나요?

A: 힙 손상의 전형적인 모습입니다. 크래시가 난 free 호출은 손상을 발견한 지점일 뿐, 손상을 일으킨 코드는 그보다 앞의 어딘가에 있습니다. 크래시 지점을 분석하기보다 -fsanitize=address로 다시 빌드해 같은 시나리오를 실행하면, 잘못된 쓰기가 일어난 바로 그 줄에서 멈춥니다.

Q2: 디버그 빌드에서는 멀쩡하고 릴리스 빌드에서만 크래시가 납니다.

A: 디버그 빌드의 할당자는 블록 앞뒤에 여유 공간과 채움 패턴을 두기 때문에, 작은 오버플로우가 그 여유 공간에 떨어져 증상이 가려지는 경우가 많습니다. 반대로 릴리스 빌드에서는 메모리 배치가 촘촘해 바로 옆 블록이나 메타데이터가 망가집니다. 코드의 문제는 두 빌드 모두에 있으므로, 릴리스 최적화를 유지한 채 ASan을 켠 빌드(-O1 -g -fsanitize=address)로 재현해 보세요.

Q3: Valgrind와 AddressSanitizer 중 무엇을 쓰나요?

A: 재컴파일할 수 있다면 ASan이 훨씬 빠르고(대략 2배 느려지는 수준) 스택 오버플로우와 전역 변수 오버플로우까지 잡습니다. Valgrind는 재컴파일 없이 바이너리 그대로 검사할 수 있고 초기화되지 않은 값 사용도 추적하지만, 수십 배 느려 큰 테스트에는 부담이 됩니다. 두 도구를 한 실행에 함께 쓸 수는 없습니다.

Q4: delete와 delete[]를 잘못 짝지어도 int 배열은 문제없이 돌아가던데요?

A: 소멸자가 없는 타입에서는 많은 구현이 두 연산을 같은 방식으로 처리해 우연히 동작하지만, 표준상 정의되지 않은 동작입니다. 소멸자가 있는 타입에서는 원소 개수 정보를 저장하는 방식 때문에 잘못된 주소를 해제하게 되어 즉시 크래시하거나 힙이 손상됩니다. ASan은 이를 alloc-dealloc-mismatch로 잡아 주므로, 우연히 동작한다고 넘어가지 말고 std::vector나 std::make_unique<T[]>로 바꾸는 것이 안전합니다.


같이 보면 좋은 글