프로그램이 동작할 때 사용하는 메모리에 대해서 간단히 정리해 보려고 합니다. 이 부분을 정확히 모른다고 프로그램을 개발할 때 큰 문제가 되지는 않을 수 있지만 정확히 알고 있으면 나중에 많은 도움이 될 수 있기 때문에 이번 기회에 알아 두시면 좋을 것 같습니다. 아래와 같은 영역에 대해서 살펴 보도록 하겠습니다.
- Stack
- Heap
Stack
Stack 은 개별 함수들에 의해서 생성된 고정된 크기의 변수들이 저장되는 프로세스 메모리 영역입니다. 각 함수의 메모리 정보를 스택프레임이라고 부르며, 여기에 함수의 지역 변수들이 저장되고, 다른 함수가 호출이 되면 해당 함수의 새로운 스택 프레임이 현재 스택 프레임 위에 생성이 됩니다. 스택 프레임을 생성한 함수만이 자신의 스택 프레임에 접근할 수 있고 그것이 결국 함수의 범위입니다. 위에서 스택에 저장되는 것은 고정된 크기의 변수라고 말한 것을 기억하실 겁니다. 이러한 변수의 크기는 컴파일 타임에 알 수 있으며, 만일 스택에 배열이 저장이 된다면 해당 배열을 선언할 때 해당 배열의 크기를 명시적으로 지정해야 합니다. 그래야만, 해당 배열의 크기를 알 수 있기 때문입니다.
아래 예시를 보면서 실제 프로그램 실행시에 스택이 어떻게 생성이 되고 소멸 되는지 알아보겠습니다.
FUCNCTION functionA() {
integer i = 10;
call functionB(i)
}
FUCNCTION functionB(integer j) {
integer k = 20;
}
위의 수두 코드를 보면 functionA 가 실행이 되면서 functionB 를 호출하고 있습니다. functionB 를 호출할 때 파라미터로 자신의 지역변수인 i 를 같이 넘겨주고 있습니다. 이 때 스택의 변경 모습은 아래 그림과 같습니다.
functionA 가 실행되면 functionA 에 대한 스택 프레임이 생성이 되고, functionA 의 내부 변수 i 가 스택에 생성이 됩니다. 그리고 functionB 를 호출하게 되면 functionB 에서 사용하는 파라미터 j 가 스택에 생성이 되고, k=20 을 실행하면 변수 k도 스택에 생성이 됩니다. 이후 functionB 가 종료되면 functionB 가 사용하던 스택 프레임은 해제됩니다.
이 후 functionA 가 종료되면 functionA 의 스택 프레임도 해제됩니다. 프로그램을 개발하다가 잘못된 재귀 함수에 의해서 ‘stack overflow’ 라는 에러를 본 적이 있을 겁니다. 스택은 제한된 크기를 갖고 있다는 것을 알고 있어야 합니다. 프로그램에서 재귀 호출을 사용하는 로직이 있다면, 재귀호출이 실행될 때마다 스택 영역에 계속 스택 프레임을 생성하게 되는데 재귀 함수에서 종료 조건이 없이 계속 무한 호출이 반복되면 스택을 모두 사용하게 됩니다. 이러한 경우 ‘stack overflow’ 에러가 발생하게 되는 것입니다.
스택에 저장되는 데이터들은 해당 함수가 종료가 되면 자동으로 사라지기 때문에 자동으로 관리되는 영역이라고 할 수 있습니다. 스택에 저장되는 데이터는 컴파일 타임에 크기를 알 수 있고, ‘후입선출’ 방식으로 저장이 되기 때문에 이 부분의 관리가 쉽고 빠릅니다. 모든 것이 예측가능하고 이 영역을 관리하는데 개발자가 특별히 신경써야 할 부분이 없습니다.
Heap (힙)
Heap 은 스택과는 다르게 자동으로 관리되지 않은 프로세스 메모리 영역입니다. 힙 메모리 영역을 사용하려면 수동으로 메모리를 할당해야 합니다. 그리고 사용이 끝나면 사용했던 메모리 영역을 수동으로 해제해야만 합니다. 사용했던 힙 메모리를 해제하지 않으면 나중에 메모리 부족 현상을 경험할 수 있습니다. 위에서 설명한 스택은 크기에 제한이 있지만 힙 영역은 크기에 제한이 없습니다. 단지 시스템의 물리적 메모리 크기에 종속적일 뿐입니다. 그렇기 때문에 프로그램에서 방대한 양의 데이터를 처리하기 위해서 힙은 필수적으로 사용할 수 밖에 없습니다.
접근성에 있어서도 힙과 스택은 차이가 있습니다. 스택에 있는 변수는 해당 스택을 할당한 함수에 의해서만 접근할 수 있지만, 힙에 있는 모든 데이터는 프로그램에 있는 모든 함수에서 접근할 수 있습니다. 개발자는 인지하기 어렵지만 힙을 할당하고 해제하는 과정은 다른 작업에 비해서 상대적으로 많은 비용이 소요되는 작업이기 때문에 주의 깊게 사용해야 합니다.
아래 그림을 통해서 좀 더 자세히 살펴보겠습니다.
FUCNCTION functionA() {
integer i = 10;
call functionB(i);
}
FUCNCTION functionB(integer j) {
POINTER k = ALLOCATE INTEGER 20;
}
위의 코드가 실행되면 스택과 힙은 아래와 같이 동작합니다.
functionB 내용을 보면 힙메모리 영역에 공간을 할당하고 정수객체 타입의 변수을 k 변수에 할당하였습니다. ( POINTER k = ALLOCATE INTEGER 7)
일반적인 개발언어에서는 new 연산자를 통해서 해당 동작이 이뤄지게 됩니다. 이경우 실제 변수의 내용은 힙 영역에 생성이 되고 functionB 의 스택영역에서는 해당 변수의 메모리 주소를 가리키는 포인터 변수가 생성이 됩니다. 이 포인터 변수를 통해서 실제 변수가 힙메모리의 어느 위치에 저장되었는지 알 수 있고, 그 메모리 주소를 통해서 해당 변수값을 알 수 있는 것이죠.
그런데, 만일 functionB 내부에서 사용한 힙 메모리 영역을 해제하지 않고 종료해버리면 힙메모리상의 주소를 저장했던 스택 변수 또한 스택에서 사라지게 됩니다. 더 이상 힙메모리의 어느 주소를 사용하고 있는지 모르기 때문에 사용했던 힙메모리를 다시 해제할 수가 없게 됩니다. 가장 일반적인 “메모리 누수 (Memory Leak)” 현상입니다. 이 영역은 프로그램이 실행되는 동안 계속 메모리를 점유하게 되고 해당 프로그램이 종료되고 나서야 해제될 수 있습니다. 그래서 위 코드는 아래와 같이 해당 변수를 모두 사용한 다음에는 명시적으로 힙 메모리 영역을 해제하도록 수정되어야 합니다.
FUCNCTION functionB(integer j) {
POINTER k = ALLOCATE INTEGER 20;
// use k variable...
DEALLOCATE k;
}
Java , C#, go 등 많이 사용되는 개발 언어에서는 이렇게 힙을 사용하는 변수들을 자동으로 제거해주는 메커니즘이 존재합니다. 개발자들은 더 이상 힙을 사용하는 데이터들에 대해서 신경을 쓰지 않아도 됩니다. 하지만, 항상 모든 것에는 장점과 단점이 같이 존재하듯이, 이렇게 자동으로 힙을 관리해주는 기능은 장점만 아니라 단점도 명확합니다.
힙에 존재하지만 더 이상 사용하지 않는 데이터들을 정리하는데 드는 비용도 무시할 수 없지만, 가장 중요한 것은 개발자들이 힙에 있는 메모리를 사용하는 코드를 개발할 때 예전보다 신경을 많이 쓰지 않는다는 것입니다. 컴퓨팅 파워가 예전에 비해서 너무 좋아졌기 때문에 이러한 문제점을 체감하는 것이 쉽지는 않지만, 대용량 처리를 해야 하는 기능을 개발할 때는 많은 신경을 써야 합니다. Rust 를 처음 접했을 때 가장 좋았던 것은 이러한 부분에 대해서 개발자가 개입할 수 있는 부분이 명확해졌다는 겁니다. 이에 대해서는 나중에 차근차근 알아보도록 하겠습니다.