12편에서 stdio.h를 사용해 파일과 입력을 다뤘다면, 이제는 "필요한 만큼의 메모리를 직접 빌렸다가 돌려주는" 동적 메모리 할당 차례입니다. 지금까지는 배열 길이를 컴파일 시점에 정했지만, 사용자 입력이나 파일 길이는 실행 전에 알 수 없습니다. malloc과 free는 이런 상황에서 프로그램이 스스로 공간을 요청하고 해제하도록 도와줍니다.
이번 글에서 새로 나오는 용어
- 힙 (Heap): 실행 중 필요한 만큼 메모리를 요청해 쓰는 영역
- 동적 메모리 할당: 프로그램 실행 중
malloc같은 함수를 통해 메모리를 확보하는 과정 - size_t: "얼마나 큰가"를 표현할 때 쓰는 부호 없는 정수 타입
- 메모리 누수 (Memory Leak): 할당한 메모리를
free하지 않아 더 이상 접근할 길을 잃은 상태
핵심 개념
학습 메모
- 소요 시간: 60~90분
- 준비물: 배열, 포인터 기초,
stdio.h입출력 경험- 학습 목표:
malloc계열 함수를 사용해 공간을 확보하고,free로 수명을 명확히 끝내기
정적 배열은 길이를 바꾸기 쉽지 않기 때문에 입력 크기를 모르거나 점점 늘어나는 데이터를 다루기 어렵습니다. 예를 들어 사용자가 몇 글자를 입력할지, 파일에 몇 개의 점수가 들어 있을지 실행 전에는 모를 수 있습니다. 동적 메모리는 바로 이런 "실행 중에 크기가 정해지는 데이터"를 다룰 때 필요합니다. 동적 메모리는 아래 단계를 항상 함께 생각해야 합니다.
- 요청: 필요한 바이트 수를 계산해
malloc/calloc/realloc으로 확보합니다. - 확인: 반환값이
NULL인지 확인해 실제로 할당이 성공했는지 검사합니다. - 사용: 포인터를 배열처럼 쓰거나 다른 포인터에게 넘깁니다.
- 해제: 더 이상 필요 없을 때
free로 돌려주고, 포인터를NULL로 초기화하면 안전합니다.
이번 글에서는 아래 시나리오를 중심으로 살펴봅니다.
- 정적 배열과 동적 메모리 차이 이해하기
malloc,calloc,realloc의 공통점과 차이점free를 꼭 호출해야 하는 이유와 누수를 찾는 감각- 실패 가능성을 대비한
NULL체크 패턴 - 입력 길이에 따라 버퍼를 늘리는 간단한 예제
코드로 따라하기
스택 배열과 힙 메모리 비교
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int fixed[3] = {1, 2, 3};
int *dynamic = malloc(sizeof(int) * 3);
if (dynamic == NULL) {
return 1;
}
for (int i = 0; i < 3; i++) {
dynamic[i] = (i + 1) * 10;
}
printf("fixed[1] = %d\n", fixed[1]);
printf("dynamic[1] = %d\n", dynamic[1]);
free(dynamic);
return 0;
}
fixed는 컴파일 시점에 크기가 정해지고 스택에 배치됩니다. dynamic은 실행 중에 힙에서 공간을 빌리고, free(dynamic);으로 직접 돌려줍니다.
malloc으로 필요한 만큼 요청하기
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t count = 5;
int *scores = malloc(sizeof(int) * count);
if (scores == NULL) {
printf("메모리를 확보하지 못했습니다.\n");
return 1;
}
for (size_t i = 0; i < count; i++) {
scores[i] = (int)(i * 10);
}
for (size_t i = 0; i < count; i++) {
printf("scores[%zu] = %d\n", i, scores[i]);
}
free(scores);
scores = NULL;
return 0;
}
sizeof(int) * count처럼 "필요한 요소 수 × 한 요소 크기"로 계산합니다.size_t는 크기를 표현할 때 사용하는 표준 타입이라 음수가 없고, 플랫폼마다 자동으로 적절한 크기를 가집니다.free이후 포인터를NULL로 바꿔 두면 잘못된 재사용을 빨리 발견할 수 있습니다.
calloc으로 0으로 초기화된 공간 얻기
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t count = 4;
int *buffer = calloc(count, sizeof(int));
if (buffer == NULL) {
return 1;
}
for (size_t i = 0; i < count; i++) {
printf("buffer[%zu] = %d\n", i, buffer[i]);
}
free(buffer);
return 0;
}
calloc은 요소 수와 요소 크기를 따로 받으며, 모든 바이트를 0으로 채워 줍니다. 배열 값을 일일이 0으로 채우는 수고를 덜 수 있지만, 모든 자료형에서 이것이 언제나 "논리적으로 원하는 초기값"과 완전히 같다고 단정하면 안 됩니다. 입문 단계에서는 정수 배열을 0으로 준비하는 예제로 먼저 이해하면 충분합니다.
realloc으로 버퍼 크기 늘리기
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
size_t capacity = 4;
size_t length = 0;
char *line = malloc(capacity);
if (line == NULL) {
return 1;
}
const char *source = "C-memory";
for (size_t i = 0; source[i] != '\0'; i++) {
if (length + 1 >= capacity) {
size_t new_capacity = capacity * 2;
char *temp = realloc(line, new_capacity);
if (temp == NULL) {
free(line);
return 1;
}
line = temp;
capacity = new_capacity;
}
line[length++] = source[i];
}
line[length] = '\0';
printf("line = %s (capacity = %zu)\n", line, capacity);
free(line);
return 0;
}
realloc은 기존 포인터를 새로운 크기로 늘이거나 줄입니다.- 반환값이 다를 수 있으므로, 임시 포인터(
temp)로 받아서 실패했을 때 원본을 잃지 않게 합니다. - 성공하면 새 포인터를 기준으로만 계속 사용하고, 실패했을 때만 원본 포인터가 그대로 남아 있다고 생각하면 됩니다.
- 새로운 공간이 이전 주소와 다를 수 있어 복사 후 오래된 포인터를 사용하면 안 됩니다.
실패 가능성을 항상 염두에 두기
힙 공간은 무한하지 않으므로, malloc이 실패하면 NULL을 반환합니다. 따라서 아래 패턴을 기본으로 익혀 둡니다.
int *data = malloc(sizeof(int) * n);
if (data == NULL) {
// 로그를 남기거나 사용자에게 알리고, 안전하게 종료
}
또한 free는 NULL 포인터를 인자로 받을 때 아무 일도 하지 않으므로, "이미 해제했는지 확신이 없다면 먼저 NULL인지 확인"하는 습관을 들이면 좋습니다.
누수를 찾아내는 생활 습관
간단한 규칙을 코드 리뷰 리스트로 만들어 두면 누수를 줄일 수 있습니다.
malloc을 호출한 함수가free까지 책임질 것인지, 아니면 호출자에게 포인터를 반환할 것인지 역할을 명확히 구분합니다.return문이 여러 개인 함수에서는 "모든 경로에서free가 호출되는가"를 체크합니다.- 동적 배열을 구조체에 넣을 때는 구조체를 해제하는 함수에
free호출을 포함시킵니다.
왜 중요한가
- 입력 길이, 파일 크기, 네트워크 데이터 등 실행 중 결정되는 정보를 다루려면 동적 메모리가 필수입니다.
malloc과free를 이해하면 이후 포인터 산술, 구조체, 사용자 정의 컨테이너까지 확장할 수 있습니다.- 메모리 누수를 스스로 추적하는 습관은 시스템 프로그래밍뿐 아니라 다른 언어의 리소스 관리(파일, 소켓 등)에도 그대로 응용됩니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요