13편에서 동적 메모리를 직접 할당하고 해제하는 법을 익혔다면, 이제는 "실수로 메모리를 잘못 다뤘을 때" 나타나는 증상을 따라가야 할 차례입니다. 메모리 오류는 재현할 때마다 결과가 달라지기도 해서 초보자에게 특히 어렵지만, 유형을 나눠 보면 패턴이 있습니다. 이번 글은 대표적인 오류를 재현하고, C에서 흔히 쓰는 디버깅 도구를 체험해 보는 데 초점을 둡니다.
이번 글에서 새로 나오는 용어
- 세그멘테이션 폴트 (Segmentation Fault): 허용되지 않은 메모리 영역을 접근할 때 운영체제가 내리는 강제 종료
- Use-after-free:
free로 해제한 메모리를 다시 사용하는 버그 - 더블 프리 (Double Free): 같은 포인터를 두 번 이상
free하는 실수 - 메모리 검사 도구: [[valgrind|
valgrind]], AddressSanitizer처럼 런타임에서 메모리 접근을 감시하는 도구
핵심 개념
학습 메모
- 소요 시간: 60분 내외
- 준비물: 포인터와 동적 메모리 기초, 명령줄에서 프로그램 실행 경험
- 학습 목표: 대표적인 메모리 오류 유형과 재현 방법, 도구 기반 디버깅 흐름 익히기
메모리 오류는 크게 세 가지에서 시작합니다.
- 잘못된 주소 접근: 배열 범위를 벗어나거나,
NULL포인터를 역참조하는 경우 - 수명 관리 실패:
free이후 포인터를 그대로 사용하거나, 해제를 두 번 호출하는 경우 - 초기화되지 않은 값 사용: 값을 넣지 않은 변수를 조건식이나 연산에 사용하는 경우
이 오류들은 증상이 비슷하지만 원인이 다르므로, 증상 → 추측 → 재현 → 수정 순서를 꾸준히 연습해야 합니다. 이번 글에서는 아래 내용을 다룹니다.
- out-of-bounds, use-after-free, double free, 초기화 누락 사례
gdb나lldb없이도 재현과 수정이 가능한 디버깅 루틴- AddressSanitizer,
valgrind를 통한 버그 위치 확인 - 버그를 막는 생활 패턴(포인터 무효화, 범위 체크, 수명표 작성)
코드로 따라하기
배열 범위 벗어나기
#include <stdio.h>
int main(void) {
int numbers[3] = {1, 2, 3};
for (int i = 0; i <= 3; i++) {
printf("numbers[%d] = %d\n", i, numbers[i]);
}
return 0;
}
i <= 3 때문에 numbers[3]까지 접근합니다. 존재하지 않는 인덱스를 읽으면 쓰레기 값이 출력되거나 세그멘테이션 폴트가 발생할 수 있습니다. 하지만 항상 즉시 크래시가 나는 것은 아니며, 이런 코드는 결과가 환경마다 달라질 수 있는 정의되지 않은 동작입니다. 즉시 고치려면 반복 조건을 i < 3으로 바꿉니다. 이 예제는 "배열 길이와 비교 연산자를 항상 함께 확인하자"는 규칙을 심어 줍니다.
Use-after-free 재현하기
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *value = malloc(sizeof(int));
if (value == NULL) {
return 1;
}
*value = 42;
free(value);
printf("value = %d\n", *value); // 해제된 메모리를 다시 읽음
return 0;
}
이 코드는 운이 좋으면 42를 출력하고, 운이 나쁘면 세그멘테이션 폴트가 날 수도 있습니다. 하지만 어떤 결과가 나오든 이미 잘못된 코드이며, 이것도 정의되지 않은 동작입니다. 해제된 주소는 운영체제가 언제든 다른 용도로 재사용할 수 있으므로, free 이후에는 포인터를 NULL로 바꾸고 다시 사용하지 않는 습관을 들입니다. 물론 NULL 대입이 모든 use-after-free를 막아 주는 것은 아니지만, 같은 변수를 통한 재사용과 이중 해제를 줄이는 데는 도움이 됩니다.
Double free와 방지 패턴
#include <stdlib.h>
int main(void) {
int *data = malloc(sizeof(int) * 2);
if (data == NULL) {
return 1;
}
free(data);
free(data); // 같은 포인터를 다시 해제
return 0;
}
같은 포인터를 두 번 해제하면 힙 관리 구조가 손상됩니다. 이것 역시 결과가 환경마다 달라질 수 있는 정의되지 않은 동작입니다. 이를 방지하려면 free한 뒤 포인터를 곧바로 NULL로 바꾸거나, 해제 책임을 함수 하나에만 모으는 구조를 선택합니다.
초기화되지 않은 메모리 사용
#include <stdio.h>
int main(void) {
int total;
if (total > 0) {
printf("positive\n");
}
return 0;
}
total에 어떤 값이 들어 있는지 모르므로, 조건식 결과도 예측할 수 없습니다. 이것은 포인터 오류와는 조금 다르지만, 역시 초기화되지 않은 값을 읽는 정의되지 않은 동작입니다. 지역 변수를 선언할 때는 항상 초기값을 지정하거나, memset 또는 calloc으로 새로 확보한 메모리를 0으로 맞춥니다.
AddressSanitizer로 버그 찾기
AddressSanitizer(약칭 ASan)는 GCC나 Clang에서 -fsanitize=address 옵션으로 바로 사용할 수 있는 런타임 검사기입니다.
clang -g -fsanitize=address -o uaf use_after_free.c
./uaf
실행하면 어떤 주소에서 잘못된 접근이 일어났는지, 해제된 포인터를 언제 만들었는지를 친절하게 보여 줍니다. 초반에는 "오류 메시지를 그대로 복사해 번역하는 습관"을 들이면 어떤 정보가 중요한지 빨리 익힐 수 있습니다.
valgrind로 누수 확인하기
valgrind --leak-check=full ./program처럼 실행하면 프로그램 종료 시점에 해제되지 않은 메모리 블록을 알려 줍니다. Linux에서는 비교적 널리 쓰이고, macOS 최신 환경에서는 제약이 있을 수 있습니다. 출력에서 "definitely lost" 항목이 0이 될 때까지 free 호출을 추가해 봅니다.
메모리 디버깅 루틴 만들기
작은 프로젝트라면 아래 표처럼 간단한 수명표를 만들어 두는 것도 도움이 됩니다.
| 포인터 | 할당 함수 | 해제 함수 | 비고 |
|---|---|---|---|
buffer |
read_file |
cleanup |
실패 시 cleanup에서만 free |
- 포인터마다 "누가 할당했는가"와 "누가 해제할 것인가"를 적어 두면 책임이 분명해집니다.
- 복잡한 함수일수록
goto cleanup;패턴으로 마지막에 한 번만free하도록 모으는 방식이 유지보수에 좋습니다.
왜 중요한가
- 메모리 오류는 프로그램을 즉시 종료시키거나, 더 위험하게는 잘못된 데이터를 조용히 만들어 냅니다.
- 오류 유형을 이름으로 분류할 수 있어야 도구 메시지를 이해하고 검색할 때도 정확한 키워드를 사용할 수 있습니다.
- AddressSanitizer,
valgrind같은 도구는 이후 포인터가 많은 프로젝트를 다룰 때 필수 안전망이 됩니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요