[C 시리즈 14편] 메모리 오류를 찾고 디버깅하는 법

English version

13편에서 동적 메모리를 직접 할당하고 해제하는 법을 익혔다면, 이제는 "실수로 메모리를 잘못 다뤘을 때" 나타나는 증상을 따라가야 할 차례입니다. 메모리 오류는 재현할 때마다 결과가 달라지기도 해서 초보자에게 특히 어렵지만, 유형을 나눠 보면 패턴이 있습니다. 이번 글은 대표적인 오류를 재현하고, C에서 흔히 쓰는 디버깅 도구를 체험해 보는 데 초점을 둡니다.

이번 글에서 새로 나오는 용어

  1. 세그멘테이션 폴트 (Segmentation Fault): 허용되지 않은 메모리 영역을 접근할 때 운영체제가 내리는 강제 종료
  2. Use-after-free: free로 해제한 메모리를 다시 사용하는 버그
  3. 더블 프리 (Double Free): 같은 포인터를 두 번 이상 free하는 실수
  4. 메모리 검사 도구: [[valgrind|valgrind]], AddressSanitizer처럼 런타임에서 메모리 접근을 감시하는 도구

핵심 개념

학습 메모

  • 소요 시간: 60분 내외
  • 준비물: 포인터와 동적 메모리 기초, 명령줄에서 프로그램 실행 경험
  • 학습 목표: 대표적인 메모리 오류 유형과 재현 방법, 도구 기반 디버깅 흐름 익히기

메모리 오류는 크게 세 가지에서 시작합니다.

  1. 잘못된 주소 접근: 배열 범위를 벗어나거나, NULL 포인터를 역참조하는 경우
  2. 수명 관리 실패: free 이후 포인터를 그대로 사용하거나, 해제를 두 번 호출하는 경우
  3. 초기화되지 않은 값 사용: 값을 넣지 않은 변수를 조건식이나 연산에 사용하는 경우

이 오류들은 증상이 비슷하지만 원인이 다르므로, 증상 → 추측 → 재현 → 수정 순서를 꾸준히 연습해야 합니다. 이번 글에서는 아래 내용을 다룹니다.

  • out-of-bounds, use-after-free, double free, 초기화 누락 사례
  • gdblldb 없이도 재현과 수정이 가능한 디버깅 루틴
  • 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는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: 예제 코드를 그대로 작성해 -fsanitize=address를 붙였다 뗐다 하며 어떤 메시지가 출력되는지 비교합니다.
  • 확장하기: malloc/free가 여러 번 등장하는 간단한 구조체 관리 함수를 작성하고, valgrind로 누수가 없는지 확인합니다.
  • 디버깅: 일부러 numbers[i] 범위 조건을 잘못 설정한 뒤 gdblldb에서 중단점을 걸어 값이 어떻게 변하는지 관찰합니다.
  • 완료 기준: "어떤 오류인지 이름 붙이고, 어떤 도구로 확인할 수 있는지"를 한 문장으로 정리할 수 있으면 충분합니다.

마무리

포인터를 다루는 이상 메모리 오류는 언제나 따라붙습니다. 핵심은 "에러 메시지를 읽는 눈"과 "수명 관리 규칙"을 함께 갖추는 것입니다. 다음 편에서는 전처리기와 매크로를 이용해 반복되는 패턴을 줄이고, 환경별로 코드를 다르게 빌드하는 방법을 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요