크기를 미리 정할 수 없다면
지금까지 배열을 만들 때는 대부분 크기를 미리 정했습니다.
int scores[5];이 코드는 정수 5개를 저장할 수 있는 배열을 만듭니다. 단순하고 빠르며 이해하기 쉽습니다. 문제는 현실의 프로그램이 항상 이렇게 얌전하지 않다는 데 있습니다.
학생 점수를 저장하는 프로그램을 만든다고 해 봅시다. 사용자가 5명만 입력할 수도 있고, 30명을 입력할 수도 있고, 파일에서 10만 명의 데이터를 읽어 올 수도 있습니다. 그런데 프로그램을 작성할 때 배열 크기를 5로 정해 버리면 6번째 학생부터는 저장할 곳이 없습니다.
int scores[5];
/* 6명 이상 입력되면? */그렇다고 처음부터 아주 크게 잡으면 어떨까요?
int scores[1000000];이번에는 반대 문제가 생깁니다. 실제로 10명만 저장해도 100만 개짜리 공간을 준비합니다. 낭비가 심합니다. 게다가 이렇게 큰 배열을 지역 변수로 만들면 스택 메모리를 과하게 사용해서 프로그램이 불안정해질 수도 있습니다.
정적 배열은 선언할 때 크기가 정해지는 배열입니다. 간단한 프로그램에서는 충분히 좋지만, 실행 중에 필요한 크기가 달라지는 상황에서는 한계가 분명합니다.
이때 필요한 도구가 동적 메모리 할당입니다. 말 그대로 프로그램이 실행되는 도중에 필요한 만큼 메모리를 요청하는 방식입니다. 이제 우리는 컴퓨터에게 이렇게 말할 수 있습니다.
"지금 정수 30개를 저장할 공간이 필요해. 힙에서 빌려줘."
그리고 다 썼다면 이렇게 말해야 합니다.
"이제 다 썼어. 돌려줄게."
C에서 이 요청과 반납을 담당하는 대표 함수가 malloc, calloc, realloc, free입니다.

힙 메모리를 직접 빌리는 일
앞 장에서 프로그램의 메모리 영역을 살펴보았습니다. 지역 변수는 주로 스택에 놓이고, 전역 변수와 정적 변수는 데이터 영역에 놓입니다. 동적 메모리 할당에서 사용하는 공간은 힙입니다.
스택은 함수 호출과 함께 자동으로 정리됩니다. 함수가 끝나면 그 안의 지역 변수는 사라집니다. 반면 힙에 요청한 메모리는 자동으로 사라지지 않습니다. 개발자가 직접 해제해야 합니다.
이 차이가 중요합니다.
void func(void)
{
int x = 10;
}x는 func 함수가 끝나면 자동으로 사라집니다. 하지만 malloc으로 얻은 메모리는 다릅니다.
void func(void)
{
int *p = malloc(sizeof(int));
}여기서 포인터 변수 p 자체는 지역 변수이므로 함수가 끝나면 사라집니다. 하지만 malloc이 힙에 확보한 공간은 자동으로 해제되지 않습니다. p가 사라지면 그 공간의 주소도 잃어버립니다. 그러면 프로그램은 그 메모리를 더 이상 사용할 수도, 돌려줄 수도 없게 됩니다. 이것이 뒤에서 다룰 메모리 누수입니다.
동적 메모리는 강력합니다. 필요한 만큼 빌릴 수 있고, 실행 중 크기를 조절할 수도 있습니다. 하지만 그만큼 책임도 따라옵니다. C는 "네가 직접 관리하겠다면 믿고 맡길게"라고 말하는 언어입니다.
malloc: 필요한 바이트 수만큼 요청하기
malloc은 동적 메모리 할당에서 가장 먼저 만나는 함수입니다. 이름은 memory allocation, 즉 메모리 할당에서 왔습니다.
malloc을 사용하려면 <stdlib.h> 헤더가 필요합니다.
#include <stdlib.h>기본 형태는 다음과 같습니다.
void *malloc(size_t size);처음 보면 조금 낯섭니다. 핵심은 간단합니다. malloc은 size 바이트만큼 힙 메모리를 요청하고, 성공하면 그 공간의 시작 주소를 돌려줍니다. 실패하면 NULL을 돌려줍니다.
정수 하나를 저장할 공간을 동적으로 확보해 봅시다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *p = malloc(sizeof(int));
if (p == NULL) {
printf("메모리 할당 실패n");
return 1;
}
*p = 10;
printf("값: %dn", *p);
free(p);
p = NULL;
return 0;
}한 줄씩 보겠습니다.
int *p = malloc(sizeof(int));sizeof(int)는 int 하나가 차지하는 바이트 수를 구합니다. 보통 4바이트인 환경이 많지만, C에서는 자료형의 크기를 외워서 쓰기보다 sizeof로 구하는 습관이 좋습니다.
malloc(sizeof(int))는 "정수 하나를 저장할 만큼의 공간을 힙에 주세요"라는 뜻입니다. malloc은 그 공간의 시작 주소를 반환합니다. 우리는 그 주소를 int * 포인터 변수 p에 저장합니다.
그다음은 매우 중요합니다.
if (p == NULL) {
printf("메모리 할당 실패n");
return 1;
}메모리 요청은 실패할 수 있습니다. 컴퓨터의 메모리가 부족할 수도 있고, 너무 큰 공간을 요청했을 수도 있습니다. malloc이 실패하면 NULL을 반환합니다. NULL은 "가리키는 곳이 없음"을 나타내는 특별한 포인터 값입니다.
따라서 malloc 뒤에는 반드시 NULL 검사를 하는 습관을 들이는 것이 좋습니다.
공간 확보에 성공했다면 이제 포인터를 통해 값을 저장할 수 있습니다.
*p = 10;p는 주소이고, *p는 그 주소에 있는 실제 공간입니다. 즉 *p = 10;은 힙에 빌린 정수 공간에 10을 저장한다는 뜻입니다.
다 쓴 뒤에는 반드시 반납합니다.
free(p);
p = NULL;free(p)는 p가 가리키는 힙 메모리를 해제합니다. 그리고 p = NULL;은 포인터가 더 이상 해제된 공간을 가리키지 않도록 정리하는 습관입니다.
배열도 실행 중에 만들 수 있다
동적 메모리가 빛나는 순간은 배열을 만들 때입니다. 사용자에게 몇 개의 점수를 입력할지 물어보고, 그 수만큼만 공간을 확보해 보겠습니다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int count;
printf("학생 수를 입력하세요: ");
scanf("%d", &count);
if (count <= 0) {
printf("학생 수는 1 이상이어야 합니다.n");
return 1;
}
int *scores = malloc(sizeof(int) * count);
if (scores == NULL) {
printf("메모리 할당 실패n");
return 1;
}
for (int i = 0; i < count; i++) {
printf("%d번째 점수: ", i + 1);
scanf("%d", &scores[i]);
}
printf("입력한 점수:n");
for (int i = 0; i < count; i++) {
printf("%d ", scores[i]);
}
printf("n");
free(scores);
scores = NULL;
return 0;
}이제 배열 크기는 코드에 고정되어 있지 않습니다. 사용자가 3을 입력하면 정수 3개짜리 공간을 만들고, 100을 입력하면 정수 100개짜리 공간을 만듭니다.
핵심은 이 줄입니다.
int *scores = malloc(sizeof(int) * count);int 하나의 크기에 count를 곱했습니다. 예를 들어 int가 4바이트이고 count가 10이라면 40바이트를 요청합니다. malloc은 그 공간의 시작 주소를 반환하고, scores는 그 주소를 저장합니다.
여기서 흥미로운 점이 있습니다.
scores[i]동적 할당으로 얻은 포인터인데도 배열처럼 사용할 수 있습니다. 왜 그럴까요? 앞에서 배운 포인터와 배열의 관계를 떠올려 봅시다. scores[i]는 내부적으로 *(scores + i)와 같은 방식으로 동작합니다. 즉 시작 주소에서 i번째 요소 위치로 이동한 뒤 그 값을 읽거나 쓰는 것입니다.
동적 배열은 진짜 배열이라기보다 "배열처럼 사용할 수 있는 연속된 메모리 공간"이라고 이해하면 좋습니다.
malloc이 초기화해 주지는 않는다
malloc으로 받은 메모리에는 어떤 값이 들어 있을까요? 답은 "모른다"입니다.
int *arr = malloc(sizeof(int) * 5);이렇게 정수 5개 공간을 얻었다고 해서 자동으로 0이 들어가는 것은 아닙니다. 그 공간에는 이전에 다른 용도로 사용되던 흔적이 남아 있을 수 있습니다. 이런 값을 흔히 쓰레기값이라고 부릅니다.
다음 코드는 위험합니다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *arr = malloc(sizeof(int) * 5);
if (arr == NULL) {
return 1;
}
for (int i = 0; i < 5; i++) {
printf("%dn", arr[i]);
}
free(arr);
return 0;
}출력값이 0일 수도 있고 이상한 숫자일 수도 있습니다. 중요한 것은 그 값을 믿으면 안 된다는 점입니다. malloc으로 확보한 메모리는 사용하기 전에 직접 초기화하는 것이 안전합니다.
for (int i = 0; i < 5; i++) {
arr[i] = 0;
}또는 처음부터 0으로 초기화된 메모리가 필요하다면 calloc을 사용할 수 있습니다.
calloc: 개수와 크기를 받아 0으로 초기화하기
calloc은 malloc과 비슷하지만 두 가지 차이가 있습니다. 첫째, 전체 바이트 수를 한 번에 받는 대신 "요소 개수"와 "요소 하나의 크기"를 따로 받습니다. 둘째, 확보한 메모리를 0으로 초기화합니다.
기본 형태는 다음과 같습니다.
void *calloc(size_t count, size_t size);정수 5개짜리 공간을 0으로 초기화해서 만들려면 이렇게 씁니다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *arr = calloc(5, sizeof(int));
if (arr == NULL) {
printf("메모리 할당 실패n");
return 1;
}
for (int i = 0; i < 5; i++) {
printf("%dn", arr[i]);
}
free(arr);
arr = NULL;
return 0;
}calloc(5, sizeof(int))는 "int 크기의 요소 5개를 저장할 공간을 주세요. 그리고 0으로 채워 주세요"라는 요청입니다.
그렇다면 언제 malloc을 쓰고 언제 calloc을 쓸까요? 무조건 하나만 쓰는 것이 아니라 상황에 따라 선택합니다.
바로 값을 모두 채워 넣을 예정이라면
malloc이 자연스럽습니다.처음 값이 0이어야 안전한 배열이라면
calloc이 편리합니다.요소 개수와 요소 크기를 나누어 표현하고 싶을 때도
calloc이 읽기 좋습니다.
다만 calloc이 모든 문제를 해결해 주는 것은 아닙니다. 0으로 초기화된다는 말은 메모리의 모든 비트가 0으로 채워진다는 뜻입니다. 정수 배열에서는 보통 기대한 대로 0이 됩니다. 포인터 배열도 보통 NULL 포인터로 초기화되는 환경을 기대할 수 있지만, C를 깊게 다루다 보면 표현 방식에 대한 세부 규칙이 중요해지는 순간이 있습니다. 지금 단계에서는 "숫자 배열을 0으로 시작하고 싶다면 calloc이 편리하다" 정도로 이해하면 충분합니다.
realloc: 이미 받은 공간의 크기 바꾸기
프로그램을 만들다 보면 처음에는 작은 공간만 필요했지만, 실행 중에 더 큰 공간이 필요해지는 경우가 많습니다. 예를 들어 사용자가 점수를 계속 입력한다고 합시다. 처음에는 4개만 저장할 공간을 준비했는데, 사용자가 5번째 점수를 입력하려고 합니다. 그러면 공간을 늘려야 합니다.
이때 사용하는 함수가 realloc입니다. 이름은 re-allocation, 즉 다시 할당한다는 뜻입니다.
기본 형태는 다음과 같습니다.
void *realloc(void *ptr, size_t new_size);ptr은 기존에 할당받은 메모리의 주소이고, new_size는 새로 필요한 바이트 수입니다. realloc은 가능한 경우 기존 공간을 확장하거나 줄입니다. 만약 기존 위치에서 확장할 수 없다면 다른 곳에 새 공간을 만들고, 기존 데이터를 새 공간으로 복사한 뒤 새 주소를 반환할 수 있습니다.
중요한 점은 realloc 후 주소가 바뀔 수 있다는 것입니다.
int *new_arr = realloc(arr, sizeof(int) * new_count);반환된 주소를 반드시 새로 받아야 합니다.
그런데 다음 코드는 조심해야 합니다.
arr = realloc(arr, sizeof(int) * new_count);겉으로는 간단해 보이지만 문제가 있습니다. 만약 realloc이 실패하면 NULL을 반환합니다. 그러면 arr에는 NULL이 들어가고, 원래 메모리의 주소를 잃어버립니다. 아직 해제하지 않은 메모리의 주소를 잃어버렸으므로 메모리 누수가 발생합니다.
그래서 안전한 방식은 임시 포인터를 사용하는 것입니다.
int *temp = realloc(arr, sizeof(int) * new_count);
if (temp == NULL) {
printf("메모리 재할당 실패n");
free(arr);
return 1;
}
arr = temp;이렇게 하면 realloc이 실패해도 기존 arr은 그대로 살아 있습니다. 실패했을 때 기존 데이터를 계속 사용할 수도 있고, 안전하게 free(arr)로 정리할 수도 있습니다.
동적 배열 크기 늘리기
이제 실제로 크기가 늘어나는 동적 배열을 만들어 보겠습니다. 사용자가 -1을 입력할 때까지 점수를 계속 입력받고, 공간이 부족하면 배열 크기를 두 배로 늘리는 프로그램입니다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int capacity = 4;
int count = 0;
int *scores = malloc(sizeof(int) * capacity);
if (scores == NULL) {
printf("메모리 할당 실패n");
return 1;
}
while (1) {
int score;
printf("점수 입력(-1 종료): ");
scanf("%d", &score);
if (score == -1) {
break;
}
if (count == capacity) {
int new_capacity = capacity * 2;
int *temp = realloc(scores, sizeof(int) * new_capacity);
if (temp == NULL) {
printf("메모리 재할당 실패n");
free(scores);
return 1;
}
scores = temp;
capacity = new_capacity;
}
scores[count] = score;
count++;
}
printf("n입력된 점수 목록:n");
for (int i = 0; i < count; i++) {
printf("%d ", scores[i]);
}
printf("n");
printf("사용한 칸: %d, 확보한 칸: %dn", count, capacity);
free(scores);
scores = NULL;
return 0;
}이 프로그램에는 동적 배열의 핵심 감각이 들어 있습니다.
capacity는 현재 확보한 칸 수입니다. 처음에는 4칸입니다. count는 실제로 사용 중인 칸 수입니다. 사용자가 점수를 입력할 때마다 scores[count]에 저장하고 count를 1 증가시킵니다.
문제는 count == capacity가 되는 순간입니다. 이 말은 확보한 칸을 모두 사용했다는 뜻입니다. 그러면 새 용량을 두 배로 늘립니다.
int new_capacity = capacity * 2;그리고 realloc으로 더 큰 공간을 요청합니다.
int *temp = realloc(scores, sizeof(int) * new_capacity);성공하면 scores가 새 공간을 가리키게 하고, capacity도 새 값으로 갱신합니다.
이 방식은 실제 프로그램에서도 자주 쓰이는 전략입니다. 입력이 들어올 때마다 1칸씩 늘리면 재할당이 너무 자주 발생할 수 있습니다. 그래서 보통 공간이 꽉 찼을 때 두 배씩 늘리는 방식을 사용합니다. 조금 여유 공간이 생기지만, 전체적으로는 훨씬 효율적입니다.
여기서 중요한 구분이 있습니다. count는 "실제로 들어 있는 데이터 개수"이고, capacity는 "현재 담을 수 있는 최대 개수"입니다. 이 둘을 섞으면 버그가 생깁니다. 출력할 때는 capacity가 아니라 count만큼만 출력해야 합니다. 아직 사용하지 않은 칸에는 의미 있는 값이 없을 수 있기 때문입니다.
free: 빌린 메모리는 반드시 돌려주기
동적 메모리 할당에서 가장 중요한 규칙은 단순합니다.
빌렸으면 돌려준다.
malloc, calloc, realloc으로 확보한 메모리는 더 이상 필요 없을 때 free로 해제해야 합니다.
free(ptr);free는 포인터 변수 자체를 없애는 함수가 아닙니다. 포인터가 가리키는 힙 메모리를 반납하는 함수입니다.
int *p = malloc(sizeof(int));
if (p == NULL) {
return 1;
}
*p = 100;
free(p);이제 p가 가리키던 힙 공간은 더 이상 우리 것이 아닙니다. 하지만 포인터 변수 p에는 여전히 예전 주소값이 남아 있을 수 있습니다. 이 상태가 위험합니다.
그래서 해제 후에는 다음처럼 정리하는 습관이 좋습니다.
free(p);
p = NULL;NULL로 바꿔 두면 적어도 "이 포인터는 지금 아무것도 가리키지 않는다"는 상태가 분명해집니다. 또한 free(NULL);은 안전합니다. 아무 일도 하지 않습니다. 따라서 포인터를 NULL로 정리해 두면 중복 해제를 피하는 데도 도움이 됩니다.
하지만 주의할 점도 있습니다. 같은 메모리를 가리키는 포인터가 여러 개라면 하나만 NULL로 바꾼다고 모두 안전해지는 것은 아닙니다.
int *p = malloc(sizeof(int));
int *q = p;
free(p);
p = NULL;
/* q는 여전히 해제된 공간의 예전 주소를 가지고 있다 */q는 아직 해제된 공간을 가리키는 위험한 포인터입니다. 이런 상황을 만들지 않는 것이 가장 좋고, 필요하다면 소유권을 명확히 해야 합니다. 소유권이란 "누가 이 메모리를 마지막에 해제할 책임이 있는가"에 대한 약속입니다.
메모리 누수: 반납하지 않고 잃어버린 공간
메모리 누수는 동적 메모리를 할당해 놓고 해제하지 않는 문제입니다. 수도꼭지를 조금 열어 둔 채 잊어버리면 물이 계속 새듯이, 프로그램도 메모리를 조금씩 잃어버릴 수 있습니다.
다음 코드를 봅시다.
#include <stdlib.h>
void leak(void)
{
int *p = malloc(sizeof(int) * 100);
if (p == NULL) {
return;
}
p[0] = 10;
}leak 함수 안에서 정수 100개짜리 공간을 할당했습니다. 하지만 free(p)가 없습니다. 함수가 끝나면 지역 변수 p는 사라집니다. 그런데 힙에 할당된 공간은 해제되지 않습니다. 더 큰 문제는 그 공간의 주소를 담고 있던 p가 사라졌기 때문에 이제 그 메모리를 해제할 방법도 없다는 점입니다.
작은 프로그램에서는 메모리 누수가 눈에 띄지 않을 수 있습니다. 프로그램이 금방 종료되면 운영체제가 전체 메모리를 회수하기 때문입니다. 하지만 오래 실행되는 서버, 게임, 임베디드 장치, 운영체제 수준 프로그램에서는 이야기가 달라집니다. 누수가 반복되면 프로그램이 점점 많은 메모리를 차지하다가 느려지거나 멈출 수 있습니다.
메모리 누수를 줄이는 가장 좋은 습관은 할당과 해제를 한 쌍으로 생각하는 것입니다.
int *arr = malloc(sizeof(int) * n);
if (arr == NULL) {
return 1;
}
/* arr 사용 */
free(arr);
arr = NULL;malloc을 쓰는 순간 마음속에 free 자리도 함께 만들어 두어야 합니다.
댕글링 포인터: 해제된 공간을 계속 가리키는 포인터
메모리 누수와 자주 함께 나오는 문제가 댕글링 포인터입니다. 댕글링 포인터는 이미 해제되었거나 더 이상 유효하지 않은 메모리 공간을 가리키는 포인터입니다.
다음 코드는 매우 위험합니다.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *p = malloc(sizeof(int));
if (p == NULL) {
return 1;
}
*p = 10;
free(p);
printf("%dn", *p); /* 위험 */
return 0;
}free(p) 이후 p가 가리키던 공간은 더 이상 사용할 수 없습니다. 그런데 printf에서 *p를 읽고 있습니다. 운이 좋으면 10이 출력될 수도 있습니다. 그래서 더 위험합니다. 겉으로는 멀쩡해 보이기 때문입니다. 하지만 그 메모리는 이미 반납된 공간입니다. 다른 용도로 재사용되었을 수도 있고, 접근하는 순간 프로그램이 비정상 종료될 수도 있습니다.
댕글링 포인터는 "가끔 잘 되는 것처럼 보이는 버그"를 만듭니다. C에서 가장 무서운 버그는 항상 바로 터지지 않는 버그입니다. 코드를 작성한 날에는 잘 돌아가다가, 다음 주에 입력이 조금 달라졌을 때 갑자기 무너질 수 있습니다.
해제 후 포인터를 NULL로 바꾸는 습관은 이런 위험을 줄여 줍니다.
free(p);
p = NULL;물론 p 말고 같은 주소를 가리키던 다른 포인터가 있다면 여전히 조심해야 합니다. 결국 핵심은 "해제한 뒤에는 사용하지 않는다"입니다.
메모리 누수와 댕글링 포인터 구분하기
두 문제는 비슷해 보이지만 방향이 다릅니다.
메모리 누수는 해제해야 할 메모리를 해제하지 못한 상태입니다. 메모리는 아직 할당되어 있지만, 프로그램이 그 주소를 잃어버렸거나 해제를 깜빡했습니다.
댕글링 포인터는 이미 해제된 메모리를 가리키는 포인터가 남아 있는 상태입니다. 메모리는 더 이상 유효하지 않은데, 포인터가 예전 주소를 붙잡고 있습니다.
간단히 말하면 이렇습니다.
메모리 누수: 메모리는 살아 있는데 주소를 잃어버렸거나 해제하지 않았다.
댕글링 포인터: 메모리는 죽었는데 포인터가 아직 살아 있는 척한다.
둘 다 프로그램을 불안정하게 만들며, 할당과 해제 규칙을 지키면 대부분 예방할 수 있다.
C를 배우는 초보자가 포인터에서 어려움을 느끼는 이유는 문법 때문만이 아닙니다. "이 주소가 지금 유효한가?", "이 메모리는 누가 해제해야 하는가?", "이 포인터는 아직 써도 되는가?" 같은 질문을 계속해야 하기 때문입니다. 이 질문들이 바로 시스템 프로그래밍의 감각입니다.
calloc과 realloc을 함께 사용할 때의 생각법
malloc, calloc, realloc은 모두 힙 메모리를 다루지만 역할이 조금씩 다릅니다.
malloc은 필요한 바이트 수만큼 공간을 확보합니다. 초기화는 하지 않습니다.
calloc은 요소 개수와 요소 크기를 받아 공간을 확보하고 0으로 초기화합니다.
realloc은 이미 확보한 공간의 크기를 바꿉니다. 크기를 늘릴 수도 있고 줄일 수도 있습니다.
예를 들어 처음에 0으로 채워진 정수 배열이 필요하다면 calloc이 좋습니다.
int *arr = calloc(count, sizeof(int));이후 데이터가 늘어나서 공간을 키워야 한다면 realloc을 사용할 수 있습니다.
int *temp = realloc(arr, sizeof(int) * new_count);
if (temp == NULL) {
free(arr);
return 1;
}
arr = temp;단, realloc으로 새로 늘어난 부분이 항상 0으로 초기화되는 것은 아닙니다. 예를 들어 정수 5개짜리 공간을 정수 10개짜리 공간으로 늘렸다면, 기존 5개의 값은 유지되지만 새로 생긴 5칸의 값은 초기화되어 있다고 믿으면 안 됩니다. 필요하다면 직접 초기화해야 합니다.
for (int i = old_count; i < new_count; i++) {
arr[i] = 0;
}이런 세부 사항이 C답습니다. C는 편리함보다 명확한 제어를 우선합니다. 대신 개발자는 그 제어에 따른 책임을 져야 합니다.
안전한 동적 메모리 사용 습관
동적 메모리는 처음에는 어렵게 느껴지지만, 규칙을 정해 놓고 반복하면 점점 몸에 익습니다. 다음 습관은 꼭 기억해 둡시다.
malloc,calloc,realloc을 사용하려면<stdlib.h>를 포함한다.할당 결과는 항상
NULL인지 검사한다.필요한 바이트 수는 직접 숫자로 쓰기보다
sizeof를 사용해 계산한다.할당한 메모리는 더 이상 필요 없을 때 반드시
free한다.free한 뒤에는 가능하면 포인터를NULL로 바꾼다.realloc결과는 임시 포인터에 먼저 저장한다.배열을 사용할 때는 현재 데이터 개수와 확보한 용량을 구분한다.
특히 sizeof를 쓸 때는 다음 형태가 자주 사용됩니다.
int *arr = malloc(sizeof(int) * count);또는 포인터가 가리키는 대상의 크기를 기준으로 이렇게 쓸 수도 있습니다.
int *arr = malloc(sizeof(*arr) * count);sizeof(*arr)는 arr이 가리키는 대상, 즉 int의 크기입니다. 이 방식은 나중에 자료형이 바뀌어도 실수를 줄일 수 있습니다.
long *arr = malloc(sizeof(*arr) * count);자료형을 int에서 long으로 바꿔도 sizeof(*arr)는 자동으로 long 크기를 계산합니다. C 코드가 길어질수록 이런 작은 습관이 버그를 줄여 줍니다.
예제: 동적 배열 함수로 정리하기
조금 더 프로그램답게 만들기 위해 동적 배열 확장 부분을 함수로 분리해 보겠습니다. 함수는 배열 포인터와 용량을 받아, 필요한 경우 더 큰 공간으로 바꿔 줍니다.
#include <stdio.h>
#include <stdlib.h>
int grow_array(int **arr, int *capacity)
{
int new_capacity = (*capacity) * 2;
int *temp = realloc(*arr, sizeof(int) * new_capacity);
if (temp == NULL) {
return 0;
}
*arr = temp;
*capacity = new_capacity;
return 1;
}
int main(void)
{
int capacity = 4;
int count = 0;
int *scores = malloc(sizeof(int) * capacity);
if (scores == NULL) {
printf("메모리 할당 실패n");
return 1;
}
while (1) {
int score;
printf("점수 입력(-1 종료): ");
scanf("%d", &score);
if (score == -1) {
break;
}
if (count == capacity) {
if (!grow_array(&scores, &capacity)) {
printf("배열 확장 실패n");
free(scores);
return 1;
}
}
scores[count] = score;
count++;
}
for (int i = 0; i < count; i++) {
printf("scores[%d] = %dn", i, scores[i]);
}
free(scores);
scores = NULL;
return 0;
}여기서 가장 낯선 부분은 int arr일 것입니다.
int grow_array(intarr, int *capacity)
왜 int *arr가 아니라 int arr일까요? 함수 안에서 scores 자체가 가리키는 주소를 바꿔야 하기 때문입니다.
앞에서 함수는 값을 복사해서 받는다고 배웠습니다. 만약 int *arr만 받으면 포인터 값이 복사됩니다. 함수 안에서 그 복사본을 바꿔도 main의 scores는 바뀌지 않습니다. 그래서 scores의 주소, 즉 포인터 변수의 주소를 넘깁니다.
grow_array(&scores, &capacity)scores는 int *입니다. &scores는 int 입니다. 처음에는 복잡해 보이지만, 원리는 같습니다. 값을 바꾸고 싶으면 그 값의 주소를 넘깁니다. capacity를 바꾸고 싶어서 &capacity를 넘겼듯이, scores라는 포인터 변수의 값을 바꾸고 싶어서 &scores를 넘긴 것입니다.
이 예제는 이중 포인터를 깊게 다루려는 목적이 아닙니다. 중요한 것은 동적 메모리를 함수로 다룰 때 "누가 포인터 값을 바꾸는가"를 생각해야 한다는 점입니다.
동적 메모리는 자유와 책임을 함께 준다
정적 배열은 단순합니다. 크기가 정해져 있고, 함수나 블록이 끝나면 자동으로 정리되는 경우가 많습니다. 대신 유연하지 않습니다. 실행 중 필요한 데이터 크기가 달라지는 프로그램을 만들기 어렵습니다.
동적 메모리는 유연합니다. 사용자가 입력한 크기만큼 배열을 만들 수 있고, 데이터가 늘어나면 공간을 확장할 수도 있습니다. 구조체 배열, 문자열 목록, 연결 리스트, 파일에서 읽어 온 데이터처럼 실제 프로그램에 가까운 작업을 할 수 있게 됩니다.
하지만 C는 이 자유를 공짜로 주지 않습니다. 힙 메모리를 빌렸다면 직접 돌려줘야 합니다. 해제한 메모리를 다시 사용하면 안 됩니다. 크기를 늘릴 때 실패 가능성을 생각해야 합니다. 배열 범위를 벗어나면 안 됩니다.
이번 장에서 익힌 규칙을 한 문장으로 정리하면 다음과 같습니다.
할당하고, 검사하고, 사용하고, 해제한다.
이 네 단계를 지키면 동적 메모리는 더 이상 막연히 무서운 대상이 아닙니다. 오히려 C가 컴퓨터의 메모리를 얼마나 직접적으로 보여 주는지 느끼게 해 주는 강력한 도구가 됩니다.
다음 장에서는 여러 값을 하나의 의미 있는 덩어리로 묶는 방법을 배웁니다. 이름, 나이, 점수처럼 서로 관련 있는 데이터를 따로따로 흩어 놓지 않고 하나의 자료형처럼 다루는 방법입니다. 그 도구가 바로 구조체입니다. 동적 메모리와 구조체가 만나면, 우리는 훨씬 현실적인 프로그램을 만들 수 있게 됩니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
