Skip to Content
독학사독학사 2단계C프로그래밍16. 동적 메모리 할당: malloc/free와 문자열·배열

이번 문서의 목표: 이 파일을 다 읽으면 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.
구분malloccalloc
인자 개수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 0

a[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만큼 할당해야 한다.

마무리 복습

문제 14지선다
malloc 함수에 대한 설명으로 가장 적절한 것은?
문제 24지선다
calloc(4, sizeof(int))과 malloc(4 * sizeof(int))의 차이로 옳은 것은?
문제 34지선다
다음 중 realloc 사용 방식으로 가장 안전한 것은?
문제 44지선다
다음 코드를 실행했을 때 일어나는 문제로 가장 적절한 것은? int 포인터 p에 malloc으로 정수 하나를 할당하고 10을 대입한 뒤, free(p)를 호출하고 나서 다시 *p 값을 출력하는 코드다.
문제 54지선다
문자열을 동적 할당할 때 malloc(strlen(src) + 1)처럼 1을 더해서 할당하는 이유로 가장 적절한 것은?
문제 64지선다
메모리 누수(memory leak)에 대한 설명으로 옳지 않은 것은?
문제 74지선다
free(p) 직후 p = NULL;을 대입해 두는 습관이 예방하는 문제로 가장 적절한 것은?

참고 자료

Last updated on