|

C 언어 malloc free 사용법: 자주 하는 실수 정리

C 언어 malloc free 사용법과 메모리 실수를 설명하는 대표 이미지
누수와 double free를 피하는 기본기를 예제로 정리한다

C 언어 malloc free 사용법은 문법 하나를 외우는 문제가 아닙니다. 어떤 메모리가 내 책임인지를 끝까지 추적하는 문제에 더 가깝습니다. 그래서 입문 단계에서는 malloc보다 free에서 더 자주 무너집니다.

이 글은 정의부터 시작하지 않겠습니다. 먼저 실제로 많이 보이는 버그 코드부터 보고, 그 다음에 메모리 누수, double free, NULL 처리, 배열 할당을 어떻게 이해해야 하는지 차근차근 정리하겠습니다.


문제가 생기는 코드부터 보자

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *scores = malloc(3 * sizeof *scores);

    scores[0] = 10;
    scores[1] = 20;
    scores[2] = 30;

    printf("%d\n", scores[1]);
    return 0;
}

겉으로 보기에는 잘 돌아갈 수 있습니다. 하지만 이 코드는 free를 하지 않았기 때문에 메모리 누수가 있습니다. 프로그램이 바로 끝나는 짧은 예제에서는 티가 덜 날 수 있지만, 반복 실행되거나 오래 살아 있는 프로그램에서는 문제가 쌓입니다.

#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof *p);
    free(p);
    free(p);
    return 0;
}

이 코드는 더 위험합니다. 같은 포인터를 두 번 해제하는 double free입니다. 어떤 환경에서는 바로 비정상 종료가 나고, 어떤 환경에서는 겉으로 멀쩡해 보일 수도 있습니다. 더 무서운 이유는 바로 이 점입니다. 결과가 일정하지 않기 때문에 디버깅이 어렵습니다.

#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof *p);
    free(p);
    *p = 42;
    return 0;
}

이건 free 이후에도 옛 포인터를 그대로 믿고 쓰는 use-after-free입니다. free를 했다고 해서 포인터 변수 자체가 자동으로 사라지는 것은 아닙니다. 값이 남아 있을 뿐이고, 그 값을 다시 믿고 접근하면 이미 내 것이 아닌 메모리를 건드리게 됩니다.

즉, 동적 메모리 버그는 대부분 malloc 자체보다 누가 소유하고 언제 반납해야 하는지 놓치는 순간에 생깁니다.


C 언어 malloc free 사용법의 핵심 규칙

malloc은 필요한 크기만큼 메모리를 빌려 오는 함수입니다. cppreference의 malloc 문서처럼, 성공하면 포인터를 돌려주고 실패하면 NULL을 돌려줍니다. 이 메모리는 초기화되지 않은 상태입니다.

free는 그렇게 빌려 온 메모리를 반납하는 함수입니다. cppreference의 free 문서malloc(3) 매뉴얼 모두, malloc 계열 함수로 받은 메모리만 free해야 하고 이미 반납한 포인터를 다시 free하면 정의되지 않은 동작이라고 설명합니다.

여기서 가장 중요한 감각은 이것입니다. malloc으로 받은 메모리는 자동으로 정리되지 않습니다. 함수가 끝났다고, 포인터 변수가 스코프를 벗어났다고, 메모리까지 자동으로 free되는 것은 아닙니다.


NULL 체크를 빼면 왜 위험할까

int *arr = malloc(100 * sizeof *arr);
arr[0] = 1;

짧은 예제에서는 이런 코드가 너무 흔해서 그냥 지나가기 쉽습니다. 하지만 malloc은 실패할 수 있고, 실패하면 NULL을 돌려줍니다. 그 상태에서 arr[0]에 접근하면 NULL 포인터를 역참조하는 셈이라 바로 터질 수 있습니다.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *arr = malloc(100 * sizeof *arr);
    if (arr == NULL)
    {
        fprintf(stderr, "malloc failed\n");
        return 1;
    }

    arr[0] = 1;
    arr[1] = 2;

    free(arr);
    return 0;
}

입문 단계에서는 성공만 상상하기 쉽습니다. 하지만 메모리 할당은 언제든 실패할 수 있는 작업입니다. 그래서 malloc 다음에는 사용보다 먼저 실패 가능성을 보는 습관이 필요합니다.


배열 할당은 이렇게 쓰면 덜 헷갈린다

int *arr = malloc(5 * sizeof *arr);

동적 배열을 만들 때는 보통 이런 패턴이 가장 편합니다. 포인트는 sizeof 안에 타입 이름을 반복하지 않고 실제 포인터를 넣는 것입니다. cppreference 예제도 이런 스타일을 보여줍니다.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    size_t count = 5;
    int *arr = malloc(count * sizeof *arr);
    if (arr == NULL)
    {
        return 1;
    }

    for (size_t i = 0; i < count; ++i)
    {
        arr[i] = (int)(i + 1) * 10;
    }

    for (size_t i = 0; i < count; ++i)
    {
        printf("arr[%zu] = %d\n", i, arr[i]);
    }

    free(arr);
    return 0;
}

이 방식이 좋은 이유는 타입을 바꾸더라도 sizeof 부분을 같이 수정할 필요가 적기 때문입니다. 예를 들어 int에서 double로 바뀌어도 선언만 바꾸면 실수 가능성이 줄어듭니다.

참고로 메모리를 0으로 채운 상태가 꼭 필요하다면 calloc을 볼 수 있습니다. Linux의 malloc(3) 문서는 calloc이 n과 size의 곱셈 오버플로를 감지한다는 점도 같이 설명합니다. 다만 이 글의 중심은 malloc/free 기본기이므로 여기서는 비교 포인트만 기억하면 충분합니다.


메모리 누수는 왜 생길까

메모리 누수는 어렵게 말하면 할당한 메모리에 더 이상 도달할 수 없는데도 반납하지 못한 상태입니다. 쉽게 말하면 빌려 놓고 영수증을 잃어버린 상태에 가깝습니다.

int *p = malloc(10 * sizeof *p);
p = malloc(20 * sizeof *p);

이 코드에서는 첫 번째 malloc이 돌려준 주소를 p가 들고 있었는데, 두 번째 malloc 결과로 덮어써 버렸습니다. 이제 첫 번째 블록은 free할 방법이 사라졌습니다. 짧지만 아주 전형적인 누수 예제입니다.

  • free를 아예 빼먹는 경우
  • 중간 return이나 error path에서 free를 놓치는 경우
  • 새 주소를 대입하면서 이전 주소를 잃어버리는 경우
  • 함수 사이에 소유권이 누구에게 있는지 모호한 경우

실무에서 누수는 거대한 코드보다 오히려 이런 짧은 틈에서 많이 시작합니다. 그래서 할당 직후에는 어떻게 쓸지만 보지 말고, 어디서 정리할지도 같이 정해야 합니다.


double free와 use-after-free를 같이 기억하자

둘은 다른 버그지만 출발점은 비슷합니다. 이미 내 책임에서 끝난 메모리를 계속 내 것처럼 다룬다는 점입니다.

  • double free: 이미 반납한 메모리를 또 반납함
  • use-after-free: 이미 반납한 메모리를 다시 읽거나 씀
int *p = malloc(sizeof *p);
if (p == NULL)
{
    return 1;
}

free(p);
p = NULL;

free 직후에 포인터를 NULL로 바꾸는 습관은 실수 방지에 도움이 됩니다. 특히 같은 변수 하나를 반복해서 쓰는 초반 코드에서는 효과가 큽니다. 그 이유는 free(NULL)은 아무 일도 하지 않기 때문입니다. 하지만 이 습관을 만능 안전장치처럼 생각하면 안 됩니다. 다른 별칭 포인터가 남아 있으면 그쪽에서는 여전히 use-after-free가 날 수 있습니다.

즉, NULL 대입은 좋은 습관이지만, 소유권 정리를 대신해 주는 마법은 아닙니다.


free(NULL)은 안전하지만 double free는 안전하지 않다

이 둘을 비슷하게 느끼는 입문자가 많습니다. 이름만 보면 둘 다 free 관련 예외처럼 보이기 때문입니다. 하지만 의미는 완전히 다릅니다.

  1. free(NULL)은 그냥 아무 일도 하지 않습니다.
  2. free(유효한 주소)는 정상 해제입니다.
  3. free(이미 해제한 주소)는 정의되지 않은 동작입니다.

그래서 조건문으로 free를 감쌀 때도 너무 복잡하게 생각할 필요는 없습니다. 포인터가 NULL일 가능성이 있는 코드는 free(p)를 그대로 써도 됩니다. 진짜로 조심해야 하는 것은 NULL이 아니라 이미 끝난 메모리를 또 믿는 상황입니다.


처음부터 이렇게 쓰면 실수가 줄어든다

  1. malloc 직후에는 바로 NULL 체크를 한다.
  2. 배열 할당은 count * sizeof *ptr 패턴으로 쓴다.
  3. 할당한 자리마다 어디서 free할지 같이 정한다.
  4. free한 뒤 같은 변수를 다시 쓸 가능성이 있으면 NULL로 돌린다.
  5. 포인터를 다른 주소로 덮어쓰기 전에 기존 블록 처리 여부를 먼저 확인한다.

이 다섯 가지만 지켜도 입문 단계에서 만나는 malloc/free 버그의 상당수를 줄일 수 있습니다. 어렵고 화려한 기법보다 이런 기본 습관이 훨씬 중요합니다.


한 번에 정리

C 언어 malloc free 사용법의 핵심은 간단합니다. malloc은 메모리를 빌려 오는 일이고, free는 그 메모리를 정확히 한 번 반납하는 일입니다. 그 사이에는 실패 체크가 필요하고, 마지막에는 누수 없이 정리되어야 합니다.

메모리 누수는 반납을 잊은 경우이고, double free는 반납이 끝난 대상을 또 건드린 경우이며, use-after-free는 반납이 끝난 대상을 계속 내 것처럼 쓴 경우입니다. 이름은 다르지만 공통 원인은 소유권 감각을 놓친 데 있습니다.

포인터와 배열 감각이 아직 흐릿하다면 C 언어 포인터와 배열 차이 글을 먼저 같이 보는 것도 좋습니다. 포인터가 무엇을 들고 있고 배열 할당을 왜 sizeof *ptr 패턴으로 쓰는지 연결해서 이해하기 쉬워집니다.

같이 보면 좋은 글

C 언어 포인터와 배열 차이: decay, sizeof, 함수 인자까지 한 번에 이해하기

DFS와 BFS는 언제 다르게 써야 할까: 그래프 탐색을 문제 풀이 관점에서 비교

참고한 자료

cppreference: malloc

cppreference: free

Linux malloc(3) manual page

함께보면 좋은 글