[C언어 50강] 31강. 메모리 버그: 누수/댕글링/더블 프리 + 디버깅 감각
동적 메모리를 배운 직후부터 C 코드 품질을 가르는 기준은 문법 실수가 아니라 메모리 버그를 얼마나 체계적으로 다루느냐입니다. 특히 메모리 누수(leak), 댕글링 포인터(dangling pointer), 더블 프리(double free)는 초급자뿐 아니라 실무자도 긴장하는 영역입니다. 오늘은 단순히 "이런 버그가 있다" 수준이 아니라, 왜 발생하고 어떤 신호로 드러나며 어떻게 예방·추적해야 하는지까지 한 번에 정리합니다.
핵심 개념
- 메모리 버그는 대부분 "할당/해제의 생명주기 불일치"에서 시작한다.
free는 메모리를 지우는 함수가 아니라 "반납" 함수이며, 반납 후 주소를 신뢰하면 안 된다.- 디버깅의 핵심은 운에 기대는 재현이 아니라, 버그를 작은 단위로 고립(isolation)시키는 습관이다.
개념 먼저 이해하기
많은 사람이 메모리 버그를 "가끔 터지는 이상한 런타임 에러"로 기억합니다. 그런데 실제로는 꽤 논리적인 원인에서 발생합니다. 먼저 메모리 누수는 "메모리가 아직 살아 있는데 참조 경로를 잃어버린 상태"입니다. 즉, 운영체제 입장에서 그 메모리는 프로그램이 사용 중이라고 보지만, 프로그램 내부에서는 더 이상 접근할 방법이 없습니다. 그래서 장시간 실행되는 프로세스에서 점진적으로 메모리 사용량이 증가하고, 결국 성능 저하나 OOM(out-of-memory)로 이어집니다.
댕글링 포인터는 그 반대 축입니다. 주소는 들고 있는데, 그 주소가 더 이상 유효한 객체를 가리키지 않는 상태입니다. 예를 들어 free(p) 이후에도 p를 읽거나 쓰면, 겉보기에는 가끔 동작하는 것처럼 보일 수 있습니다. 이유는 해제된 메모리 영역이 즉시 덮어쓰기되지 않을 수도 있기 때문입니다. 하지만 이는 "우연히 아직 안 망가진" 상태일 뿐이고, 다른 할당이 끼어드는 순간 데이터 오염, 비정상 종료, 심하면 보안 취약점으로 연결됩니다.
더블 프리는 이미 반납한 메모리를 다시 free하는 버그입니다. 초보자는 "같은 주소를 두 번 free하면 그냥 무시되지 않나?"라고 생각하기 쉬운데, 메모리 할당기(allocator)는 내부 메타데이터로 블록 상태를 관리하므로 중복 해제는 힙 구조 자체를 깨뜨릴 수 있습니다. 환경에 따라 즉시 abort가 나기도 하고, 더 나쁜 경우 잠복하다가 나중에 완전히 다른 위치에서 크래시가 발생합니다. 그래서 재현이 어려운 "유령 버그"처럼 느껴지는 것입니다.
중요한 포인트는 세 버그가 서로 연결된다는 점입니다. 예를 들어 소유권 규칙이 모호하면 A 함수는 "B가 free하겠지"라고 생각하고, B 함수는 "A가 정리했겠지"라고 생각합니다. 그러면 어떤 경로에서는 누수, 다른 경로에서는 더블 프리가 동시에 생깁니다. 즉, 문제는 free 함수 자체가 아니라 설계의 계약(contract) 부재입니다. 실무에서는 이를 막기 위해 "누가 소유자인가", "실패 시 정리 책임은 누구인가", "반납 후 포인터 상태는 어떻게 표준화할 것인가"를 코드 규약으로 명시합니다.
디버깅 감각도 이 계약에서 출발합니다. 크래시 지점만 보는 것이 아니라 "이 포인터의 출생(할당)부터 사망(해제)까지 이력"을 추적해야 합니다. 디버깅할 때는 (1) 재현 입력 고정, (2) 최소 코드로 축소, (3) 경고 옵션 강화, (4) sanitizer/도구 적용 순서로 접근하면 시행착오를 크게 줄일 수 있습니다. 메모리 버그는 감으로 잡는 게 아니라, 생명주기를 문서화해 추적 가능한 상태로 바꾸는 과정에서 잡힙니다.
기본 사용
예제 1) 누수와 소유권 누락
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *make_message(const char *name) {
char *buf = (char *)malloc(64);
if (buf == NULL) return NULL;
snprintf(buf, 64, "Hello, %s", name);
return buf; // 호출자가 free해야 함
}
int main(void) {
char *msg = make_message("Gunwoo");
if (msg == NULL) return 1;
puts(msg);
/* 누수 버전: free(msg);를 빼먹음 */
free(msg);
msg = NULL;
return 0;
}
설명:
- 이 코드는 단순하지만 소유권 규칙이 없으면 누수가 생기기 쉽다.
make_message가 동적 메모리를 반환하므로 "호출자가 해제"라는 계약이 필요하다.- 계약을 주석/API 문서에 명시하지 않으면 호출부마다 해제 유무가 달라져 누수가 퍼진다.
예제 2) 댕글링 포인터
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = (int *)malloc(sizeof(int));
if (p == NULL) return 1;
*p = 42;
printf("before free: %d\n", *p);
free(p);
/* 위험: 해제된 메모리 접근 */
/* printf("after free: %d\n", *p); */
p = NULL; // 방어적 습관
return 0;
}
설명:
free이후p역참조는 정의되지 않은 동작(UB)이다.- "출력은 되던데요?"는 안전하다는 뜻이 아니라 운이 좋았다는 뜻이다.
- 해제 직후
NULL대입은 재사용 실수를 빠르게 드러내는 데 도움 된다.
예제 3) 더블 프리 방지 패턴
#include <stdio.h>
#include <stdlib.h>
int run_task(int fail_at) {
int rc = -1;
int *a = NULL;
int *b = NULL;
a = (int *)malloc(10 * sizeof(int));
if (a == NULL) goto cleanup;
b = (int *)malloc(20 * sizeof(int));
if (b == NULL) goto cleanup;
if (fail_at == 1) goto cleanup;
if (fail_at == 2) goto cleanup;
rc = 0;
cleanup:
free(b);
b = NULL;
free(a);
a = NULL;
return rc;
}
int main(void) {
if (run_task(2) != 0) {
fprintf(stderr, "task failed\n");
}
return 0;
}
설명:
- 여러 실패 분기에서 각각
free를 작성하면 누락/중복 해제가 생기기 쉽다. cleanup단일 지점으로 모으면 정리 순서를 일관되게 유지할 수 있다.free(NULL)은 안전하므로 초기값을NULL로 두면 조건문을 줄일 수 있다.
예제 4) 디버깅 힌트: 주소 추적 로그
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *x = (int *)malloc(4 * sizeof(int));
fprintf(stderr, "alloc x=%p\n", (void *)x);
free(x);
fprintf(stderr, "free x=%p\n", (void *)x);
x = NULL;
fprintf(stderr, "null x=%p\n", (void *)x);
return 0;
}
설명:
%p로그는 포인터 생명주기 추적에 매우 유용하다.- 크래시 직전 주소 패턴을 보면 중복 해제/재사용 여부를 빠르게 좁힐 수 있다.
- 대규모 코드에서는 래퍼 함수(
xmalloc/xfree)로 로그를 표준화하기도 한다.
자주 하는 실수
실수 1) 성공 경로만 보고 에러 경로 정리를 빼먹기
- 원인: 정상 흐름 테스트만 통과하면 완료로 착각한다.
- 해결: 실패 분기별로 "이미 할당된 자원 목록"을 체크하고 cleanup 경로를 통합한다.
실수 2) 포인터 별칭(alias) 존재를 무시
- 원인:
p를 free했는데 같은 주소를q도 들고 있다는 사실을 놓친다. - 해결: 소유 포인터(owner)와 비소유 포인터(borrowed)를 구분하고 네이밍/주석으로 표시한다.
실수 3) 반환 직전에 포인터를 덮어써 누수 발생
- 원인:
buf = realloc(buf, ...)실패 처리 실수 또는 새 할당으로 기존 주소 상실. - 해결: 임시 포인터(
tmp)를 사용하고 성공 시에만 치환한다.
실수 4) "프로그램 종료 시 OS가 정리해주니 괜찮다"는 습관
- 원인: 짧은 실습 코드 기준을 서버/라이브러리 코드에 그대로 적용한다.
- 해결: 학습 단계부터 누수 0을 목표로 두고, 테스트 루프를 장시간 돌려 메모리 증가를 확인한다.
실무 패턴
- 소유권 규약 문서화: 함수 시그니처 옆에 "caller frees" / "callee owns"를 명시.
- 초기화-해제 쌍 설계:
init_x가 있으면destroy_x를 반드시 제공해 사용법을 고정. - 경고를 에러로 취급:
-Wall -Wextra -Wpedantic -Werror로 잠재 결함을 조기 차단. - 도구 기반 검증 루틴화: AddressSanitizer(ASan), LeakSanitizer(LSan),
valgrind/leaks를 CI나 로컬 체크에 포함. - 재현 스크립트 보관: 버그 입력/실행 커맨드를 문서화해 "한 번 잡은 버그"를 회귀 테스트로 전환.
오늘의 결론
한 줄 요약: 메모리 버그는 우연히 잡는 문제가 아니라, 소유권·생명주기 계약을 코드와 도구로 강제할 때 꾸준히 줄어드는 종류의 문제다.
연습문제
char *dup_text(const char *s)를 구현하고, 호출자 책임 해제 규칙을 주석에 명확히 적어보세요.- 두 개의 동적 버퍼를 사용하는 함수에서 중간 실패가 발생해도 누수/더블 프리가 없도록
cleanup패턴으로 작성해보세요. - 의도적으로 use-after-free 버그를 만든 뒤, ASan을 켜고 어떤 리포트가 나오는지 확인해보세요.
이전 강의 정답
- 30강 연습문제 1(정수 N개 동적 할당 후 평균 계산):
malloc(N * sizeof(int))성공 여부를 먼저 확인하고, 합계는 오버플로를 줄이기 위해long long으로 누적한 뒤 평균을 계산하는 방식이 안전합니다. 마지막에free를 호출해 생명주기를 닫아야 합니다.
- 30강 연습문제 2(capacity 확장 동적 배열):
len == cap일 때만realloc을 시도하고,tmp포인터로 실패 안전성을 확보한 뒤buf = tmp로 교체하는 구조가 핵심입니다.len과cap을 분리해 관리해야 경계 오류를 줄일 수 있습니다.
- 30강 연습문제 3(
safe_realloc_int래퍼):- 입력 포인터의 주소(
int **pp)와 새 용량을 받아 내부에서tmp = realloc(*pp, ...)후 성공 시에만*pp = tmp를 갱신하는 방식이 정석입니다. 실패 시 원본 보존이 가장 중요한 계약입니다.
- 입력 포인터의 주소(
실습 환경/재현 정보
- 컴파일러: Apple clang 17+ 또는 gcc 13+
- 컴파일 옵션(기본):
-std=c11 -Wall -Wextra -Wpedantic -O0 -g - Sanitizer 옵션(권장):
-fsanitize=address,undefined -fno-omit-frame-pointer - 실행 환경: macOS(arm64) 터미널 또는 Linux 셸
- 재현 체크:
- 경고/에러 없이 빌드되는지 확인
- 정상·실패 경로 모두 실행해 cleanup 동작 확인
- sanitizer 실행 시 leak/use-after-free 리포트가 없는지 확인
- 포인터 주소 로그로 할당-해제 순서가 일치하는지 점검