C (9) - 메모리 동적 할당

동적 할당 함수

 

프로그램에 필요한 메모리 저장 공간은 프로그램을 작성할 때 변수나 배열 선언을 통해 확보한다고 배웠습니다.

int num;			// 변수 공간 확보
int arr[2];		// 배열 공간 확보

 

그런데 언제나 시작부터 변수나 배열 선언을 해서 저장 공간을 확보할 수 있는 건 아닙니다.

때로는 프로그램 실행 중에 저장 공간을 할당해야 할 수도 있습니다. 이때 사용한 저장 공간은 실행 중에 재활용을 위해 반납해야 합니다. 이처럼 프로그램 실행 중에 저장 공간을 할당하는 것을 동적 할당이라고 합니다.

 

malloc, free 함수

 

프로그램 실행 중에 메모리를 동적 할당할 때는 malloc 함수를, 반환할 때는 free 함수를 사용합니다. 이 함수들을 사용할 때는 stdlib.h 헤더 파일을 include 해야 합니다.

void *malloc(unsigned int size);
void free(void *p);
#include <stdio.h>
#include <dtdlib.h>

int main(void)
{
    int *pi;
    double *pd;

    pi = (int *)malloc(sizeof(int));
    if (pi == NULL)
    {
        printf("# 메모리가 부족합니다.\n");
        exit(1);
    }
    pd = (double *)malloc(sizeof(double));

    *pi = 10;
    *pd = 3.4;

    printf("정수: %d\n", *pi);
    printf("실수: %l.1lf\n", *pd);

    free(pi);
    free(pd);

    return 0;
}
malloc 함수로 메모리를 할당할 때 필요한 바이트 수를 직접 인자로 전달하는 것보다, sizeof 연산자로 계산해 전달하는 것이 좋습니다. 그러면 컴파일러에 따라 int형 변수의 크기가 다르더라도 프로그램을 수정할 필요가 없습니다.

 

malloc 함수는 주어진 인수의 바이트 크기만큼 메모리에서 연속된 저장 공간을 할당한 후에 그 시작 주소를 반환합니다.

malloc 함수는 (void *)형을 반환하기 때문에 용도에 맞는 포인터형으로 형 변환해 사용해야 합니다.

 

동적으로 메모리를 사용할 때는 항상 포인터가 필요한데, 이때 2가지 주의할 점이 있습니다.

 

⚠️ 1. malloc 함수의 반환값이 널 포인터인지 반드시 확인하고 사용해야 합니다.

메모리 할당 함수는 원하는 크기의 공간을 할당하지 못하면 0번지인 널 포인터(null pointer)를 반환합니다. 널 포인터는 보통 NULL로 표기하는데 전처리 단계에서 0으로 바뀌므로 정수 0과 같다고 생각해도 됩니다. 널 포인터는 포인터의 특별한 상태를 나타내기 위해 사용하므로 간접 참조 연산을 할 수 없습니다. 따라서 malloc 함수가 널 포인터를 반환한 경우 그 값을 참조하면 실행 중에 에러 메시지를 표시하고 비정상 종료됩니다.

 

이 문제는 프로그램이 실행될 때 메모리의 상태에 따라 달라지므로 평소에는 잘 실행되던 프로그램이 메모리가 부족해 어느 날 갑자기 문제를 일으킬 수 있습니다. 따라서 동적 할당 함수를 호출한 후에는 반드시 반환값을 검사하는 과정이 필요합니다.

 

10~14행에서 이 과정을 수행하며 메모리를 할당하지 못한 경우에는 13행의 exit 함수가 프로그램을 정상적으로 종료합니다. exit 함수는 어떤 함수에서든 프로그램을 바로 종료할 수 있으며 예외 상황이 발생해 프로그램을 바로 종료하는 경우 인수로 1을 주고 호출합니다.

 

⚠️  2. 사용이 끝난 저장 공간은 재활용할 수 있도록 반환해야 합니다.

자동 지역 변수의 저장 공간은 함수가 반환될 때 자동으로 회수되지만, 동적으로 할당한 저장 공간은 함수가 반환된 후에도 그대로 메모리에 남아 있습니다. 따라서 함수가 반환되기 전에 동적 할당한 저장 공간은 free 함수로 직접 반환해야 합니다.

 

프로그램에서 동적으로 할당한 저장 공간은 해당 프로그램이 종료될 때 운영체제에 의해서 자동으로 회수되어 다른 프로그램이 실행될 때 재활용됩니다. 따라서 main 함수가 끝날 때는 굳이 반환할 필요가 없지만, 그 외 다른 함수에서 사용하던 저장 공간은 불필요한 경우 반환해 새로운 동적 할당에 재활용해야 합니다.

메모리 해제를 잊으면 메모리 누수(memory leak)를 일으켜 프로그램이 의도치 않게 종료될 수 있으니 반드시 사용한 메모리는 해제하는 습관을 들여야 합니다.

 

배열처럼 동적 할당

 

형태가 같은 변수가 많이 필요할 때 하나씩 동적 할당하는 것은 비효율적입니다. 할당한 저장 공간의 수만큼 포인터가 필요하기 때문입니다. 따라서 크기가 큰 저장 공간을 한꺼번에 동적 할당해 배열처럼 사용하는 편이 좋습니다. 이때 할당한 저장 공간의 위치만 포인터에 저장하면 포인터를 배열처럼 쓸 수 있습니다.

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

int main(void)
{
    int *pi;
    int i, sum = 0;

    pi = (int *)malloc(5 * sizeof(int));
    if (pi == NULL)
    {
        printf("메모리 부족!\n");
        exit(1);
    }

    printf("정수 5개 입력: ");
    for (i = 0; i < 5; i++)
    {
        scanf("%d", &pi[i]);
        sum += pi[i];
    }
    printf("평균: %.1lf\n", (sum / 5.0));
    free(pi);

    return 0;
}

9행은 배열처럼 사용할 전체 저장 공간을 동적 할당해 int형을 가리키는 포인터에 그 주소를 저장합니다. 그러면 포인터의 주소 값을 int형의 크기만큼 증가시켜 전체 공간을 배열처럼 사용할 수 있습니다.

 

기타 동적 할당 함수

 

메모리를 동적 할당할 때 가장 많이 사용하는 함수는 malloc입니다. 그러나 경우에 따라 더 유용하게 사용할 수 있는 함수들이 있습니다.

 

void *calloc(unsigned int, unsigned int);
void *realloc(void *, unsigned int);

calloc 함수는 할당한 저장 공간을 0으로 초기화하고, realloc 함수는 할당된 저장 공간의 크기를 조절합니다. 

 

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

int main(void)
{
    int *pi;
    int size = 5;
    int count = 0;
    int num;
    int i;

    pi = (int *)calloc(size, sizeof(int));
    while(1)
    {
        printf("양수 입력: ");
        scanf("%d", &num);
        if (num <= 0) break;
        if (count == size)
        {
            size += 5;
            pi = (int *)realloc(pi, size * sizeof(int));
        }
        pi[count++] = num;
    }
    for (i = 0, i < count; i++)
    {
    	printf("%5d", pi[i]);
    }
    free(pi);

    return 0;
}

12행에서 calloc 함수를 호출하는데 두 번째 인수로 할당할 저장 공간의 크기를 전달하고, 첫 번째 인수로 그 개수를 전달합니다.

 

calloc 함수는 할당된 저장 공간을 모두 0으로 초기화하므로 0으로 초기화가 필요한 경우 이 함수를 사용하면 따로 초기화하는 수고를 덜 수 있습니다.

 

만약 저장 공간의 크기를 조정해야 한다면 realloc 함수를 사용하면 됩니다. realloc 함수는 이미 할당한 저장 공간의 포인터와 조정할 저장 공간의 전체 크기를 줍니다. 저장 공간을 늘리는 경우 이미 입력한 값은 그대로 유지되며 추가된 공간에는 쓰레기 값이 존재합니다. 반대로 저장 공간을 줄이는 경우라면 입력된 데이터는 잘려 나갑니다. 

 

이미 사용하던 저장 공간에 새로 추가될 수도 있지만 재할당 과정에서 메모리의 위치가 바뀔 수 있으므로 항상 realloc 함수가 반환하는 주소를 다시 포인터에 저장해 사용하는 것이 좋습니다. 물론 메모리 위치가 바뀌는 경우에도 이미 있던 데이터를 계속 사용할 수 있도록 옮겨 저장하며 사용하던 저장 공간은 자동으로 반환됩니다.

 

또한 첫 번째 인수가 널 포인터인 경우는 malloc과 가은 기능을 수행해 두 번째 인수의 크기만큼 동적 할당하고 그 주소를 반환합니다.

널 포인터(null pointer)는 초기화하지 않은 포인터가 아닌 NULL로 초기화한 포인터를 의미합니다.

 

+) 더 알아보기

 

프로그램은 실행될 때 일정한 메모리 영역을 사용합니다. 이 영역은 다시 몇 개의 영역으로 나뉘어 관리되는데 이를 기억 부류(storage class)라고 합니다. 구체적인 구분은 시스템에 따라 다르겠지만, 주로 프로그램이 올라가는 코드 영역과 데이터가 저장되는 데이터 영역으로 나눕니다. 데이터 영역은 지역 변수가 할당되는 스택(stack) 영역이 있고, 동적 할당되는 공간인 (heap) 영역이 있습니다. 그 외에 전역 변수나 정적 변수를 위한 데이터 영역이 있습니다.

 

힙에 할당한 저장 공간에는 지역 변수와 마찬가지로 쓰레기 값이 있습니다. 그러나 지역 변수와 달리 프로그램이 종료될 때까지 메모리에 존재합니다. 따라서 주소만 알면 특정 함수에 구애 받지 않고 어디서나 사용할 수 있습니다. 동적 할당이 갖는 이런 특징 때문에 할당된 저장 공간을 사용할 때는 반환에 세심한 주의가 필요합니다. 지역 변수와 달리 동적 할당된 저장 공간은 함수가 반환되어도 메모리가 회수되지 않습니다.

 

메모리에 저장 공간이 넉넉히 남아 있어도 메모리 할당 함수들이 널 포인터를 반환할 수 있습니다. 힙 영역은 메모리의 사용과 반환이 불규칙적이기 때문에 사용 가능한 영역이 조각나서 흩어져 있을 수 있습니다. 이때 연속된 큰 저장 공간을 요구하면 동적 할당 함수는 원하는 저장 공간을 찾지 못하고 널 포인터를 반환할 수 있습니다.

 

따라서 동적 할당 함수를 호출한 후에는 반드시 반환값을 검사해서 메모리의 할당 여부를 확인해야 합니다.

 

동적 할당 활용

 

만약 영어 사전의 모든 단어를 메모리에 저장한다고 했을 때 배열은 각 행의 길이가 모두 같아야 하기 때문에 많은 메모리 공간이 낭비가 됩니다. 따라서 각 단어의 길이만큼만 메로리를 확보하고 처리하는 방법이 필요한데 이때 동적 할당을 사용할 수 있습니다.

 

문자열 처리

 

동적 할당을 수행하면 입력되는 문자열의 길이에 맞게 저장 공간을 사용할 수 있습니다. 예제를 통해 길이가 다른 여러 개의 문자열을 효율적으로 처리하는 방법을 알아보겠습니다.

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

int main(void)
{
    char temp[80];
    char *str[3];
    int i;

    for (i = 0; i < 3; i++)
    {
        printf("입력 : ");
        gets(temp);
        str[i] = (char *)malloc(strlen(temp) + 1);
        strcpy(str[i], temp);
    }

    for (i = 0; i < 3; i++)
    {
    	printf("%s\n", str[i]);
    }

    for (i = 0, i < 3; i++)
    {
    	free(str[i]);
    }

    return 0;
}

문자열을 입력하기 전에는 그 길이를 알 수 없으므로 우선 7행에서 충분한 크기의 배열을 선언하고 문자열을 입력합니다. 그리고 그 길이에 맞게 다시 동적 할당한 후 입력한 문자열을 복사합니다. 또한 동적 할당 영역을 연결할 포인터가 필요하므로 포인터 배열도 선언합니다.

 

다음은 위 예제에서 char 배열의 문자열을 출력하는 부분을 함수로 바꿔 동적 할당 영역에 저장한 데이터를 함수로 처리하는 예제입니다.

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

void print_str(char **ps);

int main(void)
{
    char temp[80];
    char *str[21] = { 0 };
    int i = 0;

    while (i < 20)
    {
        printf("입력: ");
        gets(temp);
        if (strcmp(temp, "end") == 0) break;
        str[i] = (char *)malloc(strlen(temp) + 1);
        strcpy(str[i], temp);
        i++;
    }
    print_str(str);

    for (i = 0; str[i] != NULL; i++)
    {
    	free(str[i]);
    }

    return 0;
}

void print_str(char **ps)
{
    while (*ps != NULL)
    {
        printf("%s\n", *ps);
        ps++;
    }
}

str 배열의 자료형은 (char *)형 이므로, 함수에서 str을 저장할 매개변수로 (char *)을 가리키는 이중 포인터를 선언합니다.

 

char *str[21] = { 0 };

그리고 포인터나 포인터 배열을 auto 지역 변수로 선언하면 최초에 쓰레기 값이 주소로 존재합니다. 만약 쓰레기 값이 참조가 불가능한 코드 영역의 주소이고 부주의로 이 값을 참조한다면 프로그램은 중간에 실행을 멈춥니다. 따라서 위와 같이 포인터 배열을 선언과 동시에 널 포인터로 초기화하고 참조할 때 널 포인터인지를 검사하면 더 안정적인 프로그래밍이 가능합니다.

 

이처럼 동적 할당 영역을 행의 길이가 가변적인 2차원의 char 배열처럼 사용하면 메모리를 효과적으로 사용할 수 있습니다.

 

명령행 인수

 

프로그램의 실행 방법은 운영체제마다 다릅니다. 윈도우에서는 바탕화면 프로그램 아이콘을 더블 클릭해야 하며 도스나 유닉스에서는 명령행에 실행 파일의 이름을 직접 입력해야 합니다. 명령행에서 프로그램을 실행시킬 때는 프로그램 이름 외에도 프로그램에 필요한 정보를 함께 줄 수 있는데 이를 명령행 인수(command line arugment) 라고 합니다. 운영체제가 명령행 인수를 프로그램의 main 함수로 넘기는 방법을 통해 포인터로 동적 할당한 영역을 배열처럼 사용하는 예제를 살펴보겠습니다.

#include <stdio.h>

int main(int argc, char **argv)
{
    int i;
    
    for (i = 0; i < argc; i++)
    {
    	printf("%s\n", argv[i]);
    }

    return 0;
}

main 함수에서 명령행 인수를 받기 위한 매개변수의 이름은 임의로 작성할 수 있으나 관례적으로 argcargc를 사용합니다. 각각 argument count, argument vector 라는 의미입니다. 

 

$ ./command arg1 arg2 arg3

만약 위와 같이 명령행을 입력했다면 argc는 명령행 인수의 개수인 3이 되고, 명령행에서 입력한 문자열의 위치는 argv 매개변수에 저장됩니다. 

운영체제는 명령행 인수를 가공해 문자열의 형태로 메모리에 저장하고 포인터 배열로 연결한 후에 포인터 배열의 시작 위치를 실행 프로그램의 main 함수에 넘깁니다.

'Lang > C' 카테고리의 다른 글

C (11) - 파일 입출력  (0) 2025.09.04
C (10) - 사용자 정의 자료형  (1) 2025.09.01
C (8) - 다차원 배열, 포인터 배열, 응용 포인터  (2) 2025.08.25
C (7) - 변수 영역과 데이터 공유  (0) 2025.08.21
C (6) - 문자와 문자열  (2) 2025.08.18