C 프로그래머를 위한 메모리 이야기 - 스택(function), 힙 (malloc, free)

Coder One
방문: 92

C 언어로 프로그래밍을 하려면 메모리에 대한 이해가 매우 중요합니다. 하지만 초보자에게 메모리 개념은 다소 추상적으로 느껴질 수 있는데요. 이 글에서는 가상 메모리, 스택과 힙, 동적 메모리 할당, 그리고 흔히 하는 메모리 관리 실수와 그 해결 방법을 쉽게 설명해보겠습니다. 이론보다는 직접 실습해볼 수 있는 예제 코드를 중심으로 다루겠습니다.


이미지 출처: KwonKusang

가상 메모리란?

컴퓨터에서 프로그램이 사용하는 메모리는 실제 물리적 메모리(RAM)와 1대1로 대응되지 않습니다. 가상 메모리(Virtual Memory)란 운영체제가 제공하는 기능으로, 프로그램마다 독립적인 메모리 공간을 갖는 것처럼 보이게 하는 기술입니다​. 마치 각 프로그램이 자신만의 큰 마을이나 별도의 집합 공간을 부여받는 것과 비슷합니다. 이 가상 주소 공간 덕분에 하나의 프로그램이 다른 프로그램의 메모리에 침범하지 않으며, 프로그램은 매우 큰 연속된 메모리를 가진 것처럼 작업할 수 있습니다​.

예를 들어, 우리가 C 프로그램에서 어떤 변수의 주소(&변수)를 출력해보면 항상 큰 값의 메모리 주소가 나오는데, 이 주소는 가상 주소입니다. 운영체제는 이 가상 주소를 실제 물리 메모리 주소로 변환하여 사용합니다. 초보 단계에서는 가상 메모리의 내부 작동 원리를 깊게 알 필요는 없지만, 각 프로그램이 서로 독립된 메모리 공간을 가진다는 점과, C에서 보이는 메모리 주소들은 가상화된 주소라는 정도만 알아두면 됩니다.

메모리 구조: 스택과 힙

C 프로그램이 실행될 때 메모리 공간은 용도에 따라 몇 가지 영역으로 나뉩니다. 그 중 스택(Stack)힙(Heap)은 특히 중요합니다.

  • 스택(Stack): 스택은 함수 호출과 지역 변수를 위해 사용되는 메모리 공간입니다. 새로운 함수가 호출될 때마다 스택에 변수들이 쌓이고(LIFO, Last-In First-Out 구조), 함수가 종료되면 쌓았던 메모리를 자동으로 치웁니다. 예를 들어 함수 안에서 변수를 선언하면 그 변수가 스택에 저장되고, 함수가 끝날 때 자동으로 제거됩니다. 스택 영역의 특징은 자동 할당/해제라는 점입니다. 프로그래머가 직접 관리하지 않아도 함수 호출과 함께 할당되고 반환 시 자동으로 해제되므로 편리하지만, 크기가 한정되어 있어 너무 많은 데이터를 담으면 스택 오버플로우가 발생할 수 있습니다.

  • 힙(Heap) 구역: 힙은 동적 메모리 할당을 위해 사용되는 메모리 공간입니다. 힙은 스택과 달리 개발자가 직접 관리하는 영역입니다. 힙 영역은 스택보다 훨씬 크고 보다 자유롭게 활용 가능합니다​. 단, 자유로운 대신 사용한 사람이 정리까지 책임져야 합니다. C에서는 malloc, calloc, realloc 등을 사용해 힙에 메모리를 할당하고, 사용이 끝난 메모리는 반드시 free 함수로 해제해야 합니다. 힙에 할당한 메모리는 프로그래머가 free를 호출하기 전까지 유지되며, 함수를 빠져나가도 남아있습니다. 따라서 힙은 유연성이 높지만, 잘못 관리하면 메모리 누수 등의 문제가 생길 수 있습니다.

요약하면, 스택은 지역 변수와 함수 호출에 쓰이는 자동 관리 메모리이고, 은 필요에 따라 할당/해제하는 수동 관리 메모리입니다. 스택은 쌓았다가 자동으로 치우는 접시 더미 같고, 힙은 필요할 때 직접 꺼내쓰고 되돌려줘야 하는 창고 같은 곳이라고 할 수 있겠습니다.

스택과 힙 메모리 사용 예시

스택과 힙의 차이를 코드로 한 번 확인해보겠습니다. 아래 간단한 코드에서는 스택에 할당된 변수와 힙에 할당된 변수를 만들어보고, 그 주소를 출력해봅니다.

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

void stackExample() {
    int stackVar = 10;              // 스택에 할당되는 지역 변수
    printf("stackVar의 주소: %p\n", (void*)&stackVar);
}

int main() {
    int *heapVar = malloc(sizeof(int));  // 힙에 정수 크기만큼 동적 할당
    if (heapVar == NULL) {
        perror("메모리 할당 실패");
        return 1;
    }

    *heapVar = 20;  // 할당한 힙 메모리에 값 저장
    printf("heapVar가 가리키는 주소: %p\n", (void*)heapVar);

    stackExample();  // 스택 변수의 주소 출력

    free(heapVar);   // 힙 메모리 해제
    return 0;
}

위 코드에서 stackVar는 함수가 호출될 때 스택에 생성되고 함수가 끝나면 사라집니다. 한편 heapVar로 받은 메모리는 malloc으로 힙에서 얻었기 때문에 free를 호출하기 전까지 유지됩니다. 코드를 실행해 보면 (운영체제에 따라 다르지만) stackVar의 주소와 heapVar가 가리키는 주소가 완전히 다른 범위에 있음을 확인할 수 있습니다. 예를 들어 stackVar의 주소는 0x7ff...처럼 나오고, heapVar의 주소는 0x55... 등으로 나와서 스택 영역과 힙 영역이 서로 떨어져 있음을 알 수 있습니다. 이처럼 스택과 힙은 메모리의 서로 다른 영역이며, 관리 방법도 다릅니다.

동적 메모리 할당 (malloc/free 사용법)

힙 메모리를 사용하려면 동적 메모리 할당을 해야 합니다. 동적 할당이란 프로그램이 실행되는 도중에 필요한 메모리를 직접 요청하여 확보하는 것을 말합니다. C 언어에서는 malloc 함수로 메모리를 할당하고, 다 쓴 메모리는 free 함수로 반환합니다. 필요한 용량만큼 힙에서 떼어오는 개념인데요, 쇼핑몰에서 물건을 필요할 때마다 사오고 다 쓰면 반납한다고 비유할 수도 있겠습니다.

동적 메모리 할당을 사용하는 이유는 유연성 때문입니다. 예를 들어, 데이터를 담을 배열의 크기를 컴파일 시에 알 수 없다면, 런타임에 할당해야합니다. 힙을 사용하면 사용자가 입력한 크기대로 메모리를 확보할 수 있습니다.

다음 예제는 사용자가 입력한 정수 개수만큼 정수를 저장할 수 있는 배열을 동적으로 할당하여 사용하는 코드입니다:

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

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

    // n개의 정수를 저장할 공간을 힙에 할당
    int *arr = malloc(n * sizeof(int));
    if (arr == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }

    // 할당한 메모리 사용 (예: 값 초기화 및 출력)
    for (int i = 0; i < n; ++i) {
        arr[i] = i * 2;  // 예제로 각 요소에 i*2 값 저장
    }
    printf("arr[0] = %d, arr[%d] = %d\n", arr[0], n-1, arr[n-1]);

    // 동적 할당한 메모리 해제
    free(arr);
    return 0;
}

실행 방법: 위 코드를 컴파일하고 실행하면, 먼저 정수 개수를 입력하라는 메시지가 나옵니다. 예를 들어 5를 입력하면, 5개의 정수를 저장할 공간을 malloc으로 확보하고 각 요소에 값을 넣습니다. arr[0]arr[4] 값을 출력한 후 프로그램이 끝나기 전에 free(arr)로 메모리를 해제합니다.

이 예제에서는 동적으로 배열을 만들었기 때문에, 컴파일 시에는 크기가 정해져 있지 않다가 입력에 따라 유동적으로 메모리를 사용하게 됩니다. 만약 malloc으로 메모리 할당 후 free를 호출하지 않으면 어떻게 될까요? – 그러면 프로그램이 끝날 때까지 계속 남아 있게 됩니다. 다음 섹션에서 이러한 상황을 메모리 관리 실수 관점에서 살펴보겠습니다.

흔한 메모리 관리 실수와 해결 방법

C에서는 프로그래머가 메모리를 직접 관리해야 하기 때문에 몇 가지 흔한 실수가 발생하곤 합니다. 이러한 실수들은 프로그램의 오류비정상 종료, 심지어 보안 취약점으로 이어질 수 있기 때문에 조심해야 합니다. 아래에 초보자가 흔히 겪는 메모리 관리 실수들과 그 해결 방법을 정리했습니다.

  • 메모리 누수(Memory Leak): 메모리 누수란 사용이 끝난 메모리를 제대로 해제하지 않았는데 해당 변수를 잃어버리는 경우를 말합니다. 쉽게 말해, malloc으로 메모리를 받았지만 free를 호출하지 못하고 해당 메모리 주소에 대한 참조를 잃어버린 상황입니다. 이렇게 되면 그 메모리는 프로그램이 종료될 때까지 사용되지 못한 채로 남아 있게 됩니다. 메모리 누수가 반복되면 프로그램이 점점 많은 메모리를 차지하여 메모리 부족 문제를 일으킬 수 있습니다. (예를 들어, 매번 함수가 호출될 때마다 malloc만 하고 free를 빼먹는다면 반복 호출 시 메모리 사용량이 늘어나겠죠.) 해결 방법: 할당한 메모리는 더 이상 필요없을 때 반드시 free로 해제하세요. 코드 구조상으로 malloc을 했으면 함수 끝부분에서 free를 호출하거나, malloc과 free를 지어 생각하는 습관을 들이는 것이 좋습니다.

  • 댕글링 포인터(Dangling Pointer): 댕글링 포인터는 이미 해제된 메모리를 가리키고 있는 포인터를 말합니다. 예를 들어 힙에 메모리를 할당하고 free로 해제했는데, 여전히 그 메모리에 접근하려고 하는 코드를 작성하면 댕글링 포인터 문제가 발생합니다. 해제된 메모리에 접근하는 것은 유효하지 않은 메모리를 참조하는 것이므로, 프로그램에 예기치 않은 오류를 일으킵니다. 댕글링 포인터를 따라가서 값을 읽으면 쓰레기 값이나 운 좋게 직전에 있던 값이 보일 수도 있지만, 이것은 완전히 정의되지 않은 동작입니다. 쉽게 말해, 예전에 살던 집(메모리)을 허물어버렸는데도 그 열쇠(포인터)를 가지고 집에 들어가려는 격입니다. 해결 방법: 메모리를 free한 후에는 그 포인터를 다시 사용하지 않도록 주의해야 합니다. 관례적으로, 포인터를 해제한 뒤에는 바로 NULL로 설정하여(예: free(p); p = NULL;) 실수로 다시 사용하려 해도 바로 발견할 수 있게 하는 방법이 있습니다. NULL 포인터에 접근하면 즉시 오류가 발생하기 때문에, 차라리 조기에 버그를 찾을 수 있습니다. 물론 가장 좋은 것은 애초에 free 이후 해당 포인터를 쓰지 않도록 코드 흐름을 관리하는 것입니다.

  • 이중 해제(Double Free): 같은 메모리를 두 번 free하는 실수도 빈번합니다. 예를 들어, 어떤 함수를 호출해서 메모리를 해제했는데, 나중에 또 한 번 해제하도록 코드를 작성한 경우입니다. 한 번 반환된 메모리를 또 반환하면 운영체제 입장에서 "없어진 자원을 또 없애라 한다"는 모순이 생깁니다. 이 역시 정의되지 않은 동작으로 분류되어, 프로그램이 임의의 방식으로 이상 동작하거나 충돌할 수 있습니다​. 보통은 이중 해제 시 프로그램이 바로 비정상 종료되면서 오류를 표시하는 경우가 많습니다. 해결 방법: 이중 해제를 피하려면, 메모리를 해제하는 부분을 한 곳에서만 책임지도록 코드 구조를 짜야 합니다. 만약 포인터를 전역적으로 쓰거나 여러 함수에 걸쳐 쓴다면, 어느 지점에서 free를 했는지 명확히 하고 중복으로 free하지 않도록 관리하세요. 앞서 말한 것처럼 free 후에 포인터를 NULL로 설정해 두는 것도 좋습니다.

  • 버퍼 오버플로우(Buffer Overflow): 버퍼 오버플로는 할당된 메모리 범위를 넘어서는 데이터 쓰기/읽기로 인해 발생하는 문제입니다. 예를 들어 배열을 10개 짜리로 만들었는데 11번째 요소에 접근하려 하거나, malloc(10)으로 10바이트를 할당받아놓고 10바이트 초과로 쓰는 경우입니다. 이 경우 초과된 데이터가 인접한 메모리를 덮어쓰면서 예기치 않은 동작을 일으킵니다​. 흔한 예로, C 문자열을 다룰 때 strcpy로 버퍼 크기보다 큰 문자열을 복사하면 그 뒤의 메모리가 망가지는 상황이 있습니다. 버퍼 오버플로가 일어나면 프로그램이 바로 충돌(segfault)하거나, 운 나쁘게는 충돌하지 않고 내부 데이터가 손상되어 나중에 엉뚱한 버그를 낳기도 합니다. (보안 측면에서도 버퍼 오버플로는 취약점을 만들어 공격자가 임의 코드를 실행하도록 악용될 수 있습니다.) 해결 방법: 항상 배열이나 동적 할당한 메모리의 경계를 지켜서 사용해야 합니다. 루프를 돌 때 인덱스 범위를 확인하고, C 표준 함수 사용 시에도 strncpy, snprintf처럼 안전한 버전의 함수를 사용해 버퍼 크기를 지정하는 것이 좋습니다. 동적 할당 시에도 필요한 크기보다 약간 여유 있게 할당하거나, 입력 값을 신중히 검증하여 잘못된 메모리 접근을 하지 않도록 주의해야 합니다.

위의 실수들은 C 언어를 막 시작한 사람들이 한 번쯤 겪을 수 있는 함정들입니다. 요약하자면 "할당했으면 해제하기", "해제했으면 그 포인터 사용 금지", 그리고 "메모리 범위 확인 철저히"가 핵심 수칙입니다. C 언어는 강력하지만 이런 메모리 관리 실수를 방지하기 위한 장치가 없어서(예를 들어 Java나 Python은 가비지 컬렉터가 자동으로 메모리를 정리해주지만, C는 개발자가 직접 해야 합니다), 스스로 규칙을 지켜야 합니다. 필요하다면 주석이나 함수 이름 등에 메모리 관리 정책을 명시해 두는 것도 도움이 됩니다.

마치며

이번 글에서는 C 프로그래밍의 메모리 개념을 크게 가상 메모리, 스택과 힙, 동적 할당, 메모리 관리 실수 네 가지 주제로 나누어 살펴보았습니다. 복잡한 운영체제 이론이나 CPU 아키텍처까지 들어가진 않았지만, 초보자분들이 실용적인 수준에서 이해해야 할 핵심을 설명하려고 노력했습니다. 기억해야 할 중요한 점을 다시 한 번 정리하면 다음과 같습니다:

  • 가상 메모리: 프로그램마다 독립된 주소 공간을 갖도록 하는 운영체제의 지원 덕분에, 메모리 충돌 없이 큰 메모리를 다루는 착각(illusion)이 가능하다​.
  • 스택: 함수 호출과 함께 자동으로 할당되고 종료, 해제되는 LIFO 구조의 메모리 영역. 지역 변수 저장에 사용되며 너무 크면 안 된다.
  • 힙: 프로그래머가 malloc/free로 관리하는 동적 메모리 영역. 유연하게 큰 메모리를 다룰 수 있으나 직접 해제해줘야 한다​.
  • 동적 할당: 실행 중 필요한 메모리를 확보하는 기술. 사용 후 반드시 해제해야 하며, 예제로 malloc/free 코드 사용법을 익혔다.
  • 메모리 실수: 메모리 누수​, 댕글링 포인터​, 이중 해제​, 버퍼 오버플로​등 대표적인 문제와 예방법을 알아두자.

C 언어 메모리 관리는 처음엔 어렵게 느껴질 수 있지만, 작은 예제들을 직접 코딩하며 실습해보면 감을 잡을 수 있습니다. 위에 나온 코드를 직접 작성하고 실행해 보세요. 그리고 의도적으로 free를 빼먹거나, 배열 인덱스를 잘못 지정해보면서 어떤 일이 일어나는지도 실험해보면 좋습니다. 물론 실험은 조심해서 해야 합니다! 😄 메모리 관리를 철저히 하는 습관을 들이면, 안정적인 C 프로그램을 작성할 수 있을 것입니다. Happy Coding!


Related Posts