이번 문서의 목표: 이 파일을 다 읽으면 malloc·calloc·realloc·free 네 함수의 시그니처와 반환값 규칙을 정확히 구분해 쓸 수 있고, 크기를 미리 알 수 없는 배열과 문자열을 힙(heap)에 만들고 해제할 수 있으며, “메모리 누수”와 “댕글링 포인터”가 왜 서로 다른 함정인지 코드만 보고 짚어낼 수 있다.
왜 정적 배열만으로는 부족한가
11편에서 배운 배열은 int arr[10];처럼 크기를 소스 코드 작성 시점(컴파일 타임, compile time)에 숫자로 못박아야 합니다. 그런데 실제 프로그램은 “사용자가 입력할 데이터가 몇 개일지” 실행해 보기 전에는 알 수 없는 경우가 훨씬 많습니다. 크기를 넉넉하게 100으로 잡아 두면 데이터가 3개뿐일 때 97칸이 낭비되고, 반대로 5로 잡아 두면 6번째 데이터가 들어올 때 배열이 넘칩니다(버퍼 오버플로, buffer overflow).
이 문제를 해결하는 방법이 동적 메모리 할당(dynamic memory allocation)입니다. “동적”(dynamic)이라는 말은 “실행 중에 그때그때 결정된다”는 뜻으로, 실행 도중에 필요한 만큼만 메모리를 요청해서 쓰고, 다 쓰면 돌려주는 방식입니다. 이 요청과 반납이 일어나는 메모리 영역이 05편에서 다룬 힙(heap)입니다.
쉽게 말하면: 정적 배열은 “미리 예약해 둔 고정 좌석 수의 식당”, 동적 메모리는 “손님이 올 때마다 필요한 만큼 테이블을 붙였다 뗐다 하는 식당”입니다. 손님 수를 미리 모를 때는 후자가 훨씬 효율적이지만, 테이블을 다 쓰고도 치우지 않으면(free를 안 하면) 식당 전체가 테이블로 가득 차 버립니다.
05편에서 힙은 “실행 도중 필요에 따라 늘어나거나 줄어드는 동적 메모리 공간”이라고 소개했습니다. 이 편에서는 그 힙에 실제로 메모리를 요청하고 반납하는 표준 라이브러리 함수 네 개를 다룹니다. 이 함수들은 모두 <stdlib.h> 헤더에 선언되어 있습니다.
1. malloc — 힙에서 메모리 한 덩어리 빌려오기
void *malloc(size_t size);- 인자
size: 몇 바이트(byte)를 빌릴지 지정합니다. 타입은size_t인데, 이는<stdlib.h>에 정의된 부호 없는(unsigned) 정수 타입으로 “크기”나 “개수”처럼 음수가 될 수 없는 값을 담는 데 씁니다. - 반환값: 할당에 성공하면 그 메모리 블록의 시작 주소를
void*(어떤 타입도 가리킬 수 있는 범용 포인터) 형태로 돌려줍니다. 실패하면 널 포인터(NULL)를 반환합니다. 힙 공간이 부족하거나 시스템 자원이 없을 때 실패할 수 있으므로, 시험형 문제에서는 항상malloc의 반환값을NULL과 비교해 확인하는 코드가 정답 패턴으로 나옵니다. void*를 실제로 쓰려면 원하는 타입의 포인터로 형 변환(type casting, 07편)을 해 주는 것이 관례입니다. 예:int *p = (int *)malloc(5 * sizeof(int));
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = (int *)malloc(5 * sizeof(int));
if (p == NULL) {
printf("메모리 할당 실패\n");
return 1;
}
for (int i = 0; i < 5; i++) {
p[i] = i * 10;
}
for (int i = 0; i < 5; i++) {
printf("%d ", p[i]);
}
printf("\n");
free(p);
return 0;
}0 10 20 30 40한 줄씩 실행 추적
| 줄 | 실행 내용 | p가 가리키는 곳 | 힙의 상태 |
|---|---|---|---|
malloc(5 * sizeof(int)) | int 5개 크기(보통 20바이트)를 힙에서 요청 | 힙의 새 블록 시작 주소 | 20바이트 블록이 “사용 중”으로 표시됨 |
if (p == NULL) | 할당 성공 시 이 조건은 거짓이라 건너뜀 | 그대로 | 그대로 |
p[i] = i * 10; (5회) | p[0]–p[4]에 0, 10, 20, 30, 40 저장 | 그대로 | 값이 채워짐 |
printf 반복문 | 배열처럼 인덱싱해 값을 순서대로 출력 | 그대로 | 그대로 |
free(p); | 힙 블록을 운영체제(시스템)에 반납 | p는 여전히 옛 주소를 담고 있지만 그 주소는 더 이상 유효하지 않음 | 블록이 “사용 가능”으로 반환됨 |
p[i]처럼 malloc이 돌려준 포인터를 배열처럼 대괄호로 접근할 수 있는 이유는 12편에서 배운 배열 이름과 포인터의 관계와 정확히 같은 원리입니다. p[i]는 컴파일러 내부에서 *(p + i)로 바뀌므로, p가 배열의 첫 주소든 malloc이 돌려준 힙 주소든 상관없이 똑같이 동작합니다. 다만 p는 배열 이름이 아니라 변수이므로, 12편에서 배운 대로 p에 다른 주소를 다시 대입할 수 있다는 차이는 여전히 남아 있습니다.
자주 틀리는 점: malloc(5)라고 쓰면 정수 5개가 아니라 5바이트를 요청한 것입니다. int가 4바이트인 환경에서 정수 5개를 담으려면 malloc(5 * sizeof(int))처럼 반드시 sizeof로 타입 크기를 곱해야 합니다. sizeof(int)처럼 타입의 바이트 크기를 직접 구하는 것이 이식성(portability, 다른 컴퓨터·컴파일러에서도 잘 동작하는 성질) 있는 코드입니다.
2. calloc — 초기화까지 해 주는 malloc
void *calloc(size_t nmemb, size_t size);- 인자가 두 개입니다.
nmemb(number of members, 원소 개수)와size(원소 하나의 크기)를 곱한 만큼 메모리를 할당합니다. malloc과 결정적으로 다른 점: 할당한 메모리를 전부 0으로 초기화해서 돌려줍니다.malloc은 초기화를 하지 않으므로, 할당 직후 그 메모리에는 이전에 다른 용도로 쓰였던 값(쓰레기 값, garbage value)이 그대로 남아 있을 수 있습니다.- 반환값 규칙은
malloc과 같습니다. 성공하면void*, 실패하면NULL.
| 구분 | malloc | calloc |
|---|---|---|
| 인자 개수 | 1개(총 바이트 수) | 2개(원소 개수, 원소 크기) |
| 초기화 | 하지 않음(쓰레기 값 남을 수 있음) | 0으로 초기화 |
| 호출 예시 | malloc(5 * sizeof(int)) | calloc(5, sizeof(int)) |
| 반환값 | 성공 시 주소, 실패 시 NULL | 성공 시 주소, 실패 시 NULL |
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *a = (int *)malloc(3 * sizeof(int));
int *b = (int *)calloc(3, sizeof(int));
printf("%d\n", b[0]);
printf("%d\n", b[1]);
free(a);
free(b);
return 0;
}0
0a[0], a[1]은 malloc이 초기화를 보장하지 않으므로 출력에 넣지 않았습니다. 시험 문제에서 “이 코드의 출력은 무엇인가”를 물을 때 malloc으로 할당한 배열의 초기값을 단정적으로 묻는다면, 그 문제 자체가 “정해진 값이 없다(정의되지 않은 동작, undefined behavior)“는 것을 이해하는지 확인하려는 함정일 가능성이 큽니다. 반면 calloc의 초기값은 항상 0이므로 확정적으로 답할 수 있습니다.
3. realloc — 이미 할당된 블록의 크기 조정
void *realloc(void *ptr, size_t size);ptr: 이전에malloc/calloc/realloc으로 할당했던 블록의 주소.size: 새로 원하는 전체 바이트 크기.- 동작: 기존 블록을 새 크기로 늘리거나 줄입니다. 이때 기존에 저장되어 있던 데이터는 새 크기 범위 안에서 그대로 유지됩니다. 크기를 늘렸다면 새로 늘어난 부분은
calloc처럼 초기화되지 않고 쓰레기 값입니다. - 반환값: 성공하면 (새로 옮겨졌을 수도 있는) 블록의 주소. 실패하면 NULL을 반환하고, 원래 블록은 그대로 남아 있습니다.
시험에서 가장 자주 나오는 함정은 바로 이 실패 처리 방식입니다.
❌ p = realloc(p, new_size);이렇게 원래 포인터 p에 곧바로 realloc의 결과를 덮어써 버리면, realloc이 실패해 NULL을 반환했을 때 p에는 NULL이 들어가 버립니다. 이 순간 원래 블록의 주소를 완전히 잃어버려서, 그 블록을 free할 방법이 사라지고 메모리 누수(뒤에서 다룸)가 확정됩니다.
✅ int *tmp = realloc(p, new_size);
if (tmp == NULL) {
// 할당 실패 처리. p는 여전히 원래 블록을 가리키므로 free(p)로 정리 가능
} else {
p = tmp;
}이렇게 임시 포인터에 먼저 받아서 NULL 여부를 확인한 뒤에만 원래 포인터에 대입하는 것이 안전한 패턴입니다. 독학사 시험에서 “realloc 사용에서 잘못된 부분을 고르시오”라는 문항이 나온다면 이 패턴을 정확히 아는지가 핵심입니다.
4. free — 빌린 메모리 반납하기
void free(void *ptr);malloc/calloc/realloc으로 할당받은 메모리를 시스템에 돌려줍니다. 반환값은 없습니다(void).free할 수 있는 대상은 오직 저 세 함수가 돌려준 주소뿐입니다. 배열 이름이나 지역 변수의 주소를free하면 정의되지 않은 동작이 됩니다.free(NULL)은 아무 일도 하지 않는 안전한 호출입니다(표준에서 보장). 하지만 이미 한 번 free한 포인터를 또 free(이중 해제, double free)하는 것은 위험한 오류입니다.
5. 힙 메모리를 둘러싼 두 가지 함정
메모리 누수(memory leak): malloc으로 할당한 블록의 주소를 free하지 않고 잃어버리는 것입니다. 프로그램이 오래 실행될수록 힙에서 회수되지 않는 블록이 계속 쌓여 결국 메모리가 고갈될 수 있습니다.
void leak(void) {
int *p = (int *)malloc(sizeof(int) * 100);
// free(p)를 호출하지 않고 함수가 끝난다.
}이 함수가 끝나면 지역 변수 p(스택에 있던 포인터 변수 자체)는 사라지지만, p가 가리키던 힙의 100칸 블록은 사라지지 않습니다. 그 블록의 주소를 알고 있던 유일한 변수가 사라졌으므로, 이제 그 블록은 free할 방법이 없는 채로 힙에 영원히 남습니다. 이것이 메모리 누수입니다.
댕글링 포인터(dangling pointer): free로 이미 반납한 블록을, 그 블록을 가리키던 포인터를 통해 계속 사용하는 것입니다.
int *p = (int *)malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p); // 위험: 이미 반납된 메모리를 읽음free(p)를 호출한 순간 그 메모리 블록은 “사용 가능”으로 시스템에 반환되었을 뿐, p라는 변수 자체가 자동으로 NULL이 되는 것은 아닙니다. 즉 p는 여전히 예전 주소값을 그대로 들고 있지만, 그 주소가 가리키는 메모리는 더 이상 내 것이 아닙니다. 이렇게 “이미 반납된 곳을 여전히 가리키고 있는 포인터”를 댕글링 포인터라고 부릅니다. 댕글링 포인터를 역참조(*p)하면 우연히 이전 값이 그대로 남아 있어 정상처럼 보일 수도 있지만, 다른 코드가 그 사이 같은 자리를 다시 할당받아 값을 덮어썼다면 완전히 엉뚱한 값을 읽게 됩니다.
해결 관례: free(p); 직후에 p = NULL;을 대입해 두는 습관입니다. 이렇게 하면 이후 실수로 *p에 접근해도, NULL을 가리키는 포인터를 역참조하려다 즉시 프로그램이 비정상 종료(세그멘테이션 오류, segmentation fault)되어 오류를 바로 발견할 수 있습니다. 반대로 댕글링 포인터 상태로 방치하면 오류가 한참 뒤에야, 그것도 실행할 때마다 다르게 나타나 디버깅이 훨씬 어렵습니다.
| 구분 | 메모리 누수(memory leak) | 댕글링 포인터(dangling pointer) |
|---|---|---|
| 원인 | free를 아예 호출하지 않음 | free한 블록을 계속 사용함 |
| 결과 | 힙 공간이 회수되지 않고 계속 낭비됨 | 반납된 메모리를 읽거나 씀(정의되지 않은 동작) |
| 발견 시점 | 프로그램이 오래 실행되며 서서히 드러남 | 접근하는 순간 즉시 문제가 될 수 있음(운이 나쁘면 늦게 드러남) |
| 예방책 | 할당한 블록마다 반드시 대응하는 free 작성 | free 직후 포인터를 NULL로 초기화 |
6. 동적 배열 — 실행 중에 크기를 정하는 배열
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n;
printf("정수 개수 입력: ");
scanf("%d", &n);
int *arr = (int *)malloc(n * sizeof(int));
if (arr == NULL) {
return 1;
}
for (int i = 0; i < n; i++) {
arr[i] = (i + 1) * (i + 1);
}
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
free(arr);
return 0;
}사용자가 3을 입력했다고 가정하면 출력은 다음과 같습니다.
1 4 9이 코드가 시험에서 중요한 이유는, int arr[n];처럼 변수를 배열 크기로 바로 쓰는 문법(가변 길이 배열, VLA)에 의존하지 않고도, malloc을 쓰면 실행 중 입력받은 n으로 정확히 그 크기만큼의 배열을 힙에 만들 수 있다는 점을 보여주기 때문입니다. 독학사 출제기준은 표준 C 문법을 기준으로 하므로, “크기를 실행 중에 정하는 배열”을 만드는 정석은 항상 malloc 계열 함수입니다.
7. 동적 문자열 — 필요한 만큼만 할당하기
19편에서 자세히 다룰 문자열 함수를 미리 조금 빌려서, 문자열을 위한 동적 할당 패턴을 봅니다. 문자열은 항상 마지막에 널 문자(\0)가 붙어야 하므로(11편), 저장할 글자 수보다 1바이트 더 할당해야 합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
const char *src = "Hello";
char *copy = (char *)malloc(strlen(src) + 1);
if (copy == NULL) {
return 1;
}
strcpy(copy, src);
printf("%s\n", copy);
printf("%zu\n", strlen(copy));
free(copy);
return 0;
}Hello
5한 줄씩 실행 추적
| 줄 | 실행 내용 | 값·상태 |
|---|---|---|
strlen(src) | "Hello"의 글자 수를 셈(널 문자 제외) | 5 |
malloc(strlen(src) + 1) | 5글자 + 널 문자 1개 = 6바이트 요청 | copy → 힙의 6바이트 블록 |
strcpy(copy, src) | src의 내용을 copy로 복사(널 문자까지 포함) | copy = “Hello\0” |
printf("%s\n", copy) | 널 문자를 만날 때까지 출력 | Hello |
printf("%zu\n", strlen(copy)) | 다시 글자 수를 셈 | 5 |
자주 틀리는 점: malloc(strlen(src))처럼 + 1을 빠뜨리면, strcpy가 널 문자까지 복사하려는 순간 할당받은 블록의 범위를 1바이트 벗어나 씁니다. 이는 버퍼 오버플로의 전형적인 예로, 당장 눈에 보이는 오류 없이 실행되다가 다른 데이터가 깨지는 형태로 문제가 드러날 수 있습니다.
8. 오류 유형 한눈에 정리
| 오류 유형 | 원인 코드 예 | 왜 문제인가 |
|---|---|---|
| 반환값 미확인 | int *p = malloc(...); p[0] = 1; (NULL 검사 없음) | 할당 실패 시 NULL을 역참조해 프로그램이 비정상 종료될 수 있음 |
| 크기 계산 오류 | malloc(n) (sizeof 없이 개수만 씀) | 원소 1개가 아니라 1바이트만 할당됨 |
| realloc 결과 직접 덮어쓰기 | p = realloc(p, n); | 실패 시 원래 블록 주소를 잃어 메모리 누수 확정 |
| free 누락 | 할당만 하고 함수 종료 | 메모리 누수 |
| free 후 사용 | free(p); *p = 1; | 댕글링 포인터, 정의되지 않은 동작 |
| 이중 해제 | free(p); free(p); | 이중 해제 오류 |
| 문자열 할당 크기 부족 | malloc(strlen(s)) | 널 문자 자리가 빠져 버퍼 오버플로 |
핵심 정리
malloc(size)은 지정한 바이트 수만큼 힙에서 할당하고 초기화하지 않으며,calloc(n, size)은 원소 개수와 크기를 따로 받아 0으로 초기화한다. 둘 다 실패 시NULL을 반환한다.realloc(ptr, size)은 기존 데이터를 유지하며 크기를 조정하되, 실패 시 원래 블록은 그대로 남고NULL을 반환하므로 반드시 임시 포인터로 먼저 받아 확인해야 한다.free(ptr)은 세 할당 함수가 돌려준 주소만 반납할 수 있으며, 반환값이 없다.- 메모리 누수는
free를 호출하지 않아 힙 블록을 잃어버리는 것이고, 댕글링 포인터는free한 블록을 포인터로 계속 사용하는 것이다.free직후 포인터를NULL로 두는 습관이 댕글링 포인터를 예방한다. - 문자열을 동적 할당할 때는 널 문자를 위해
strlen(s) + 1만큼 할당해야 한다.