메모리 관리 API#
이 장에서는 Unix 시스템에서 메모리를 관리하기 위한 API(Application Programming Interface)에 대해 알아보겠습니다. 여기서 말하는 메모리란 프로세스의 사용자 주소 공간을 의미합니다. 우리가 해결하고자 하는 핵심 질문은 다음과 같습니다.
핵심 질문
프로그램에서 메모리를 어떻게 할당하고 관리해야 할까?
Unix/C 프로그램에서 메모리를 효과적으로 사용하는 방법을 이해하는 것은 안정적이고 강력한 소프트웨어를 개발하는 데 있어 매우 중요합니다. 어떤 API가 제공되고, 어떤 실수를 피해야 하는지 살펴보도록 하겠습니다.
메모리 공간의 종류[1]#
C 프로그램이 실행될 때, 크게 두 가지 유형의 메모리 공간이 할당됩니다.
첫 번째는 ‘스택(stack)’ 메모리입니다. 스택 메모리의 할당과 해제는 프로그래머를 대신하여 컴파일러가 자동으로 처리해줍니다. 그래서 때로는 ‘자동(automatic)’ 메모리라고도 불립니다.
C에서 스택에 메모리를 할당하는 것은 매우 간단합니다. 예를 들어 func()
라는 함수 내에서 x
라는 이름의 정수 변수를 선언한다고 해 보죠.
void func() {
int x; // 스택에 정수형 변수 선언
// ...
}
컴파일러는 func()
가 호출될 때 스택 프레임 내에 x
를 위한 공간을 마련해 둡니다.
두 번째 유형은 ‘힙(heap)’ 메모리입니다. 힙은 함수 호출이 끝난 후에도 유지되어야 하는 데이터를 저장하는 데 사용됩니다. 힙 메모리의 할당과 해제는 프로그래머가 직접 관리해야 합니다.
다음은 힙에 정수 포인터를 할당하는 예시 코드입니다.
void func() {
int *x = (int*) malloc(sizeof(int));
// ...
}
이 코드에는 몇 가지 주목할 점이 있습니다.
먼저 한 줄 안에서 스택과 힙 할당이 동시에 일어납니다. 컴파일러는 int *x
선언을 보고 정수 포인터를 위한 공간을 스택에 마련합니다. 그리고 malloc()
함수 호출을 통해 정수 자체를 저장할 공간을 힙에서 동적으로 할당받습니다.
malloc()
이 반환하는 것은 새로 할당된 공간의 주소값입니다. 성공 시에는 해당 주소를, 실패 시에는 NULL
을 돌려주죠. 이 주소값은 스택의 x
변수에 저장되어 프로그램에서 사용됩니다.
이처럼 명시적으로 메모리를 관리해야 하고 다양한 용도로 사용되는 힙 메모리는 프로그래머에게나 시스템에게나 쉽지 않은 도전 과제입니다. 앞으로의 논의는 주로 힙 메모리 관리에 초점을 맞출 것입니다.
몇 가지 핵심 용어를 정리해 보겠습니다.
API (Application Programming Interface)
운영체제나 라이브러리가 제공하는 함수나 메서드의 집합을 말합니다. 프로그래머는 API를 통해 시스템 자원에 접근하고 제어할 수 있습니다.
스택 (Stack)
함수 호출 시 할당되는 메모리 영역으로, 함수의 매개변수, 지역변수, 리턴 주소 등이 저장됩니다. LIFO(Last-In-First-Out) 구조를 가지며, 함수 호출이 완료되면 자동으로 해제됩니다.
힙 (Heap)
프로그램이 실행 중에 동적으로 할당하고 해제할 수 있는 메모리 영역입니다. 프로그래머가 직접 메모리 생명주기를 관리해야 합니다. 힙은 스택과 달리 비연속적이고 임의 접근 가능한 메모리 블록들로 구성됩니다.
malloc()
C 언어에서 힙 메모리를 동적 할당하기 위해 사용하는 함수입니다. 할당할 바이트 수를 인자로 받아, 성공 시 할당된 메모리의 주소를
void*
타입으로 반환합니다.
이제 실제로 어떤 API들이 있는지, 그리고 메모리 관리에서 주의해야 할 점은 무엇인지 하나씩 살펴보도록 하겠습니다.
malloc() 함수#
malloc()은 힙에서 메모리를 동적으로 할당하는 함수입니다. 사용법은 매우 간단한데요, 필요한 메모리의 크기(바이트 단위)를 인자로 전달하면 됩니다. 할당에 성공하면 새로 할당된 메모리 블록의 시작 주소를 가리키는 포인터를 반환하고, 실패하면 NULL을 반환합니다.
malloc()을 사용하려면 어떻게 해야 할까요? 먼저 관련 함수 원형이 선언된 헤더 파일 <stdlib.h>
를 소스 코드에 포함시켜야 합니다. 그리고 다음과 같은 형태로 함수를 호출하면 됩니다.
void* malloc(size_t size);
여기서 size
는 할당받고자 하는 메모리의 크기를 바이트 단위로 나타낸 값입니다. 이 크기를 정확히 지정하기 위해 보통 sizeof()
연산자를 사용합니다.
C에서 sizeof()
는 컴파일 시점에 평가되는 연산자로, 인자로 전달된 데이터 타입이나 변수의 크기를 바이트 단위로 반환합니다. 따라서 sizeof()
는 함수 호출이 아니라 연산자로 취급됩니다.
예를 들어 malloc(sizeof(double))
과 같이 호출하면, sizeof(double)
은 컴파일 시점에 상수 값 8(64비트 시스템 기준)으로 대체되어 malloc()
에 전달되는 거죠.
그런데 sizeof()
를 변수에 적용할 때는 주의해야 합니다. 다음 코드를 봅시다.
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
첫 번째 줄에서는 정수 10개를 저장할 수 있는 배열을 위한 메모리를 할당했습니다. 그런데 두 번째 줄에서 sizeof(x)
를 출력해 보면, 4(32비트 시스템)나 8(64비트 시스템)이 나옵니다.
왜 그럴까요? 여기서 sizeof()
는 x
가 가리키는 메모리 블록의 크기가 아니라, 포인터 변수 x
자체의 크기를 반환하기 때문입니다.
반면 다음과 같이 정적 배열에 sizeof()
를 적용하면 기대한 대로 동작합니다.
int x[10];
printf("%d\n", sizeof(x)); // 40 출력 (32비트 시스템 기준)
문자열을 다룰 때도 조심해야 합니다. 문자열을 저장할 공간을 동적 할당할 때는 보통 malloc(strlen(s) + 1)
과 같은 코드를 사용합니다. strlen()
으로 문자열의 길이를 구한 뒤, 마지막 NULL 문자를 저장할 공간까지 확보하는 거죠. 이때 sizeof()
를 쓰면 원하는 결과를 얻지 못합니다.
한 가지 더 눈여겨볼 점은 malloc()
의 반환 타입이 void*
라는 것입니다. 이는 C 언어의 특징을 잘 보여주는데요, malloc()
은 그저 메모리 블록의 주소만 반환할 뿐, 그 공간을 어떤 타입의 데이터를 저장하는 데 사용할지는 전적으로 프로그래머에게 맡기는 겁니다.
따라서 malloc()
이 반환한 void*
값은 적절한 타입의 포인터로 캐스팅해서 사용해야 합니다. 앞선 예제에서 (int*)
로 캐스팅한 것처럼 말이죠. 사실 캐스팅이 없어도 프로그램은 돌아가지만, 명시적 캐스팅을 통해 의도를 분명히 하는 게 좋은 습관입니다.
free() 함수#
사실 메모리 할당보다 더 어려운 문제는 할당한 메모리를 언제, 어떻게 해제할 것인가 하는 점입니다.
힙에서 동적 할당한 메모리가 더 이상 필요 없어졌을 때, 이를 시스템에 반환하는 작업은 전적으로 프로그래머의 책임입니다. 이를 위해 free()
함수를 사용합니다.
int *x = malloc(10 * sizeof(int));
// ...
free(x);
free()
는 인자로 malloc()
이 반환한 포인터 값 하나만 받습니다. 해제할 메모리 블록의 크기는 전달하지 않는데요, 메모리 할당 라이브러리가 내부적으로 관리하고 있기 때문입니다.
여기서 주목할 점은, 동적 할당한 메모리를 해제하는 것이 전적으로 프로그래머의 책임이라는 사실입니다. 메모리 누수(memory leak)를 방지하려면 불필요해진 메모리를 빠짐없이 free()
로 해제해야 합니다.
반대로 이미 해제된 메모리를 또 다시 해제하려 하면 언디파인드 비헤이비어(undefined behavior)를 초래할 수 있으므로, 이 점도 주의해야 겠죠.
이처럼 동적 메모리 관리는 C 프로그래밍에서 가장 까다롭고 중요한 주제 중 하나입니다. malloc()
과 free()
의 사용법을 정확히 익히고, 메모리 누수나 이중 해제와 같은 실수를 피하는 것이 안정적인 프로그램을 작성하는 핵심 요령이라 할 수 있겠습니다.
운영체제의 지원 [2]#
C 표준 라이브러리에서 제공하는 malloc()
과 free()
함수는 각각 메모리 할당과 해제를 담당합니다. 이들은 시스템 콜이 아닌 라이브러리 함수로, 프로세스의 가상 주소 공간 내에서 힙 메모리를 관리하는 역할을 합니다.
하지만 라이브러리 자체는 운영체제에게 메모리를 요청하고 반환하는 시스템 콜을 기반으로 동작합니다. 대표적인 예가 brk
시스템 콜인데요, 이는 프로그램의 ‘브레이크(break)’ 위치를 조정하여 힙의 크기를 늘리거나 줄이는 기능을 합니다.
여기서 브레이크란 힙의 끝을 가리키는 포인터를 말합니다. brk
시스템 콜은 새로운 브레이크 주소값을 인자로 받아, 그에 맞춰 힙 영역을 조정하는 거죠. 비슷한 역할을 하는 sbrk
함수도 있는데, 이는 브레이크 위치를 얼마나 옮길지 그 증감량을 인자로 받습니다.
그런데 중요한 점은 프로그래머가 직접 brk
나 sbrk
를 호출해서는 안 된다는 것입니다. 이들은 malloc()
이나 free()
같은 메모리 할당 라이브러리 내부에서만 사용되어야 합니다. 직접 호출할 경우 예측 불가능한 문제가 발생할 수 있으므로, 반드시 표준 라이브러리 함수를 통해 메모리를 다뤄야 합니다.
또 다른 방법으로는 mmap()
함수를 사용하여 운영체제로부터 메모리를 받아올 수도 있습니다. 적절한 인자를 전달하면 mmap()
은 파일과 연결되지 않은 익명(anonymous) 메모리 영역을 할당해 주는데요, 이 공간은 힙과 유사하게 다룰 수 있습니다. 특히 대용량 메모리가 필요할 때 유용하게 활용할 수 있는 방법입니다.
기타 함수들#
메모리 관리와 관련하여 알아두면 좋은 추가 함수들이 몇 가지 더 있습니다.
calloc()
malloc()
과 비슷하지만, 할당된 메모리 블록을 모두 0으로 초기화한 뒤 반환합니다.초기화를 빠뜨리는 실수를 방지하고자 할 때 유용합니다.
특히 동적 할당한 배열을 0으로 초기화할 때 많이 쓰이며, ‘초기화되지 않은 읽기’와 같은 미묘한 버그를 예방하는 데 도움이 됩니다.
realloc()
이미
malloc()
이나calloc()
으로 할당한 메모리 블록의 크기를 조정할 때 사용합니다.예컨대 동적 할당한 배열의 크기를 늘리거나 줄일 때 유용하죠.
realloc()
을 호출하면 기존 블록보다 큰 새 메모리 블록을 할당하고, 이전 블록의 내용을 모두 복사해 옵니다. 그리고 새 블록의 주소를 반환하죠.이를 통해 메모리를 재사용하면서도 블록 크기를 유연하게 변경할 수 있습니다.
요약#
이번 장에서는 C 프로그램에서 사용되는 두 가지 메모리 영역, 스택(stack)과 힙(heap)에 대해 살펴보았습니다.
스택: 함수 호출과 관련된 지역 변수, 매개변수, 리턴 주소 등을 저장하는 메모리 영역입니다. 컴파일러에 의해 자동으로 관리되므로 프로그래머가 직접 할당하거나 해제할 필요가 없죠. 함수 내에서 변수를 선언하면 해당 함수의 스택 프레임에 메모리가 할당됩니다.
힙: 프로그램 실행 중에 동적으로 할당하고 해제하는 메모리 영역입니다. 모든 할당과 해제를 프로그래머가 직접 관리해야 하는 게 특징이에요.
malloc()
함수로 힙에서 원하는 크기의 블록을 할당받고, 다 쓴 뒤에는free()
로 반환하는 식이죠.
이 두 메모리 영역은 각기 다른 용도와 수명 주기를 가지고 있습니다. 따라서 프로그램에서는 보통 스택과 힙을 적절히 조합하여 필요한 데이터를 저장하고 관리하게 되죠.
부가적으로 calloc()
이나 realloc()
같은 함수를 활용하면 메모리 초기화나 크기 조정도 손쉽게 할 수 있습니다.
이처럼 운영체제와 시스템 콜의 도움을 받아 구현된 다양한 메모리 관리 라이브러리 함수들을 잘 이용한다면, 보다 안전하고 효율적인 프로그램을 만들 수 있을 것입니다.