프로그래밍

동적 메모리 할당

배열은 크기를 미리 정해 두어야 했습니다.
미리 알 수 없다면 어떻게 할까요?

학습 목표

  1. 필요한 메모리의 크기를 실행 중에 정해야 하는 상황을 설명할 수 있다.
  2. malloc으로 메모리를 할당해 배열처럼 쓸 수 있다.
  3. 사용을 마친 메모리를 free로 해제해야 하는 이유를 설명할 수 있다.
  4. calloc과 realloc이 malloc과 무엇이 다른지 알고 구별해 쓸 수 있다.

미리 알 수 없을 때

코드를 작성하는 시점과 필요한 크기를 아는 시점이 다릅니다.

int score[5];라고 선언하려면 코드를 작성하는 시점에 5를 알고 있어야 합니다. 그런데 몇 명분이 필요한지는 프로그램을 실행해 봐야 알 수 있는 경우가 많습니다. 사용자가 개수를 입력하는 경우나, 파일을 열어 봐야 줄 수를 알 수 있는 경우가 그렇습니다.

C99부터는 int score[n];처럼 실행 중에 정한 값으로 크기를 정하는 가변 길이 배열(variable-length array, VLA)도 있습니다. 다만 C11부터는 컴파일러가 반드시 지원하지 않아도 되는 기능이 되었고, 크기가 크면 프로그램이 비정상 종료될 수 있으며, 선언한 함수가 끝나면 사라집니다. 그래서 크기가 크거나 함수가 끝난 뒤에도 써야 하는 데이터는 이제부터 볼 방법으로 메모리를 마련합니다.

넉넉하게 잡아 두면 되지 않을까요

int score[1000];처럼 크게 선언하는 방법을 흔히 떠올립니다. 문제가 두 가지 있습니다. 다섯 명만 쓸 때는 요소 995개가 쓰이지 않고, 1001명이 오면 그때는 방법이 없습니다.

문제는 「얼마나 커야 충분한가」에 답이 없다는 것입니다. 필요한 것은 「그때 가서 필요한 만큼 마련하는」 방법입니다.

int score[1000];  — 얼마나 커야 충분한가에 답이 없다 … 쓰는 것은 다섯 995칸이 논다. 1001명이 오면 방법이 없다 malloc(n * sizeof(int));  — 그때 가서 필요한 만큼 쓸 만큼만 빌리고 다 쓰면 돌려준다.

실행 중에 메모리를 빌려 씁니다

프로그램에는 실행 중에 필요한 만큼 빌려 쓸 수 있는 메모리 영역이 따로 있고, 흔히 이 영역을 힙(heap)이라고 부릅니다. 프로그램이 malloc을 호출해 필요한 크기를 요청하면, malloc은 힙에서 그만큼의 메모리 공간을 확보해 그 시작 주소를 반환합니다. 이처럼 실행 중에 메모리를 할당받는 것을 동적 메모리 할당(dynamic memory allocation)이라고 합니다.

반환되는 것이 「주소」라는 데 주목하세요. 이름이 붙은 변수가 새로 생기는 것이 아니라 주소 하나만 반환됩니다. 그래서 동적으로 할당한 메모리는 포인터로만 다룰 수 있습니다.

스스로 확인

한 해의 월별 강수량을 저장하는 배열과, 사용자가 입력한 문장의 단어를 하나씩 나누어 저장하는 배열이 있습니다. 두 배열 가운데 코드를 작성하는 시점에 크기를 정할 수 없는 쪽은 어느 것이고, 그 이유는 무엇일까요?

malloc으로 할당하기

메모리를 할당하는 함수는 malloc입니다.

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

int main(void) {
    int n = 5;
    int *data = (int *) malloc(n * sizeof(int));   // 정수 다섯 개를 저장할 메모리를 할당한다

    if (data == NULL) {                            // 할당에 실패했을 수도 있다
        printf("메모리를 할당하지 못했습니다.\n");
        return 1;
    }

    for (int i = 0; i < n; i = i + 1) {
        data[i] = (i + 1) * 10;                    // 배열과 똑같이 쓴다
    }

    for (int i = 0; i < n; i = i + 1) {
        printf("%d ", data[i]);                    // 10 20 30 40 50
    }
    printf("\n");

    free(data);                                    // 다 썼으면 해제한다

    return 0;
}
malloc 뒤 — 할당한 메모리에는 이름이 없다 data 포인터가 유일한 연결선이다. 이 주소를 잃으면 해제할 수도 없다. free 뒤 — 메모리는 해제됐는데 주소는 남아 있다 data 이 상태로 data[0]에 접근하면 해제된 메모리에 접근한다 그래서 free 다음 줄에 data = NULL을 써 둔다
코드 의미
n * sizeof(int) 필요한 크기. 요소의 개수가 아니라 바이트 수로 요청한다.
(int *) 반환된 void * 주소를 int *로 형 변환한다.
== NULL 할당에 실패했는지 검사한다. 실패하면 NULL이 반환된다.
data[i] 할당한 뒤에는 배열과 똑같이 인덱스로 쓴다.

개수가 아니라 바이트 수로 요청합니다

malloc(5)는 「5바이트」를 요청하는 것이지 「정수 다섯 개를 저장할 메모리」를 요청하는 것이 아닙니다. 정수 다섯 개를 저장하려면 5 * sizeof(int)라고 써야 합니다.

20이라고 직접 쓰지 않는 이유가 있습니다. int가 몇 바이트인지는 환경에 따라 다를 수 있고, 나중에 저장할 자료형을 바꾸면 숫자도 함께 고쳐야 합니다. sizeof로 써 두면 두 경우 모두 크기가 자동으로 맞춰집니다.

malloc은 void *를 반환합니다

malloc은 할당한 메모리에 어떤 자료형의 값이 저장될지 알 수 없습니다. 정수를 저장할지 문자를 저장할지는 malloc을 호출한 쪽만 압니다. 그래서 가리킬 대상의 자료형을 정하지 않은 포인터를 반환하고, 호출한 쪽이 형 변환으로 「이 자료형의 주소로 쓰겠다」고 정합니다. void 포인터가 쓰이는 대표적인 예입니다.

C에서는 이 형 변환을 생략해도 됩니다. void *는 다른 자료형의 포인터에 대입할 때 자동으로 변환되기 때문입니다. 다만 형 변환을 써 두면 어떤 자료형으로 쓸 메모리인지가 그 줄에서 바로 보이므로 이 강의노트에서는 써 두는 쪽을 택합니다.

할당에 실패할 수도 있습니다

요청한 크기만큼 할당할 수 없으면 malloc은 NULL을 반환합니다. 그것을 검사하지 않고 data[0] = 10;을 실행하면 널 포인터를 역참조해 값을 저장하려는 것이 되어 대부분의 환경에서 비정상 종료됩니다.

할당한 직후에는 반드시 NULL인지 검사합니다. 「역참조하기 전에 NULL인지 검사하는 습관」이 특히 자주 쓰이는 곳이 할당 직후입니다.

스스로 확인

문자 100개를 저장할 메모리를 할당하려면 malloc에 무엇을 전달해야 할까요? 문자열로 쓴다면 몇 바이트를 더 할당해야 할까요?

free로 해제하기

할당한 메모리는 직접 해제해야 합니다. 자동으로 해제되지 않습니다.

함수 안에서 선언한 지역 변수는 흔히 스택(stack)이라고 부르는 영역에 놓여, 함수가 끝나면 자동으로 사라집니다. 그러나 힙에 할당한 메모리는 함수가 끝나도 남아 있습니다. free를 호출해 메모리를 돌려주기 전까지 계속 할당된 상태입니다. 이렇게 할당한 메모리를 돌려주는 것을 해제(deallocation)라고 합니다.

주소를 잃어버리면 해제할 수도 없습니다

할당한 메모리의 주소를 저장하고 있는 포인터가 유일한 연결선입니다. 그 포인터에 다른 주소를 대입해 버리면 앞서 할당한 메모리가 어디에 있는지 알 방법이 없어집니다. 사용할 수도, 해제할 수도 없는 메모리가 남는 것입니다.

이렇게 해제하지 못한 메모리가 쌓여 사용할 수 있는 메모리가 줄어드는 것을 메모리 누수(memory leak)라고 합니다. 대부분의 운영 체제는 프로그램이 끝나면 그 프로그램의 메모리를 한꺼번에 회수하므로 잠깐 실행되고 끝나는 프로그램에서는 잘 드러나지 않습니다. 오래 실행되는 프로그램일수록 누수된 메모리가 계속 쌓입니다.

같은 포인터에 새 주소를 대입하면 p 먼저 할당한 메모리 끊겼다 새로 할당한 메모리 먼저 할당한 메모리가 어디에 있는지 알 방법이 없다. 사용할 수도 해제할 수도 없다 — 이것이 메모리 누수다.

해제한 뒤에도 포인터에는 옛 주소가 남아 있습니다

free(data);는 「이 메모리는 이제 쓰지 않는다」고 알리는 것이지 포인터 변수 data의 값을 바꾸는 것이 아닙니다. data에는 여전히 해제된 메모리의 주소가 남아 있습니다. 이처럼 해제된 메모리를 가리키는 포인터를 댕글링 포인터(dangling pointer)라고 합니다. 그 상태로 data[0]에 접근하는 것은 정의되지 않은 동작으로, 이미 다른 용도로 다시 할당되었을 수도 있는 메모리에 접근하는 것입니다.

그래서 free 다음 줄에 data = NULL;을 써 두는 습관을 들입니다. 널 포인터로 바꿔 두면 실수로 역참조했을 때 조용히 넘어가지 않고 대부분의 환경에서 그 줄에서 비정상 종료되어 잘못이 바로 드러납니다.

같은 메모리를 두 번 해제해서도 안 됩니다. 이미 해제한 메모리의 주소를 다시 free에 전달하면 정의되지 않은 동작이 됩니다. NULL로 바꿔 두면 이 실수도 함께 막힙니다. free(NULL)은 아무 일도 하지 않기 때문입니다. 다만 같은 주소를 다른 포인터에도 저장해 두었다면 그 포인터도 댕글링 포인터가 되므로 따로 챙겨야 합니다.

할당하는 줄과 해제하는 줄을 쌍으로 봅니다

malloc을 쓰는 순간 free를 어디에 쓸지 함께 정합니다. 나중에 찾아 넣으려 하면 함수를 빠져나가는 경로를 모두 검토해야 합니다 — 중간에 return으로 빠져나가는 경로가 있으면 그 경로에도 free가 있어야 합니다.

스스로 확인

반복문 안에서 malloc을 호출하고 그 반환값을 같은 포인터에 계속 대입하면 무슨 일이 생길까요? 몇 번째로 할당한 메모리까지 해제할 수 있을까요?

calloc과 realloc

0으로 채워 주는 함수와 크기를 바꿔 주는 함수입니다.

malloc이 할당한 메모리에는 어떤 값이 들어 있을까요? 초기화하지 않은 변수를 읽으면 엉뚱한 값이 나오는 것과 같은 상황입니다.

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

int main(void) {
    int *a = (int *) malloc(5 * sizeof(int));
    int *b = (int *) calloc(5, sizeof(int));       // 개수와 요소 하나의 크기를 따로 전달한다

    if (a == NULL || b == NULL) {
        free(a);                    // 하나만 할당됐어도 해제한다. free(NULL)은 아무 일도 하지 않는다
        free(b);
        return 1;
    }

    printf("malloc: ");
    for (int i = 0; i < 5; i = i + 1) {
        printf("%d ", a[i]);        // 어떤 값이 들어 있을지 「정해져 있지 않다」
    }

    printf("\ncalloc: ");
    for (int i = 0; i < 5; i = i + 1) {
        printf("%d ", b[i]);        // 0 0 0 0 0
    }
    printf("\n");

    free(a);
    free(b);

    return 0;
}

calloc은 0으로 채워 줍니다

malloc은 할당한 메모리를 초기화하지 않으므로 정해지지 않은 값이 들어 있습니다. 실행할 때마다 다른 값이 나올 수 있고, 0이 나올 수도 있습니다 — 0이 나왔다고 「0으로 초기화된다」고 믿으면 안 되는 대표적인 경우입니다.

calloc은 할당한 메모리의 모든 바이트를 0으로 채워 줍니다. 호출하는 방법도 조금 달라서 요소의 개수와 요소 하나의 크기를 인자 두 개로 나누어 전달합니다. calloc(5, sizeof(int))처럼 씁니다. 합을 누적하거나 개수를 셀 배열이라면 calloc을 쓸 때 0으로 초기화하는 반복문을 따로 쓰지 않아도 됩니다.

할당한 뒤에 크기가 모자란 것을 알게 되면 어떻게 할까요? 더 큰 메모리를 새로 할당해 저장된 값을 복사하고 원래 메모리를 해제하면 되는데, 이 과정을 한 번에 해 주는 함수가 realloc입니다.

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

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

    if (data == NULL) {
        return 1;
    }

    for (int i = 0; i < 3; i = i + 1) {
        data[i] = i + 1;
    }

    int *bigger = (int *) realloc(data, 6 * sizeof(int));   // 저장된 값까지 옮겨 준다

    if (bigger == NULL) {
        free(data);                 // 늘리지 못했어도 「원래 메모리는 그대로」 남아 있다
        return 1;
    }
    data = bigger;                  // 성공했을 때만 새 주소를 대입한다

    for (int i = 3; i < 6; i = i + 1) {
        data[i] = i + 1;
    }

    for (int i = 0; i < 6; i = i + 1) {
        printf("%d ", data[i]);     // 1 2 3 4 5 6 — 앞의 세 값이 그대로 있다
    }
    printf("\n");

    free(data);

    return 0;
}

반환값을 원래 포인터에 바로 대입하지 않습니다

data = realloc(data, …);처럼 바로 대입하고 싶어집니다. 그런데 크기를 늘리는 데 실패하면 NULL이 반환됩니다. 그 값을 data에 대입해 버리면 원래 메모리의 주소를 잃어 해제할 수도 없게 됩니다 — 실패 한 번으로 메모리 누수가 생기는 것입니다.

그래서 반환값을 다른 포인터에 먼저 저장해 NULL인지 검사한 뒤 성공했을 때만 data에 대입합니다. realloc이 실패해도 원래 메모리는 해제되지 않고 그대로 남아 있으므로 그 메모리를 해제하고 끝낼 수 있습니다.

한 가지 더 있습니다. 늘린 메모리의 주소가 원래 주소와 다를 수 있습니다. 원래 위치에서 이어서 늘릴 공간이 없으면 다른 곳에 새로 할당해 값을 모두 옮기고 원래 메모리를 해제하기 때문입니다. 그래서 원래 주소를 저장하고 있던 다른 포인터가 있다면 그 포인터들은 댕글링 포인터가 됩니다.

여기까지를 한 프로그램으로

개수를 입력받아 그만큼만 할당하고, 다 쓰고 나서 해제합니다. 「넉넉하게 잡아 두기」가 필요 없어졌습니다.

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

int main(void) {
    int n;

    printf("점수를 몇 개 입력할까요? ");
    if (scanf("%d", &n) != 1 || n <= 0) {
        printf("한 개 이상이어야 합니다.\n");
        return 1;
    }

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

    if (score == NULL) {
        printf("메모리를 할당하지 못했습니다.\n");
        return 1;
    }

    int sum = 0;

    for (int i = 0; i < n; i = i + 1) {
        printf("%d번째 점수: ", i + 1);
        scanf("%d", &score[i]);
        sum = sum + score[i];
    }

    printf("\n%d개의 합계 %d, 평균 %.1f\n", n, sum, (double) sum / n);

    free(score);
    score = NULL;        // 해제한 뒤에는 널 포인터로 바꿔 둔다

    return 0;
}

n이 0 이하인지 먼저 검사한 부분을 보세요. 입력받은 값이 그대로 malloc의 크기 계산에 쓰이므로 잘못된 값이 들어오면 엉뚱한 크기를 요청하게 됩니다. 사용자가 입력한 값은 검사한 뒤에 메모리 할당에 씁니다.

스스로 확인

「여기까지를 한 프로그램으로」의 프로그램에서 malloc이 성공한 뒤 입력 도중에 return으로 빠져나가는 경로를 만든다면 무엇을 잊지 말아야 할까요?

핵심 정리

실행 중에 할당한다

필요한 크기를 실행 중에 요청한다. 반환되는 것은 주소뿐이라 포인터로만 다룰 수 있다.

바이트 수로 요청하고 검사한다

개수 * sizeof(자료형)으로 쓴다. 반환값이 NULL인지 쓰기 전에 반드시 검사한다. 할당한 뒤에는 배열과 똑같이 쓴다.

자동으로 해제되지 않는다

free로 직접 해제한다. 주소를 잃으면 해제할 수도 없다(메모리 누수). free는 포인터의 값을 바꾸지 않으므로 해제한 뒤 NULL을 대입해 옛 주소를 남기지 않는다.

0으로 채우는 calloc, 크기를 바꾸는 realloc

calloc은 0으로 채워 준다. realloc은 저장된 값을 옮기는 일까지 해 주지만 실패할 수 있으니 반환값을 다른 포인터에 먼저 저장한다.

확인 퀴즈

배운 내용을 확인해 봅시다.

1. 정수 n개를 저장할 메모리를 할당하는 올바른 코드는?

2. free(data);를 한 뒤 data는 어떤 상태인가?

3. data = realloc(data, 더 큰 크기);처럼 바로 대입하면 위험한 이유는?