[CS] C : Malloc과 동적 메모리

메모리 구조

프로그램이 실행되기 위해서는 먼저 프로그램이 메모리에 로드(Load)되어야 한다.

또한 프로그램이 사용하는 변수들을 저장할 메모리도 필요하다. 따라서 운영체제는 프로그램의 실행을 위해 다양한 메모리 공간을 제공한다.

코드 영역

  • 메모리의 코드 영역은 실행할 프로그램의 코드, 상수, 리터럴 등이 저장되는 영역이다
  • 텍스트 영역이라고도 부른다.
  • CPU는 코드 영역에 저장된 명령어를 하나씩 가져가서 처리한다
  • 읽기 전용으로, 코드가 메모리에 로드되는 시점에 할당된다

데이터 영역

  • 프로그램의 전역 변수와 정적(static) 변수가 저장되는 영역이다
  • 프로그램의 시작과 함께 할당되고, 프로그램이 종료되면 소멸된다

힙 영역

  • 메모리의 힙 영역은 사용자가 직접 관리할 수 있는 그리고 관리해야만 하는 메모리 영역이다
  • 사용자에 의해 동적으로 메모리 공간이 할당되고 해제된다
  • 힙 영역은 메모리의 낮은 주소에서 높은 주소 방향으로 할당된다 (확장)

스택 영역

  • 함수의 호출과 관계되는 지역 변수와 매개 변수가 저장된다
  • 함수의 호출과 함께 할당되며, 함수의 호출이 완료되면 소멸된다
  • 스택 영역에 저장되는 함수의 호출 정보를 스택 프레임이라 한다
  • 스택 영역의 메모리는 높은 주소에서 낮은 주소 방향으로 할당된다 (감소)

메모리 할당

C는 동적 메모리 할당을 위한 4개의 표준 라이브러리 함수를 제공한다. 이는 모두 <stdlib.h> 헤더 파일에 선언되어있다. 함수를 알아보기에 앞서, 정적과 동적 메모리 할당의 차이에 대해 알아보자.

정적 메모리 할당

  • 컴파일 시점에 메모리 크기가 고정된다.
  • 런타임에는 메모리의 크기를 변경할 수 없다.

✅ 예시

int arr[2] = {1, 2}; 

동적 메모리 할당

  • 실행 중 (런타임) 필요한 만큼 힙 영역에서 메모리를 할당하는 방식
  • 가변 크기 데이터 구조를 처리하고 메모리 사용을 최적화하는데 유연성을 제공한다

Java나 Python 같은 언어는 프로그래머를 위해 메모리 할당과 해제를 자동으로 관리하는 가비지 컬렉션 메커니즘을 가진다. 하지만 C언어에서는 프로그래머가 메모리 할당을 직접 관리해야 한다.

 

✅ 가비지 컬렉션이란?

  • 더 이상 사용되지 않는 (도달할 수 없는) 메모리를 자동으로 찾아서 해제하는 시스템
  • 장점
    • 메모리 해제 자동화로 누수 방지
    • 개발자 부담 적음
    • dangling pointer 방지
  • 단점
    • 예측 불가능한 시점에 실행됨
    • 실행 중 일시적인 멈춤 발생 가능
    • 제어력이 줄어든다 (정밀한 메모리 최적화가 어렵다)

Malloc

메모리의 힙 영역에서 지정된 바이트 크기의 단일 메모리 블록을 할당한다. 이때 메모리는 초기화되지 않았기 때문에, 쓰레기 값이 포함되어 있다.

메모리 할당 과정

  1. malloc이 호출된다
  2. 내부 힙 관리자 (malloc 내부 구조)가 메모리 블록을 찾는다
    • malloc은 미리 확보해놓은 힙 영역 안에서 사용하지 않는 블록(free block)이 있는지 확인한다
    • 있다면 그 블록을 쪼개서 사용한다
    • 힙 관리자가 처리한다 (ptmalloc, jemalloc 등)
  3. 메모리가 부족하면 시스템 콜 요청
    • 남은 free block이 없다면 malloc 내부는 커널에 요청해 더 많은 메모리를 확보한다
    • 만약 할당에 실패하면 NULL을 반환한다
  4. 할당된 메모리에 대한 포인터를 반환
    • 확보한 메모리 블록 중 일부를 사용자에게 넘기고
    • 나머지는 내부적으로 free list로 관리한다

언제 malloc을 사용할까?

언제 malloc()으로 동적 메모리 할당이 필요할까?

  1. 크기가 컴파일 타임에 정해지지 않은 경우
    • 스택에는 컴파일 타임에 크기가 고정된 배열만 만들 수 있다. 동적으로 크기를 결정하기 위해서는 힙이 필요하다.
int n;
scanf("%d", &n);
int *arr = malloc(sizeof(int) * n); // 사용자 입력에 따라 런타임에 크기가 결정된다
  1. 스택에 올릴 수 없는 큰 데이터인 경우
    • 스택은 보통 수백 KB ~ 몇 MB로 제한이 있다
    • 너무 큰 배열이나 구조체는 스택에 올리면 스택 오버플로우가 발생하기때문에 힙에 안전하게 보관하기 위함이다.
int big[100000000]; // ❌ 스택에 올리면 터질 수 있음
int *big = malloc(sizeof(int) * 100000000);   // ✅ 힙에 안전하게
  1. 할당된 데이터를 함수 밖에서도 계속 사용할 때
    • 스택은 함수가 끝나면 메모리가 사라진다. 반면 힙에 할당된 메모리는 free()하기 전까지 유지된다
int* create_aray(){
    int *arr = malloc(sizeof(int) * 10);    // 함수 밖에서도 사용할 수 있다
    return arr;
}

malloc에 형변환이 필요한가?

ListNode* ln = (ListNode*)malloc(sizeof(ListNode));  // 형변환이 붙을때

ListNode* ln = malloc(sizeof(ListNode));

이 코드는 … :

  • malloc()은 void* 타입의 메모리 포인터를 반환한다
  • 우리는 ListNode* 타입의 포인터로 받고 있다.
  • 즉, (ListNode*) 라는 형변환(casting)이 포함되어있다.

형변환이 필요한가?

  • C 언어는 void* → T* 형변환이 암시적으로 자동으로 수행된다
  • 즉 생략해도 무방하다.
  • 하지만 왜 저 변환을 명시하는 코드가 많을까?
    • 이유 1 : C++ 호환성을 위함. C++은 void*를 자동으로 다른 포인터로 변환해주지 않는다. 그래서 명시적 형변환이 필요하다.
    • 이유 2 : 가독성이 좋다. type을 명시해줌으로써 다른 언어와 혼용되거나 교차 컴파일 환경에서 문제가 생기는 것을 미리 방지하기 위함이다.

Realloc

이미 할당한 메모리의 크기를 바꾸는 함수이다. 메모리 재사용 or 재할당 시 사용한다.

작동 방식

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

ptr : 이전에 malloc()이나 calloc()으로 할당한 메모리

new_size : 새로 바꾸고 싶은 크기

  1. ptr이 가리키는 메모리 블록을 확인한다
  2. 만약 new_size가 기존보다 작으면, 앞부분만 남기고 나머지는 반납한다
  3. 더 크면 :
    • 현재 위치에 공간이 충분하면 → 그 자리에서 확장한다
    • 공간이 부족하면 → 새 메모리 블록을 malloc으로 만들고 기존 데이터를 복사 후 기존 블록을 해제한다

코드 예제

int *arr = malloc(sizeof(int) * 5);   // 5개짜리 배열 할당
arr = realloc(arr, sizeof(int) * 10); // 10개로 확장한다
  • 기존 5칸짜리 배열에서 → 10칸으로 확장한다
  • 이전 데이터는 그대로 보존된다
  • 이때 왜 arr = realloc… 처럼 반환값을 받아서 다시 대입해야 할까?
    • realloc()은 새로운 메모리 주소를 리턴할 수 있기 때문이다.
    • 기존 arr 위치에서 공간을 확장하려 시도할때, 공간 부족으로 실패하면 새로운 위치에 메모리 블록을 할당하고 기존 데이터를 복사한다.
    • 이때 메모리는 새로운 주소를 가지게 되고, 그 주소를 반환한다
    • 즉 항상, 같은 위치에서 메모리가 확장 가능하지 않을 수 있으니 반환값을 다시 대입하는 과정이 필요하다.
  • 만약 대입하지 않으면 어떻게 될까?
    • realloc(arr, new_size) 로 할 경우
    • 반환값을 받지 않으면, 새 주소가 생겼는데 받지 않아서 잃어버린 것과 같다
    • 메모리 누수(memory leak) 발생

주의사항

  1. realloc() 실패 시 NULL을 반환하므로, 기존 포인터를 덮어쓰면 위험하다
arr = realloc(arr, new_size);    // ❌ 위험!

int *tmp = realloc(arr, new_size);
if (tmp == NULL)
    perror("realloc failed");
else
    arr = tmp;       // 메모리 크기 변경 성공시에만 포인터 갱신
  1. realloc(NULL, size) 는 malloc(size)와 동일하다
  2. realloc(ptr, 0)은 free(ptr)과 동일하다. (구현체 따라 다를 수 있다)

Calloc

malloc과 동일하지만, 확보한 메모리의 모든 바이트를 0으로 초기화한다.

작동 방식

void *calloc(size_t num, size_t size);
int *arr = (int *)calloc(10, sizeof(int));  // int 10개 공간 확보 + 모두 0으로 초기화
  • sizeof vs size_t 차이
    • C에서 타입이나 변수의 크기(바이트)를 계산해주는 연산자
    • sizeof(int) ⇒ 4바이트
    • size_t
    • unsigned 정수형 타입 (보통 unsigned int 또는 unsigned long)
    • 메모리 크기나 배열 인덱스용으로 쓰는 표준 타입
    • malloc(size_t size), calloc(size_t num, size_t size) → 함수의 인자로 크기를 받기 위해 쓰임
  • sizeof

✅ calloc의 과정

  1. 내부 힙 관리자에서 요청한 메모리 크기를 확보하고
  2. 확보된 블록을 memset(0) 또는 내부 루프를 통해 0으로 초기화한다
  3. 사용자에게 포인터 반환한다

언제 calloc을 쓰는가?

  • 배열을 만들고 모든 값이 0이어야 할 때
  • 구조체를 생성하고 모든 필드를 0으로 초기화하고 시작하고 싶을때
  • 포인터, 플래그, 카운터 등 기본값이 0일때 논리적으로 깔끔한 초기화가 필요할때

정말 Calloc이 Malloc보다 더 빠를까?

✅ 목표

  • 같은 크기의 메모리를 각각 calloc() 과 malloc+memset() 으로 할당 및 초기화
  • 소요 시간을 측정

✅ 코드

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

#define SIZE 100000000  // int 1억 개 ≈ 약 400MB

int main() {
    int *arr;
    clock_t start, end;
    double time_calloc, time_malloc_memset;

    // calloc + 실제 메모리 접근
    start = clock();
    arr = (int *)calloc(SIZE, sizeof(int));
    for (int i = 0; i < SIZE; i++) arr[i] += 1;  // 메모리 실접근
    end = clock();
    time_calloc = (double)(end - start) / CLOCKS_PER_SEC;
    free(arr);

    // malloc + memset + 실제 메모리 접근
    start = clock();
    arr = (int *)malloc(SIZE * sizeof(int));
    memset(arr, 0, SIZE * sizeof(int));
    for (int i = 0; i < SIZE; i++) arr[i] += 1;  // 메모리 실접근
    end = clock();
    time_malloc_memset = (double)(end - start) / CLOCKS_PER_SEC;
    free(arr);

    // 결과 출력
    printf("calloc + access:          %.6f sec\n", time_calloc);
    printf("malloc + memset + access: %.6f sec\n", time_malloc_memset);

    return 0;
}

 

✅ 결과

calloc 이 malloc 보다 더 느리게 나온다! 왜일까?

 

✅ 차이의 원인

  1. calloc의 지연 초기화(lazy zeroing)가 깨졌다.
    • 즉 해당 메모리 주소를 읽고 → 수정해서 다시 쓴다
    • calloc()은 내부적으로 mmap()으로 제로 페이지(공통된 0 페이지)를 매핑해준다
    • 근데 그 페이지는 읽기는 가능하지만, 쓰려면 복사(copy-on-write)가 일어나야 한다
    • 그래서!
    • arr[i]에 접근하면서 OS는 “공통 페이지를 쓰면 안되니, 복사하고 너만 써”라 명령하고 실제 물리 메모리를 할당한다
      • 이 때, 지연 초기화가 깨지고 실제 초기화 비용이 발생한다
    • 즉, 지연 초기화는 메모리 접근 없이 단순 할당만 할때 유지된다.
  2. for (int i = 0; i < SIZE; i++) arr[i] += 1;
  3. malloc + memset 이 더 빠른 이유는?
    • memset()이 이미 접근해서 0을 써놨기 때문에
    • 이후 for 루프에서 += 1 할때는 페이지 폴트가 없고 바로 캐시에서 가져올 수 있다 (즉, 캐시가 따듯하다)
      • calloc 보다 더 연속적이고 효율적인 메모리 접근이 이루어진다.
    • warm cache : 이전에 메모리에 접근하며 CPU 캐시에 데이터를 올려두고, 다음 접근에는 메모리가 아니라 캐시에서 바로 데이터를 가져온다

✅ 결론

calloc 은 실제로 메모리를 “즉시 0으로 채우지 않는다”

  • 보통 calloc()이나 new int[1000]() 같은 호출은 0으로 채워진 메모리를 기대하지만
  • 근데 실제로 0, 0, 0, 0, ... 이렇게 다 쓰면 너무 느리다!
  • 그래서 OS는 그냥 이미 다 0인 페이지 (제로 페이지) 한 장을 만들어두고 가상 메모리에 매핑한다
  • 이걸 원하는 모든 프로세스에 읽기 전용으로 연결만 해줌 (page table에 등록만)

그래서 위 코드처럼 제로 페이지에 쓰기를 시도하면?

  • OS는 페이지 폴트(page fault)를 일으킨다
  • OS : “제로 페이지는 읽기 전용이기에 쓸 수 없어! 다른데로 가!”
  • 새로운 물리 메모리가 할당되고, 0을 채워서 준다. 이를 Copy-on-Write이라 한다.

Free

malloc, calloc, realloc 으로 할당한 메모리를 다시 OS에 반환하는 함수이다.

작동 방식

  1. malloc으로 받은 메모리는 힙에서 관리된다
  2. free() 호출 시
    • 메모리 블록은 힙의 free list에 등록된다
    • 나중에 다른 malloc은 이 블록을 재사용할 수 있게 된다
    • 대부분의 경우 메모리는 OS에 즉시 반환되지 않는다 (힙 내부 재활용)

즉, free는 메모리 회수 요청일뿐, 삭제는 아니다

주의사항

  1. Free한 메모리는 다시 사용하면 안된다
int *p = malloc(10 * sizeof(int));
free(p);
p[0] = 42;  // ❌ use-after-free → 정의되지 않은 동작(UB)
  1. free는 malloc/calloc/realloc으로 할당한 포인터만 받을 수 있다
int a; 
free(&a)   // ❌ wrong

int *a = malloc(10 * sizeof(int));
free(a)    // ⭕ correct

Dangling Pointer

이미 해제된 메모리를 가리키는 포인터

🔹 왜 생기나?

int *p = malloc(sizeof(int) * 10);
free(p);         // 메모리 해제
*p = 42;         // ❌ 댕글링 포인터 사용 → 정의되지 않은 동작 (UB)
  • p는 여전히 원래 메모리 주소를 기억하고 있지만, 그 주소는 이미 "내 것"이 아니야
  • 그걸 다시 접근하면 → 쓰레기값 or 충돌 or 보안 취약점

🔹 해결 방법

free(p);
p = NULL;   // 포인터를 초기화해서 더 이상 접근하지 않게 함

메모리 누수 (Memory Leak)

더 이상 접근할 수 없는 힙 메모리가 해제되지 않고 남아있는 것

🔹 언제 생기나?

  1. free()를 안 한 경우
int *p = malloc(100);
// free(p) 누락
  1. 포인터를 덮어써서 원래 주소를 잃어버린 경우
int *p = malloc(100);
p = malloc(200);   // 이전 100바이트 공간을 잃어버림

두 번째 malloc 호출이 이전 포인터를 덮어써서 기존 메모리 해제할 수단이 없어진다

🔹 왜 문제인가?

  • 프로그램 실행 중 쌓이면 시스템 메모리가 고갈된다
  • 메모리 누수가 많으면 프로그램이 느려지고, 심하면 다운될 수 있다.
  • 꼭! 프리하자!