1. 도입
프로그래머가 처음으로 new와 delete를 보면 머릿속에 위험한 생각이 떠오릅니다: "오, 이제 원할 때 어디서나 객체를 만들 수 있어! 나는 메모리의 주인이다!" 이 기분은 프로그램이 오류가 발생한 곳이 아니라 어딘가에서 std::cout 근처에서 떨어지기 시작할 때 빠르게 사라집니다(클래식). 이유는 간단합니다: 수동 메모리 관리는 매우 쉽게 위반할 수 있는 계약입니다. 오늘은 이 계약을 위반하는 세 가지 가장 인기 있는 방법을 살펴봅니다.
기준을 유지하기 위해 수동 메모리의 "황금 불변식"(항상 참이어야 하는 것)을 정리해둡니다:
각 new / new[] 로 획득한 자원은 해당하는 delete / delete[] 로 정확히 한 번 해제되어야 하며
더 이상 사용되지 않을 때까지 해제하면 안 됩니다.
불변식이 깨지면 본 강의의 세 가지 주인공 중 하나가 발생합니다.
지형도: 세 가지 오류와 그 차이
예제로 들어가기 전에 개념을 혼동하지 않도록 잠깐 "상식 표"를 만들어 보는 것이 유용합니다. 이 버그들은 결과가 비슷해서 자주 혼동됩니다: "모든 것이 망가졌다". 하지만 망가지는 방식은 다릅니다.
| 버그 | 무엇을 위반했나 | 전형적인 증상 | 왜 교활한가 |
|---|---|---|---|
|
자원을 해제하지 않음 | 프로그램이 메모리를 점점 더 많이 사용, 때때로 오랜 시간이 지난 후 충돌 | 작은 테스트에서는 나타나지 않을 수 있음 |
|
자원을 두 번 해제함 | 비정상 종료, allocator 충돌, "heap corruption" | 오류가 발생한 위치와 충돌 위치가 멀리 떨어져 있는 경우가 많음 |
|
자원을 해제한 후 사용함 | "쓰레기 데이터", 드문 충돌, 이상한 값 | 메모리가 재사용되기 전까지는 때때로 "작동"함 |
이제 우리는 바로 이 순서대로 진행합니다: 먼저 누수(가장 "조용한" 것), 다음으로 double-free(가장 시끄러운 것), 마지막으로 use-after-free(가장 신비로운 것).
2. Leak: 메모리를 '잃어버려' 유출된 경우
메모리 누수는 메모리를 할당했지만 해제하지 않은 상황입니다. 중요한 점: 메모리가 운영체제 수준에서 "영구적으로" 유실될 필요는 없습니다 — 프로세스가 종료되면 OS가 모두 회수합니다. 하지만 프로그램이 실행되는 동안 누수는 메모리 사용량 증가를 의미하고, 때로는 할당 및 단편화로 인해 성능 저하를 초래합니다.
가장 불쾌한 부분은 누수가 종종 "무해해 보인다는" 것입니다: 프로그램은 올바른 값을 출력하고 테스트를 통과하지만 문제는 장시간 실행이나 부하 상황에서만 나타납니다. 즉, 당신이 가장 원하지 않는 곳에서 서프라이즈가 발생합니다.
가장 단순한 leak: delete를 잊음
"교과서" 예제로 시작합니다. 진부하지만 초심자가 익숙해지는 방식이 바로 이렇습니다: 할당하고 잊음.
#include <iostream>
int main() {
int* p = new int{10};
std::cout << *p << '\n'; // 10
// delete p; // 잊음 => leak
}
여기서 문제는 프로그램이 반드시 충돌한다는 것이 아닙니다. 문제는 당신이 계약을 위반했다는 것입니다: 자원이 필요 이상으로 오래 살아 있고, 더 이상 제어할 수 없습니다.
조기 return으로 인한 Leak: "함수에서 나가면서 문을 닫지 않음"
이것은 현실과 더 가깝습니다: 로직이 분기되고, 어느 분기에서 함수에서 나가면서 자원을 해제하지 않습니다.
#include <string>
int* parse_positive_or_null(const std::string& s) {
int* p = new int{0};
if (s.empty()) {
return nullptr; // leak: p 가 해제되지 않음
}
*p = 42; // 가정: 파싱 완료
return p;
}
여기서 중요한 것은 return 자체의 문제가 아닙니다. 문제는 delete가 분기들에 흩어져 있고(어떤 분기에는 전혀 없음) 분기가 많아질수록 실수할 확률이 커진다는 것입니다. 이것이 수동 메모리가 복잡한 코드에서 잘 확장되지 않는 이유 중 하나입니다.
포인터 덮어쓰기로 인한 Leak: "마지막 주소를 잃음"
매우 흔한 실수: 메모리를 할당한 뒤 포인터에 새 주소를 할당하면 이전 주소를 잃게 됩니다 — 더 이상 해제할 방법이 없습니다(참조가 사라짐).
int main() {
int* p = new int{1};
p = new int{2}; // leak: {1}의 주소를 잃음
delete p; // {2}만 해제
p = nullptr;
}
이는 변수를 재사용하여 "코드 줄 수를 줄인다"는 생각으로 자주 발생합니다. 결국 절약되는 것은 줄 수뿐이며 메모리나 정신적 비용은 절약되지 않습니다.
루프에서의 Leak: "조금씩, 매번"
누수는 반복되는 코드에 들어가면 정말 위험해집니다. 그때는 "조금씩 새어나가다 큰 피해" 효과가 발생합니다.
#include <iostream>
int main() {
for (int i = 0; i < 3; ++i) {
int* p = new int{i};
std::cout << *p << ' '; // 0 1 2
// delete p; // 잊음 => 각 반복마다 leak
}
std::cout << '\n';
}
세 번 반복할 때는 아무도 눈치채지 못합니다. 삼백만 번이면 노트북도 팬이 의심스럽게 돌기 시작하며 비행기 소리를 내며(유머) 문제를 드러냅니다.
4. Double-free: 자원을 두 번 해제함(이제 UB)
double-free는 "방을 두 번 청소했네, 뭐가 나쁘지?"처럼 들리지만 메모리에서는 정반대입니다: 두 번째 delete는 이미 해제된 자원을 또 해제하려고 합니다. 그 이후에는 Undefined Behavior가 시작됩니다: 프로그램이 즉시 죽을 수도 있고 나중에 죽을 수도 있으며, 내부 allocator 구조를 손상시켜 다른 곳에서 충돌을 유발할 수 있습니다.
초심자에게서 double-free가 자주 발생하는 가장 흔한 원인은 "포인터를 복사하는 것"과 "객체를 복사하는 것"의 차이를 이해하지 못해서입니다. 포인터는 주소일 뿐입니다. 주소를 복사하면 같은 자원에 대한 두 개의 이름을 얻게 됩니다. 두 이름이 모두 소유자라고 생각하면 둘 다 delete를 시도합니다.
하나의 new에 대한 두 포인터: "하나의 냉장고에 두 명의 주인"
int main() {
int* p = new int{5};
int* q = p; // q 와 p 는 같은 객체를 가리킴
delete p;
// delete q; // UB: double-free (실행하지 않음)
q = nullptr;
}
핵심 사고: T*를 복사하면 주소를 복사할 뿐이며 두 번째 객체를 만들지 않습니다.
삭제를 수행하는 "편한" 함수 때문에 발생하는 Double-free
때로는 함수 계약이 나빠서 코드가 망가집니다. 예를 들어 포인터를 "처리"하기 위해 함수에 넘겼는데, 그 함수가 포인터를 받았으니 자신이 해제해야 한다고 판단하면 문제가 됩니다. 반면 호출자는 자신이 해제할 것이라고 생각합니다. 결과는 두 번의 delete입니다.
#include <iostream>
void print_and_delete(int* p) {
std::cout << *p << '\n'; // 예: 출력
delete p; // 그리고 갑자기 해제함
}
int main() {
int* p = new int{7};
print_and_delete(p);
// delete p; // UB: double-free (실행하지 않음)
p = nullptr;
}
이 예제가 중요한 이유는 "이렇게 하면 안 된다"는 것뿐만 아니라 소유권은 명확해야 한다는 점을 보여주기 때문입니다. int* 타입만으로는 누가 소유자인지 알 수 없습니다. 이것이 "원시" API의 근본적인 문제 중 하나입니다.
해제 후 포인터를 0으로 초기화하지 않아 발생하는 Double-free: 오래된 주소에 대한 반복 delete
때때로 두 번째 delete는 포인터를 복사했기 때문이 아니라, 변수를 재사용하면서 이미 해제되었다는 사실을 잊었기 때문에 발생합니다.
int main() {
int* p = new int{1};
delete p;
// ... 많은 코드 ...
// delete p; // UB: 같은 주소에 대한 두 번째 delete (실행하지 않음)
p = nullptr;
}
네, p = nullptr;는 소유권 구조를 고치지는 못합니다. 그러나 이는 "조용한 UB"를 보다 명시적인 오류로 바꾸는 데 종종 도움이 됩니다: 나중에 누군가 delete p를 시도하면 delete nullptr는 안전하며 아무것도 망가지지 않습니다.
5. Use-after-free: 객체는 이미 죽었는데 여전히 그와 대화함
Use-after-free는 메모리를 해제했지만 그 메모리를 가리키는 포인터를 계속 사용했을 때 발생합니다. 기술적으로 delete 이후 포인터는 dangling 상태가 됩니다: 여전히 주소를 가지고 있지만 그 주소에는 더 이상 유효한 객체가 없습니다.
교활함은 여기서 비롯됩니다: 종종 사람들은 "주소는 같은데 데이터도 그대로일 거야"라고 생각합니다. 그러나 C++는 그런 것을 보장하지 않습니다: delete 이후 그 메모리는 즉시 다른 용도로 재할당될 수도 있고 일정 시간 동안 "그대로" 있을 수도 있습니다. 그래서 use-after-free는 때때로 "작동"합니다(이는 충돌하는 것보다 더 위험함).
직접적인 use-after-free: delete 후 역참조
#include <iostream>
int main() {
int* p = new int{99};
delete p;
// std::cout << *p << '\n'; // UB: use-after-free (실행하지 않음)
p = nullptr;
}
운이 좋으면 프로그램이 충돌합니다. 운이 없으면 어떤 숫자를 얻고 "괜찮다"고 생각합니다. 그리고 그 "괜찮음"이 나중에 프로덕션 버그가 됩니다.
저장된 주소를 통한 use-after-free: 포인터가 밖으로 빠져나감
더 현실적인 버전: 주소를 다른 곳에 저장해 두었다가 해제한 뒤 그 저장된 복사본을 사용하는 경우입니다.
#include <iostream>
int main() {
int* p = new int{10};
int* alias = p; // 같은 주소에 대한 두 번째 "라벨"
delete p;
// std::cout << *alias << '\n'; // UB: use-after-free (실행하지 않음)
alias = nullptr;
}
심리적 포인트에 유의하세요: p = nullptr;를 성실히 했더라도 alias는 여전히 dangling 상태로 남아 있습니다. 따라서 포인터를 0으로 만드는 것은 주소가 복사된 경우 보편적인 방패가 아닙니다.
"랜덤"처럼 보이는 use-after-free: 메모리가 다른 new로 재사용됨
매우 교육적인 효과: 객체를 삭제한 뒤 어디선가 또 다른 new를 호출하면 allocator가 같은 주소를 반환할 수 있습니다. 그러면 이전의 dangling 포인터가 갑자기 "새로운 객체"를 가리키게 됩니다. 그를 통해 읽으면 다른 객체의 데이터를 얻게 됩니다. 거의 탐정 소설의 한 장면 같습니다.
#include <iostream>
int main() {
int* p = new int{1};
delete p;
int* q = new int{777}; // 때때로 p와 같은 주소를 받을 수 있음
// std::cout << *p << '\n'; // UB: use-after-free (실행하지 않음)
delete q;
}
왜 이것이 무서운가요: 프로그램이 충돌했다면 문제를 빨리 찾았을 것입니다. 하지만 "작동하지만 잘못된 결과"를 내면 유령 버그를 포착하기가 매우 어렵습니다.
이 버그들이 이상하게 나타나는 이유: 간단한 allocator 모델
오류가 즉시 정직하게 프로그램을 망가뜨릴 것이라고 믿고 싶지만 현실은 다릅니다: 메모리 관리자(allocator)는 해제된 블록을 재사용하고, 자유 블록의 리스트를 유지하고, 블록을 합치고 나누는 복잡한 시스템입니다. 따라서 delete는 메모리를 "제로로 지운다"가 아니라 "블록을 시스템으로 반환한다"는 의미입니다.
생명 주기의 간단한 도표를 기억해 두면 유용합니다:
flowchart LR
A[new 할당 블록] --> B[객체가 존재함]
B --> C[delete로 수명 종료]
C --> D[블록은 재사용 가능해짐]
D --> E[새로운 new가 같은 주소를 반환할 수 있음]
이 도표에서 두 가지 불쾌한 효과가 따라옵니다. 첫째, use-after-free가 때때로 "그럴듯한" 데이터를 반환할 수 있다는 점입니다(블록이 아직 재사용되지 않았기 때문). 둘째, double-free는 두 번째 delete에서 즉시 충돌하지 않을 수 있고 내부 구조를 망가뜨려 전혀 다른 new나 delete 호출에서 나중에 충돌을 일으킬 수 있습니다.
6. 미니 케이스: TaskBoard와 char*에 관한 레거시 노트
버그를 진공 상태에서 연구하지 않기 위해, 간단한 콘솔 애플리케이션 TaskBoard(가칭)를 가정해봅시다. 여기서 작업은 std::vector에 있고, 제목은 std::string에 저장됩니다. 모두 괜찮습니다… 누군가가 작업에 "C 스타일 노트"를 추가하기로 결정할 때까지, 이유는 "더 빠르니까" 또는 "new[]를 연습하고 싶어서" 같은 사소한 이유입니다.
노트를 위해 C 문자열을 할당하고 char*을 반환하는 작은 함수를 만들어 보겠습니다. 이것은 권장 방식이 아니라 위험을 보여주기 위한 교육용 예제입니다.
C 문자열을 위한 메모리 할당
#include <cstring>
#include <string>
char* make_note_cstr(const std::string& note) {
char* p = new char[note.size() + 1];
std::memcpy(p, note.c_str(), note.size() + 1); // '\0' 포함
return p; // 주의: 누가 delete[] 할 것인가?
}
여기서 고전적인 설계 문제가 시작됩니다: 반환 타입 char*만으로는 누가 소유자이고 언제 해제해야 하는지 전혀 알 수 없습니다. 즉, 우리의 세 가지 버그가 모두 "자연스러운" 시나리오가 됩니다.
Leak: 노트를 받고 해제하지 않음
#include <iostream>
#include <string>
int main() {
char* note = make_note_cstr("Call mom");
std::cout << note << '\n'; // Call mom
// delete[] note; // 잊음 => leak
}
한 번의 잊힌 delete[]는 사소해 보일 수 있습니다. 그러나 노트가 자주 생성되면(예: 작업이 업데이트될 때마다) 누수는 시스템적 문제가 됩니다.
Double-free: 두 모듈이 모두 "노트의 소유자"라고 판단함
#include <string>
void store_note_somewhere(char* note) {
// ... 저장 ...
delete[] note; // "우리가 책임이라고 판단함"
}
int main() {
char* note = make_note_cstr("Buy milk");
store_note_somewhere(note);
// delete[] note; // UB: double-free (실행하지 않음)
}
이 상황은 특히 팀 개발에서 자주 발생합니다: 한 사람이 함수를 작성하고 다른 사람이 그것을 호출하며 소유권 계약이 어디에도 명확히 문서화되어 있지 않거나(또는 주석으로만 있고 아무도 읽지 않음) 하는 경우입니다.
Use-after-free: 노트가 삭제되었지만 포인터는 작업에 남아 있음
가령 작업 객체 어딘가에 char* note;가 저장되어 있고, 노트를 업데이트할 때 delete[] note를 호출하지만 필드를 갱신하지 않거나 다른 곳에서 여전히 참조하고 있는 경우를 상상해보세요.
#include <iostream>
int main() {
char* note = make_note_cstr("Old note");
delete[] note;
// std::cout << note << '\n'; // UB: use-after-free (실행하지 않음)
note = nullptr;
}
여기서의 교훈은 동일합니다: 수동으로 메모리를 관리한다면 주소가 "번진" 모든 장소에서 수명 관리를 수동으로 보장해야 합니다.
7. 전형적인 실수들
실수 №1: "그냥 함수에서 T*를 반환하면 알아서 처리하겠지".
동적 객체에 대한 원시 포인터를 반환하는 것은 거의 항상 소유권 계약의 구멍을 만듭니다. 코드는 컴파일되고 작은 예제에서는 작동하지만, 나중에 누수(아무도 해제하지 않음)나 double-free(두 번 해제됨)가 갑자기 나타납니다. 책임이 명확히 정해져 있지 않기 때문입니다.
실수 №2: 포인터 복사가 객체 복사로 인식됨.
아마도 초심자에게 가장 흔한 double-free 및 use-after-free 원인입니다. int* q = p;는 두 번째 int를 만들지 않고 같은 주소에 대한 두 번째 라벨을 만듭니다. "이제 메모리에 두 개의 이름이 있다"라고 명확히 의식하지 않으면, 뇌가 존재하지 않는 "객체 복사"를 자동으로 그려버립니다.
실수 №3: delete가 분기와 조기 종료들에 걸쳐 흩어짐.
함수에 여러 개의 return과 여러 분기가 생기고 delete가 "어딘가에" 있을 때 누수 확률은 거의 통계적 필연성이 됩니다. 보통 코드를 훑어보며 "여기서 우리가 정말로 해제했나?" 하고 생각해야 한다면 이미 위험합니다.
실수 №4: delete 후에도 포인터에 접근하거나 다른 곳으로 전달함.
Use-after-free는 종종 악의가 아니라 건망증에서 태어납니다: 객체를 해제했지만 주소가 변수에 남아 있거나 구조체 필드를 갱신하지 않았거나 "임시 alias"가 어딘가에 저장되어 있습니다. delete 이후 포인터는 무효로 간주해야 합니다. p = nullptr;는 도움이 되지만 주소가 복사된 경우에는 해결책이 아닙니다.
실수 №5: new[]와 delete (혹은 그 반대)를 "거의 같다"고 생각함.
이미 본 주제이지만, 이는 오늘의 고장들과 직접 연결되어 있습니다: 잘못된 해제 방식도 UB이며 흔히 "손상된 heap"처럼 나타나므로 double-free/use-after-free와 유사한 증상을 보입니다. 배열을 할당했다면 delete[]로 해제하세요 — 타협은 금물입니다.
GO TO FULL VERSION