[C언어 50강] 30강. 동적 메모리: malloc/calloc/realloc/free 패턴
C언어를 배우다 보면 어느 순간 배열 크기를 미리 정해두는 방식이 답답해집니다. 사용자 입력 길이가 매번 다르고, 파일 크기도 실행 전에는 알 수 없고, 처리해야 할 데이터 개수도 상황마다 달라지기 때문입니다. 이때 필요한 도구가 동적 메모리입니다. 오늘은 malloc, calloc, realloc, free를 "함수 사용법"이 아니라 "메모리 생명주기 관리" 관점에서 정리합니다.
핵심 개념
- 동적 메모리는 실행 중(heap 영역) 에 필요한 크기만큼 확보하고 반납하는 방식이다.
malloc/calloc/realloc의 반환값은 반드시 확인하고, 실패 시 대체 흐름을 설계해야 한다.- 포인터 변수 하나를 다루는 문제가 아니라, 소유권(누가 free할지) 과 수명(lifetime) 을 다루는 문제다.
개념 먼저 이해하기
정적 메모리(전역/정적 변수)와 자동 메모리(함수 지역 변수, 스택)는 수명이 비교적 명확합니다. 전자는 프로그램 시작~종료, 후자는 블록 진입~이탈입니다. 하지만 동적 메모리는 개발자가 직접 free를 호출하기 전까지 살아 있습니다. 즉, 컴파일러가 수명을 자동으로 닫아주지 않습니다. 그래서 동적 메모리를 쓰기 시작하는 순간부터 C 프로그래머는 "값 계산"만 하는 사람이 아니라 "메모리 관리자"가 됩니다.
핵심은 네 가지 질문입니다. 첫째, 언제 할당할 것인가. 너무 일찍 크게 잡으면 낭비가 발생하고, 너무 자주 조금씩 잡으면 관리 비용이 커집니다. 둘째, 누가 해제할 것인가. 함수 A에서 할당하고 함수 B에서 쓰고 함수 C에서 종료된다면 free 책임을 명확히 문서화해야 합니다. 셋째, 실패하면 어떻게 할 것인가. malloc은 메모리가 부족하면 NULL을 반환하므로, 성공을 전제하고 코드를 쓰면 언젠가 장애가 납니다. 넷째, 크기 변경 시 기존 데이터는 안전한가. 특히 realloc은 내부적으로 새 블록을 만들고 복사한 뒤 기존 블록을 해제할 수 있어, 반환값 처리 순서가 매우 중요합니다.
많이 생기는 오해도 짚어야 합니다. "운영체제가 프로그램 종료 때 메모리 회수하니 free 안 해도 된다"는 말은 학습 단계에서는 위험합니다. 짧은 단일 실행 프로그램에서는 티가 안 나더라도, 서버/라이브러리/반복 실행 환경에서는 누수가 곧 장애로 이어집니다. 또 "포인터를 NULL로 만들면 free한 것과 같다"는 오해도 있습니다. NULL 대입은 단지 주소를 잊어버리는 행위일 뿐이며, 실제 메모리 반납은 free만 합니다. 반대로 free 후 포인터를 NULL로 재설정하는 습관은 댕글링 포인터 재사용을 줄이는 데 유효합니다.
실무적으로 동적 메모리는 자료구조 설계와 연결됩니다. 배열 기반 버퍼를 시작 용량으로 만들고, 데이터가 찰 때 2배로 늘리는 전략은 성능과 구현 복잡도의 균형이 좋습니다. 이때 용량(capacity)과 실제 사용 길이(length)를 분리해 관리해야 하며, 재할당 실패 시 원본 포인터 보존 전략을 반드시 지켜야 합니다. 즉, 동적 메모리의 본질은 함수 암기가 아니라 안전한 상태 전이(state transition) 입니다.
기본 사용
예제 1) 최소 동작 예제
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t n = 5;
int *arr = (int *)malloc(n * sizeof(int));
if (arr == NULL) {
fprintf(stderr, "메모리 할당 실패\n");
return 1;
}
for (size_t i = 0; i < n; ++i) {
arr[i] = (int)(i + 1) * 10;
}
for (size_t i = 0; i < n; ++i) {
printf("arr[%zu] = %d\n", i, arr[i]);
}
free(arr);
arr = NULL;
return 0;
}
설명:
malloc(n * sizeof(int))는 int 5개 크기만큼 연속 메모리를 요청한다.- 반환값이
NULL인지 먼저 검사해야 이후 역참조(arr[i])가 안전해진다. free는 "사용 완료" 시점에 정확히 한 번 호출한다.- 해제 후
arr = NULL로 재설정하면 실수로 재사용하는 버그를 줄일 수 있다.
예제 2) 실무에서 자주 맞닥뜨리는 패턴
#include <stdio.h>
#include <stdlib.h>
int push_back_int(int **buf, size_t *len, size_t *cap, int value) {
if (*len == *cap) {
size_t new_cap = (*cap == 0) ? 4 : (*cap * 2);
int *tmp = (int *)realloc(*buf, new_cap * sizeof(int));
if (tmp == NULL) {
return -1; // 원본 *buf는 여전히 유효
}
*buf = tmp;
*cap = new_cap;
}
(*buf)[*len] = value;
(*len)++;
return 0;
}
int main(void) {
int *data = NULL;
size_t len = 0, cap = 0;
for (int i = 1; i <= 10; ++i) {
if (push_back_int(&data, &len, &cap, i * 3) != 0) {
fprintf(stderr, "버퍼 확장 실패\n");
free(data);
return 1;
}
}
for (size_t i = 0; i < len; ++i) {
printf("%d ", data[i]);
}
printf("\nlen=%zu, cap=%zu\n", len, cap);
free(data);
return 0;
}
설명:
- 동적 배열에서 가장 흔한 패턴은
len과cap을 분리하는 것이다. realloc결과를 바로 원본 포인터에 대입하지 않고tmp에 받는다.- 실패하면 원본 포인터를 유지하므로 데이터 유실 없이 복구/종료 처리가 가능하다.
- 확장 비율 2배 전략은 재할당 횟수를 줄여 평균 성능을 안정화한다.
예제 3) 디버깅 포인트 포함 예제
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *name;
int age;
} Person;
int init_person(Person *p, const char *name, int age) {
size_t len = strlen(name);
p->name = (char *)malloc(len + 1);
if (p->name == NULL) {
return -1;
}
memcpy(p->name, name, len + 1);
p->age = age;
return 0;
}
void destroy_person(Person *p) {
free(p->name);
p->name = NULL;
p->age = 0;
}
int main(void) {
Person p;
if (init_person(&p, "Kim", 30) != 0) {
fprintf(stderr, "Person 초기화 실패\n");
return 1;
}
printf("name=%s, age=%d\n", p.name, p.age);
destroy_person(&p);
return 0;
}
설명:
- 구조체 내부 포인터 멤버를 가지면 생성(init)과 소멸(destroy) 함수를 세트로 설계해야 한다.
- 문자열은 널 문자까지 포함해
len + 1을 할당해야 버퍼 오버런을 피한다. - 소멸 함수에서
NULL대입까지 해두면 이중 해제 가능성을 낮춘다.
자주 하는 실수
실수 1) realloc 반환값을 원본 포인터에 바로 대입
- 원인:
ptr = realloc(ptr, new_size);처럼 한 줄로 처리해 실패 시NULL이 들어오면 기존 주소를 잃어버린다. - 해결: 반드시
tmp = realloc(ptr, ...)후 성공 시에만ptr = tmp로 교체한다.
실수 2) 할당 크기 계산 오류
- 원인:
malloc(n)처럼 요소 개수가 아니라 바이트 수를 혼동하거나,sizeof(pointer)를 잘못 사용한다. - 해결:
malloc(n * sizeof(*ptr))스타일을 습관화해 타입 변경에도 안전하게 유지한다.
실수 3) 해제 후 재사용
- 원인:
free(ptr)뒤에 여전히ptr[i]접근, 혹은 다른 함수에서 같은 주소를 참조. - 해결: 해제 직후
ptr = NULL처리, 소유권을 단일화하고 해제 위치를 하나로 모은다.
실수 4) 누수는 작은 프로그램에선 괜찮다고 방치
- 원인: 실행이 짧아 문제가 드러나지 않아 습관이 굳어짐.
- 해결: 학습 단계부터 "할당 지점과 해제 지점 짝 맞추기"를 코드 리뷰 체크리스트에 넣는다.
실무 패턴
- 소유권 문서화: "이 함수가 반환한 포인터는 호출자가 free한다" 같은 규칙을 주석/API 문서에 명시한다.
- 단일 종료 지점 패턴: 오류 처리 분기가 많을 때
goto cleanup으로 이미 할당한 자원을 한 번에 정리한다. - capacity 기반 확장 정책: 0→4→8→16처럼 점진 확장하여 재할당 횟수와 메모리 낭비를 균형화한다.
- 메모리 도구 활용: macOS라면
leaks, Linux라면valgrind/ASan으로 누수·오버런을 조기에 잡는다. - 초기화 원칙 통일:
malloc후 필요한 필드만 직접 초기화할지,calloc로 0초기화할지 팀 기준을 통일한다.
오늘의 결론
한 줄 요약: 동적 메모리의 본질은 malloc/free 함수 호출이 아니라, "누가 언제 소유하고 언제 안전하게 반납하는지"를 설계하는 능력이다.
연습문제
- 사용자에게 정수 개수 N을 입력받아 N개 배열을 동적 할당하고, 평균을 계산한 뒤 메모리를 해제하시오.
- 길이를 모르는 정수 입력 스트림을 동적 배열(capacity 확장)로 저장하는 코드를 작성하시오.
realloc실패 시 기존 데이터를 보존하는 안전한 래퍼 함수safe_realloc_int를 구현하시오.
이전 강의 정답
- 29강 연습문제 1(이중 포인터로 문자열 배열 순회):
- 핵심은
char **argv_like를 "문자열의 시작 주소들을 담은 배열"로 보고,argv_like[i]는char *,argv_like[i][j]는 문자라는 계층을 유지하는 것입니다.
- 핵심은
- 29강 연습문제 2(함수에서 포인터 주소 바꾸기):
- 호출자 포인터 자체를 바꾸려면
int **pp처럼 포인터의 주소를 넘겨야 하며, 함수 내부에서*pp = new_addr;로 갱신해야 합니다.
- 호출자 포인터 자체를 바꾸려면
- 29강 연습문제 3(NULL 종료 규약 점검):
- 문자열 배열/포인터 체인을 순회할 때 종료 조건(
NULL또는 길이)을 명확히 합의하지 않으면 경계 초과 접근이 발생합니다.
- 문자열 배열/포인터 체인을 순회할 때 종료 조건(
실습 환경/재현 정보
- 컴파일러: Apple clang 17+ 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -Wpedantic -O0 -g - 실행 환경: macOS(arm64) 터미널 또는 Linux 셸
- 재현 체크:
- 컴파일 경고 0개인지 확인
- 정상 입력/비정상 입력에서 종료 코드 확인
- 메모리 누수 도구로 leak 여부 확인
realloc실패 분기 로직을 코드 리뷰로 검증