[C 시리즈 13편] 동적 메모리 할당으로 필요한 만큼만 쓰기

English version

12편에서 stdio.h를 사용해 파일과 입력을 다뤘다면, 이제는 "필요한 만큼의 메모리를 직접 빌렸다가 돌려주는" 동적 메모리 할당 차례입니다. 지금까지는 배열 길이를 컴파일 시점에 정했지만, 사용자 입력이나 파일 길이는 실행 전에 알 수 없습니다. mallocfree는 이런 상황에서 프로그램이 스스로 공간을 요청하고 해제하도록 도와줍니다.

이번 글에서 새로 나오는 용어

  1. (Heap): 실행 중 필요한 만큼 메모리를 요청해 쓰는 영역
  2. 동적 메모리 할당: 프로그램 실행 중 malloc 같은 함수를 통해 메모리를 확보하는 과정
  3. size_t: "얼마나 큰가"를 표현할 때 쓰는 부호 없는 정수 타입
  4. 메모리 누수 (Memory Leak): 할당한 메모리를 free하지 않아 더 이상 접근할 길을 잃은 상태

핵심 개념

학습 메모

  • 소요 시간: 60~90분
  • 준비물: 배열, 포인터 기초, stdio.h 입출력 경험
  • 학습 목표: malloc 계열 함수를 사용해 공간을 확보하고, free로 수명을 명확히 끝내기

정적 배열은 길이를 바꾸기 쉽지 않기 때문에 입력 크기를 모르거나 점점 늘어나는 데이터를 다루기 어렵습니다. 예를 들어 사용자가 몇 글자를 입력할지, 파일에 몇 개의 점수가 들어 있을지 실행 전에는 모를 수 있습니다. 동적 메모리는 바로 이런 "실행 중에 크기가 정해지는 데이터"를 다룰 때 필요합니다. 동적 메모리는 아래 단계를 항상 함께 생각해야 합니다.

  1. 요청: 필요한 바이트 수를 계산해 malloc/calloc/realloc으로 확보합니다.
  2. 확인: 반환값이 NULL인지 확인해 실제로 할당이 성공했는지 검사합니다.
  3. 사용: 포인터를 배열처럼 쓰거나 다른 포인터에게 넘깁니다.
  4. 해제: 더 이상 필요 없을 때 free로 돌려주고, 포인터를 NULL로 초기화하면 안전합니다.

이번 글에서는 아래 시나리오를 중심으로 살펴봅니다.

  • 정적 배열과 동적 메모리 차이 이해하기
  • malloc, calloc, realloc의 공통점과 차이점
  • free를 꼭 호출해야 하는 이유와 누수를 찾는 감각
  • 실패 가능성을 대비한 NULL 체크 패턴
  • 입력 길이에 따라 버퍼를 늘리는 간단한 예제

코드로 따라하기

스택 배열과 힙 메모리 비교

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

int main(void) {
    int fixed[3] = {1, 2, 3};
    int *dynamic = malloc(sizeof(int) * 3);

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

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

    printf("fixed[1] = %d\n", fixed[1]);
    printf("dynamic[1] = %d\n", dynamic[1]);

    free(dynamic);
    return 0;
}

fixed는 컴파일 시점에 크기가 정해지고 스택에 배치됩니다. dynamic은 실행 중에 힙에서 공간을 빌리고, free(dynamic);으로 직접 돌려줍니다.

malloc으로 필요한 만큼 요청하기

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

int main(void) {
    size_t count = 5;
    int *scores = malloc(sizeof(int) * count);

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

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

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

    free(scores);
    scores = NULL;
    return 0;
}
  • sizeof(int) * count처럼 "필요한 요소 수 × 한 요소 크기"로 계산합니다.
  • size_t는 크기를 표현할 때 사용하는 표준 타입이라 음수가 없고, 플랫폼마다 자동으로 적절한 크기를 가집니다.
  • free 이후 포인터를 NULL로 바꿔 두면 잘못된 재사용을 빨리 발견할 수 있습니다.

calloc으로 0으로 초기화된 공간 얻기

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

int main(void) {
    size_t count = 4;
    int *buffer = calloc(count, sizeof(int));

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

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

    free(buffer);
    return 0;
}

calloc은 요소 수와 요소 크기를 따로 받으며, 모든 바이트를 0으로 채워 줍니다. 배열 값을 일일이 0으로 채우는 수고를 덜 수 있지만, 모든 자료형에서 이것이 언제나 "논리적으로 원하는 초기값"과 완전히 같다고 단정하면 안 됩니다. 입문 단계에서는 정수 배열을 0으로 준비하는 예제로 먼저 이해하면 충분합니다.

realloc으로 버퍼 크기 늘리기

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

int main(void) {
    size_t capacity = 4;
    size_t length = 0;
    char *line = malloc(capacity);

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

    const char *source = "C-memory";

    for (size_t i = 0; source[i] != '\0'; i++) {
        if (length + 1 >= capacity) {
            size_t new_capacity = capacity * 2;
            char *temp = realloc(line, new_capacity);
            if (temp == NULL) {
                free(line);
                return 1;
            }
            line = temp;
            capacity = new_capacity;
        }

        line[length++] = source[i];
    }

    line[length] = '\0';
    printf("line = %s (capacity = %zu)\n", line, capacity);

    free(line);
    return 0;
}
  • realloc은 기존 포인터를 새로운 크기로 늘이거나 줄입니다.
  • 반환값이 다를 수 있으므로, 임시 포인터(temp)로 받아서 실패했을 때 원본을 잃지 않게 합니다.
  • 성공하면 새 포인터를 기준으로만 계속 사용하고, 실패했을 때만 원본 포인터가 그대로 남아 있다고 생각하면 됩니다.
  • 새로운 공간이 이전 주소와 다를 수 있어 복사 후 오래된 포인터를 사용하면 안 됩니다.

실패 가능성을 항상 염두에 두기

힙 공간은 무한하지 않으므로, malloc이 실패하면 NULL을 반환합니다. 따라서 아래 패턴을 기본으로 익혀 둡니다.

int *data = malloc(sizeof(int) * n);
if (data == NULL) {
    // 로그를 남기거나 사용자에게 알리고, 안전하게 종료
}

또한 freeNULL 포인터를 인자로 받을 때 아무 일도 하지 않으므로, "이미 해제했는지 확신이 없다면 먼저 NULL인지 확인"하는 습관을 들이면 좋습니다.

누수를 찾아내는 생활 습관

간단한 규칙을 코드 리뷰 리스트로 만들어 두면 누수를 줄일 수 있습니다.

  1. malloc을 호출한 함수가 free까지 책임질 것인지, 아니면 호출자에게 포인터를 반환할 것인지 역할을 명확히 구분합니다.
  2. return 문이 여러 개인 함수에서는 "모든 경로에서 free가 호출되는가"를 체크합니다.
  3. 동적 배열을 구조체에 넣을 때는 구조체를 해제하는 함수에 free 호출을 포함시킵니다.

왜 중요한가

  • 입력 길이, 파일 크기, 네트워크 데이터 등 실행 중 결정되는 정보를 다루려면 동적 메모리가 필수입니다.
  • mallocfree를 이해하면 이후 포인터 산술, 구조체, 사용자 정의 컨테이너까지 확장할 수 있습니다.
  • 메모리 누수를 스스로 추적하는 습관은 시스템 프로그래밍뿐 아니라 다른 언어의 리소스 관리(파일, 소켓 등)에도 그대로 응용됩니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: malloc/calloc/realloc 예제를 각각 실행한 뒤, capacitylength 값을 출력해 메모리가 어떻게 늘어나는지 기록합니다.
  • 확장하기: realloc 예제에서 source를 사용자 입력으로 바꾸고, 줄의 길이가 0이면 바로 종료하도록 조건을 추가합니다.
  • 디버깅: free 호출을 일부러 주석 처리해 본 뒤, valgrindAddressSanitizer 같은 도구가 어떤 경고를 내는지 확인합니다(설치되어 있다면).
  • 완료 기준: "언제 할당하고 언제 해제하는지"를 글로 설명할 수 있고, 실패 시 NULL을 검사하는 패턴을 직접 작성할 수 있으면 됩니다.

마무리

동적 메모리는 "필요할 때 잠깐 빌려 쓰는 공간"입니다. 이번 편에서는 malloc 계열 함수 사용법과 free 습관을 익혔으니, 다음 편에서는 메모리 오류가 실제로 어떻게 나타나고 어떤 도구로 추적할 수 있는지 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요