Skip to main content

10. 동적 메모리 할당과 메모리 관리

포인터 장을 지나 여기까지 왔다면, 이제 C 언어에서 정말 중요한 문 하나 앞에 서 있는 셈입니다. 바로 프로그램이 실행되는 동안 필요한 만큼 메모리를 직접 빌리고, 다 쓰면 직접 반납하는 법입니다. 이것이 동적 메모리 할당입니다.12

처음에는 조금 긴장될 수 있습니다. 지금까지는 변수를 선언하면 알아서 메모리가 준비됐고, 함수가 끝나면 지역 변수도 알아서 사라졌습니다. 그런데 동적 메모리는 다릅니다. 직접 요청하고, 직접 관리해야 합니다. 조금 귀찮아 보이지만, 대신 훨씬 유연해집니다. 사용자가 몇 개를 입력할지 모르는 배열도 만들 수 있고, 프로그램 도중에 더 큰 공간으로 늘릴 수도 있습니다.13

C를 실전에서 쓰려면 이 주제를 피할 수 없습니다. 오히려 여기서 감을 잡으면, 자료구조와 파일 처리, 문자열 처리, 시스템 프로그래밍이 한층 자연스럽게 이어집니다.

동적 메모리 할당과 메모리 관리

왜 동적 메모리가 필요할까

정적 배열은 편하지만 크기가 고정됩니다.

int scores[100];

이 코드는 점수 100개를 저장할 수 있습니다. 그런데 사용자가 5개만 입력하면 메모리가 남고, 150개를 입력하면 부족합니다. 미리 크게 잡으면 낭비이고, 작게 잡으면 모자랍니다. 이런 상황에서 필요한 것이 동적 메모리입니다.

동적 메모리는 프로그램이 실행되는 중에 "지금 정수 5개가 필요합니다", "아니, 20개로 늘려야겠습니다"처럼 그때그때 메모리를 요청하는 방식입니다.1

조금 비유해 보면 이렇습니다. 지금까지는 방 안에 붙박이 서랍만 썼다면, 이제는 필요할 때 창고를 빌리는 법을 배우는 것입니다. 문제는 창고를 빌렸으면 나중에 꼭 반납해야 한다는 점입니다. 반납을 잊으면 창고 사용료가 계속 나가는 것처럼, 프로그램 안에서는 메모리 누수가 생깁니다.4

스택과 힙, 어디에 만들어질까

이전 장에서 변수와 주소를 배웠다면, 이제 메모리가 놓이는 자리를 조금 더 구분해서 볼 수 있습니다. 함수 안에서 만드는 일반 지역 변수는 보통 스택(stack) 영역에 놓입니다. 반면 동적으로 할당한 메모리는 힙(heap) 영역에 놓입니다.2

둘의 차이를 초보자 눈높이에서 아주 단순하게 정리하면 이렇습니다.

  • 지역 변수는 선언하면 자동으로 생기고, 블록이나 함수가 끝나면 자동으로 사라진다.

  • 힙 메모리는 malloc, calloc, realloc으로 직접 확보해야 한다.

  • 힙 메모리는 free를 호출하기 전까지 계속 남아 있다.

  • 그래서 편리하지만, 관리 책임도 프로그래머에게 있다.12

이 차이가 중요한 이유는 수명(lifetime) 때문입니다. 지역 변수는 함수가 끝나면 사라지지만, 힙 메모리는 함수가 끝나도 살아남을 수 있습니다. 그래서 함수 안에서 만든 동적 메모리의 주소를 반환해서 바깥에서 계속 사용할 수 있습니다.2

동적 메모리 함수 네 가지

동적 메모리 할당 함수는 <stdlib.h> 헤더 파일에 들어 있습니다.1

#include <stdlib.h>

꼭 알아야 할 함수는 네 개입니다.

  • malloc : 원하는 크기만큼 메모리를 할당한다.

  • calloc : 여러 칸의 메모리를 할당하고, 시작 상태를 0으로 맞춘다.

  • realloc : 이미 할당한 메모리의 크기를 바꾼다.

  • free : 다 쓴 메모리를 해제한다.13

이 네 함수가 이번 장의 주인공입니다.

malloc: 필요한 만큼 처음 빌리기

malloc은 가장 기본적인 동적 메모리 함수입니다. 이름도 직관적입니다. memory allocation의 줄임말이라고 생각하면 됩니다.

void *malloc(size_t size);

인자로는 몇 바이트(byte)를 빌릴지 넣습니다. 예를 들어 int 10개가 필요하다면 다음처럼 씁니다.

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

여기서 중요한 포인트는 10이 아니라 10 * sizeof(int)라는 점입니다. malloc은 "정수 10개"를 알아듣지 못하고, 오직 "몇 바이트"만 알아듣습니다. 그래서 sizeof로 자료형 크기를 곱해 주는 습관이 매우 중요합니다.3

조금 더 실용적인 형태로는 이렇게 자주 씁니다.

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

이 방식은 자료형 이름을 직접 반복하지 않아도 돼서 실수 줄이기에 좋습니다.

malloc으로 받은 메모리는 초기화되지 않습니다. 즉, 안에 어떤 값이 들어 있을지 보장되지 않습니다. 흔히 쓰레기값이라고 부르는 상태입니다.13

그래서 malloc을 썼다면 두 가지를 바로 떠올려야 합니다. 첫째, 할당이 성공했는지 확인하기. 둘째, 필요한 값으로 직접 초기화하기입니다.

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

int main(void) {
    int n;
    printf("몇 개의 정수를 저장할까요? ");
    scanf("%d", &n);

    int *arr = malloc(n * sizeof *arr);
    if (arr == NULL) {
        printf("메모리 할당 실패n");
        return 1;
    }

    for (int i = 0; i < n; i++) {
        arr[i] = i + 1;
    }

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

    free(arr);
    arr = NULL;
    return 0;
}

여기서 arr은 동적 배열처럼 사용할 수 있습니다. 문법은 배열처럼 보이지만, 실제로는 힙에 만들어진 메모리를 포인터가 가리키고 있는 것입니다.

calloc: 0으로 깨끗하게 시작하기

calloc은 malloc과 비슷하지만, 시작이 더 깔끔합니다.

void *calloc(size_t count, size_t size);

calloc은 "원소 개수"와 "원소 하나의 크기"를 따로 받습니다. 그리고 확보한 메모리를 0으로 채운 상태로 돌려줍니다.13

int *arr = calloc(10, sizeof *arr);

이 코드는 정수 10개를 저장할 메모리를 만들고, 처음 값들을 0으로 시작하게 합니다.

숫자 배열을 만들 때 처음 값이 모두 0이어야 한다면 calloc이 꽤 편합니다. 예를 들어 빈도수 세기, 카운터 배열, 합계 저장용 배열 같은 곳에서 잘 어울립니다.

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

int main(void) {
    int *counts = calloc(5, sizeof *counts);
    if (counts == NULL) {
        printf("메모리 할당 실패n");
        return 1;
    }

    for (int i = 0; i < 5; i++) {
        printf("counts[%d] = %dn", i, counts[i]);
    }

    free(counts);
    counts = NULL;
    return 0;
}

처음 출력값이 모두 0이면, calloc이 제 역할을 한 것입니다.

free: 빌렸으면 꼭 반납하기

동적 메모리에서 가장 중요한 함수 하나만 꼽으라면, 많은 경우 free라고 해도 과장이 아닙니다.

free(arr);

malloc, calloc, realloc으로 확보한 메모리는 자동으로 사라지지 않습니다. 사용이 끝났다면 반드시 free로 반납해야 합니다.14

반납하지 않으면 메모리 누수(memory leak)가 생깁니다. 말 그대로 메모리가 새는 것입니다. 프로그램이 이미 그 메모리를 더 이상 쓰지 않는데도, 시스템 입장에서는 여전히 점유된 상태로 남아 버립니다.4

짧게 실행되고 끝나는 작은 연습 프로그램에서는 눈에 잘 안 띌 수 있습니다. 하지만 오래 실행되는 프로그램, 서버, 반복 작업 프로그램에서는 누수가 차곡차곡 쌓여서 결국 프로그램이 느려지거나 멈출 수 있습니다.4

그리고 free를 호출했다고 해서 포인터 변수 자체가 자동으로 안전해지는 것은 아닙니다. 포인터 안에는 예전 주소가 그대로 남아 있을 수 있습니다. 하지만 그 주소는 이제 유효하지 않습니다. 이런 포인터를 댕글링 포인터라고 부릅니다.

그래서 보통 이렇게 씁니다.

free(arr);
arr = NULL;

이 한 줄이 만능 해결책은 아니지만, 해제한 메모리를 실수로 다시 사용하는 일을 줄이는 데 도움을 줍니다.

realloc: 크기를 늘리거나 줄이기

처음엔 5칸이면 충분할 줄 알았는데, 쓰다 보니 20칸이 필요할 때가 있습니다. 이럴 때 realloc을 씁니다.

void *realloc(void *ptr, size_t new_size);

이 함수는 이미 할당된 메모리 블록의 크기를 바꿉니다. 더 크게 늘릴 수도 있고, 줄일 수도 있습니다.13

int *arr = malloc(5 * sizeof *arr);
int *temp = realloc(arr, 10 * sizeof *arr);

여기서 중요한 사실이 하나 있습니다. realloc은 메모리를 같은 자리에서 늘릴 수도 있고, 다른 자리로 옮길 수도 있습니다. 즉, 주소가 바뀔 수 있습니다.3

그래서 초보자가 많이 하는 이 코드는 조금 위험합니다.

arr = realloc(arr, 10 * sizeof *arr);

왜 위험할까요? 만약 realloc이 실패해서 NULL을 반환하면, 원래 arr이 가리키던 주소를 잃어버릴 수 있기 때문입니다. 그럼 기존 메모리를 해제할 방법도 놓치게 됩니다.

더 안전한 패턴은 이렇습니다.

int *temp = realloc(arr, 10 * sizeof *arr);
if (temp == NULL) {
    printf("메모리 재할당 실패n");
    free(arr);
    return 1;
}
arr = temp;

핵심은 임시 포인터에 먼저 받아서 성공했을 때만 원래 포인터를 바꾸는 것입니다.3

또 한 가지 기억할 점이 있습니다. realloc으로 크기를 키웠을 때 새로 늘어난 부분은 자동으로 초기화되지 않습니다. 필요한 값이 있다면 직접 넣어야 합니다.3

함수가 끝나도 살아남는 메모리

동적 메모리가 왜 유용한지 가장 잘 보여 주는 장면 중 하나가 바로 이것입니다.

함수 안에서 만든 일반 지역 배열은 함수가 끝나면 사라집니다. 그래서 그 주소를 바깥으로 넘기면 위험합니다. 하지만 힙에 만든 메모리는 함수가 끝나도 남아 있으므로, 주소를 반환해서 바깥에서 계속 사용할 수 있습니다.2

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

int *make_array(int n) {
    int *arr = malloc(n * sizeof *arr);
    if (arr == NULL) {
        return NULL;
    }

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

    return arr;
}

int main(void) {
    int *data = make_array(5);
    if (data == NULL) {
        printf("메모리 할당 실패n");
        return 1;
    }

    for (int i = 0; i < 5; i++) {
        printf("%d ", data[i]);
    }
    printf("n");

    free(data);
    data = NULL;
    return 0;
}

이 코드는 꽤 중요합니다. 앞으로 문자열을 함수에서 만들어 반환하거나, 구조체를 동적으로 생성하거나, 자료구조 노드를 만드는 일들이 모두 이런 생각에서 출발합니다.

실전 예제: 입력 개수만큼 점수를 저장하기

이제 malloc, realloc, free를 한 번에 써 보겠습니다. 사용자가 점수를 몇 개 입력할지 미리 모른다고 해 봅시다. 처음에는 4칸만 준비하고, 다 차면 2배로 늘리겠습니다. 이런 방식은 실제 동적 배열 구현의 기본 아이디어이기도 합니다.5

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

int main(void) {
    int capacity = 4;
    int count = 0;
    int sum = 0;

    int *scores = malloc(capacity * sizeof *scores);
    if (scores == NULL) {
        printf("메모리 할당 실패n");
        return 1;
    }

    while (1) {
        int input;
        printf("점수 입력 (-1 입력 시 종료): ");
        scanf("%d", &input);

        if (input == -1) {
            break;
        }

        if (count == capacity) {
            int new_capacity = capacity * 2;
            int *temp = realloc(scores, new_capacity * sizeof *scores);
            if (temp == NULL) {
                printf("메모리 확장 실패n");
                free(scores);
                return 1;
            }
            scores = temp;
            capacity = new_capacity;
        }

        scores[count] = input;
        sum += input;
        count++;
    }

    if (count == 0) {
        printf("입력된 점수가 없습니다.n");
    } else {
        printf("입력 개수: %dn", count);
        printf("평균: %.2fn", (double)sum / count);
    }

    free(scores);
    scores = NULL;
    return 0;
}

이 예제에서 눈여겨볼 포인트는 두 개입니다. 첫째, 현재 들어 있는 개수 count와 전체 용량 capacity를 따로 관리한다는 점입니다. 둘째, 꽉 찼을 때 realloc으로 더 큰 공간을 요청한다는 점입니다.5

이 패턴은 앞으로 리스트, 스택, 큐, 문자열 버퍼를 직접 만들 때 계속 다시 만나게 됩니다. 한 번 익혀 두면 정말 오래 씁니다.

메모리 누수는 어떻게 생길까

메모리 누수는 거창한 실수에서만 생기지 않습니다. 아주 소박한 코드에서도 잘 생깁니다.

int *ptr = malloc(10 * sizeof *ptr);
ptr = NULL;

겉으로 보면 NULL로 잘 정리한 것 같지만, 사실은 큰일이 났습니다. 원래 malloc이 돌려준 주소를 잃어버렸기 때문입니다. 이제 그 메모리를 free할 방법이 없습니다. 메모리는 남아 있는데, 가는 길을 잃어버린 셈입니다.4

또 이런 경우도 있습니다.

void process(void) {
    int *data = malloc(100 * sizeof *data);
    if (data == NULL) {
        return;
    }

    /* 작업 수행 */

    return;   // free(data)를 빼먹음
}

함수는 정상적으로 끝나지만, 힙 메모리는 반납되지 않습니다. 이런 실수가 반복되면 누수는 눈덩이처럼 커집니다.4

초보자가 특히 조심해야 할 실수

동적 메모리에서는 비슷한 실수가 자주 반복됩니다. 미리 알고 있으면 피하기 쉽습니다.

  • malloc(n)처럼 바이트 수 계산을 빼먹는 실수. 자료형 크기를 고려하지 않으면 필요한 공간보다 적게 잡을 수 있다.

  • 반환값이 NULL인지 확인하지 않고 바로 사용하는 실수. 메모리 할당 실패도 실제로 일어날 수 있다.3

  • free를 빼먹는 실수. 처음에는 잘 돌아가 보여도 누수는 계속 쌓인다.4

  • realloc 결과를 원래 포인터에 바로 덮어쓰는 실수. 실패했을 때 기존 메모리 주소를 잃을 수 있다.3

  • free한 뒤에 같은 포인터를 다시 사용하는 실수. 이미 반납한 메모리를 건드리면 정의되지 않은 동작이 된다.

C는 친절하게 "그건 위험해!"라고 손을 잡아 주는 언어가 아닙니다. 그래서 습관이 실력입니다.

안전하게 쓰는 습관

동적 메모리를 잘 다루는 사람은 함수를 외운 사람이 아니라, 수명을 관리하는 사람입니다. 누가 메모리를 만들었고, 누가 마지막에 해제할지를 늘 의식해야 합니다.5

초보자라면 아래 습관부터 몸에 익히면 좋습니다.

  • malloc, calloc, realloc 뒤에는 항상 NULL 검사를 붙인다.

  • malloc 계열로 만들었으면, 사용이 끝나는 지점에서 free를 짝으로 둔다.

  • free(ptr); ptr = NULL; 패턴을 습관처럼 사용한다.

  • realloc은 임시 포인터에 먼저 받는다.3

  • 함수가 메모리를 반환하면, 누가 해제할지 규칙을 분명히 정한다.5

예를 들어 "이 함수가 반환한 포인터는 호출한 쪽에서 free한다" 같은 약속이 있어야 프로그램이 커져도 덜 헷갈립니다.

정리

동적 메모리 할당은 C 언어가 왜 강력한지, 또 왜 조심해야 하는지를 동시에 보여 주는 주제입니다. 실행 중 필요한 만큼 메모리를 확보할 수 있으니 유연하지만, 자동으로 정리되지 않으니 책임도 함께 따라옵니다.12

이번 장에서 꼭 붙잡아야 할 핵심은 다음과 같습니다.

  • malloc은 필요한 바이트 수만큼 메모리를 할당한다.

  • calloc은 할당과 함께 0으로 시작하게 해 준다.

  • realloc은 기존 메모리 크기를 바꿀 수 있지만, 주소가 바뀔 수 있으니 조심해야 한다.3

  • free는 선택이 아니라 필수다. 빼먹으면 메모리 누수가 생긴다.4

포인터 입문이 "주소를 이해하는 장"이었다면, 이번 장은 "그 주소로 실제 자원을 관리하는 장"입니다. 이 감각이 생기면 C 언어가 갑자기 훨씬 실전적으로 보이기 시작합니다.

다음 장에서는 파일 입출력, 전처리기, 헤더 파일을 배우게 됩니다. 그리고 그때부터는 프로그램이 메모리 안에서만 머무르지 않고, 파일과 여러 소스 파일로 세계를 넓혀 가게 됩니다.

참고

1C Dynamic Memory Allocation Using malloc(), calloc(), free() & realloc()

2C dynamic memory allocation - Wikipedia

3Dynamic Memory Allocation in C - GeeksforGeeks

4What is Memory Leak? How can we avoid? - GeeksforGeeks

5C언어 50강 30강. 동적 메모리: malloc/calloc/realloc/free 패턴

책 목록

초보자를 위한 C 언어 학습: 기초부터 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기