이 질문의 핵심은 위와 같은데, 꼬리의 꼬리를 물 수 있는 논란이 있는 질문이라서 답변자의 수준에 따라 전혀 다른 양상의 논의가 흘러갈 수 있는 아주 좋은 질문이라고 할 수 있습니다.
OS, 컴퓨터 구조론에서 정해진 답변은 있습니다만, 답변자의 밑천이 드러나기 딱 좋은 질문입니다. 왜냐하면 답변자가 어떤 답변을 하든 논란의 여지가 있기 때문에 꼬리에 꼬리를 무는 질문이 나가기 때문입니다.
이 질문의 답변의 시작은 'Stack 과 Heap 메모리는 존재 이유가 다르다'이어야 할 겁니다. 현재 시점에서 C, C++, Java 등 프로그래밍의 언어들이 계속해서 사용되는 상황 속이라면, Stack 과 Heap 은 모두 존재해야만 합니다.
Stack 메모리의 기본 개념과 특징
Stack 메모리는 지역변수, 매개변수, 반환주소 등이 사용하는 '선현 메모리' 공간입니다. C 언어를 공부한 사람이라면 Stack Frame 이라는 것을 알고 있는다면, 함수 호출의 방식을 이해할 겁니다. 이런 것에 사용되는 공간이 Stack 메모리입니다. 호출규약까지도 알고 있는다면, Stack 에 대해서 충분히 많이 알고 있구나라고 면접관은 생각할 겁니다.
Stack 메모리의 특징으로는 컴파일 타임에 필요한 메모리 공간의 크기를 알 수 있다는 점입니다. 그러다보니 컴파일 타임에 Java 바이트 코드로 변환이 되기 전에 어떤 식으로든 소모되어야 하는 Stack 메모리의 크기를 명확히 알 수 있게 됩니다. 이것이 가능한 이유는 지역변수의 수명을 명확하게 추적할 수 있기 때문입니다. 예를 들어, 어떤 함수가 있다고 해봅시다.함수는 명확히 Scope 가 있으며, 지역 변수는 해당 스코프를 벗어나기 전까지만 유지되기 때문입니다.
또한, '의존성'을 가지는 변수들이 있다면 이러한 것들도 반영이 될 겁니다.이러한 특징은 멀티 쓰레딩을 활용하는 순간에 같이 활용하게 되는 특징들입니다.
Stack 메모리의 무한정 증대는 비효율적일 수 있다
위에서 살펴본 Stack 메모리의 특징 때문에 Stack 메모리의 무한정 증대는 비효율적일 수 있는데요. Function 들이 순차적으로 호출될 때마다 Stack 에 쌓이고, 반환이 될 때마다 Stack 메모리에서 역순으로 다시 지워지는 식인데요.
이런 특징 때문에 함수간의 호출-반환의 관계가 길어질수록 Stack 사용량이 선형으로 증가하는 특징이 있습니다. 그런데 여기서 문제가 하나 있습니다.함수 B가 함수 C 를 호출한다고 해봅시다. 그러면 B는 실행 중간에 잠깐 동작을 멈추고 C 를 호출한 다음, C가 반환할 때까지 기다렸다가 다시 동작을 이어서 실행합니다. 이런 호출의 Depth 가 2, 3 만 넘어서더라도 논리적으로, 가시적으로 비효율적으로 다가올 수 있습니다.
이렇게 말하는 이유는 B 함수의 추상화가 잘 되어서, B를 호출하는 A 개발자 입장에서 그저 호출하면 끝이면 좋겠으나, B에서 에러가 났을 때, 그 에러의 원인이 C에 들어있을 수도 있습니다. 그러면 A 개발자 입장에서는 호출의 Depth 가 깊어질수록 비효율적으로 느껴질 수 있습니다.
이런 의미에서 Stack 메모리의 무한정 증대, 즉 호출-반환의 관계가 무한정으로 길어지는 코드는 논리적 비효율의 증대를 가져올 가능성이 없지 않습니다.
동적할당 Stack 과 멀티스레딩 이슈
유저의 input 을 runtime 에 동적으로 받는 변수가 있다고 해봅시다.
그러면, Stack 메모리의 하나씩 차곡 차곡 쌓이다가 동적 배열을 받는 별도의 메모리 공간이 있을 겁니다.
T1, T2, T3 함수가 실행된다고 해봅시다. T2 에 있는 A 라는 동적변수는 실행이 끝나면 스택 메모리에서 소멸될 텐데요. 문제는 멀티스레딩 환경에서 발생합니다. A 라는 변수는 이미 소멸되었는데 다른 스레드에서 해당 변수를 참조하려고 하면 에러가 발생하고 말 겁니다.
즉, 이것은 '메모리의 크기'의 문제가 아니라 '구조'의 문제입니다. 덩치가 크고 말고의 문제가 아니라 멀티 스레딩을 활용하지 않는 대규모 애플리케이션이 없는 상황에서, 이런 구조적 한계로 인해서 모든 스레드가 접근할 수 있는 메모리 공간은 Stack 과 별개로 필요하다는 것을 알 수 있습니다.
Heap 메모리 파편화(Fragmentation. C 언어의 malloc( ))
이제 Heap 메모리 얘기를 해봅시다. Heap 메모리 얘기는 C언어의 malloc 함수로 시작을 하면 좋을 것 같은데요. C 언어의 malloc( ) 함수는 생각보다 속도가 느립니다. 게임서버를 만드는 분들은 이 함수를 쓰지 않는 설계를 하는데요. User 가 들어올 때마다 메모리를 할당하는 방법이 있겠구요.
채널별로 메모리를 생성하는 방법도 있을 겁니다.
Heap 메모리 파편화?
아무튼 힙 메모리 파편화에 대해서 이어서 살펴봅시다.
우리는 int 가 4bytes, char 1bytes 를 사용한다는 것을 아는데요. 그러면 이 2가지만 사용한다면 메모리 공간의 크기가 5가 될까요? 그렇지 않습니다.
주로 8이 나올텐데요.
이렇게 되는 이유는 '메모리정렬'과 관련이 있습니다. 관리하기 편할려고 이렇게 되었습니다.Stack 에 변수가 쌓이는데, 메모리 폭을 int 에 맞게 4로 잡고 시작하는 것이죠. char 는 1bytes 만 사용하는데, 우선 4bytes 를 할당하고 1byte 만 사용하도록 하는 것이죠. 메모리의 낭비가 가능하지만, 이렇게 하면 메모리의 관리가 4bytes 단위로 편의성이 증대하게 됩니다.
그래서, Stack, Heap 모든 메모리들은 이런 식으로 메모리 관리를 합니다.
OS 에서도 Page 단위로 메모리 관리를 한다
우리가 텍스트를 열어서 영문 1개만 입력 후 저장한다고 해봅시다. 그러면 1byte 크기의 파일이 생성되어야 할 것 같지만, 실제로는 4KB가 할당되고, 그 안에 1 byte 만 이용하고 있는 메모리가 생성됩니다.
이처럼 낭비되는 메모리가 발생하는데, 이것을 '내부 파편화'라고 합니다.
파편화로 인한 문제점과 Compact
'파편화'는 '큰 덩어리'를 할당하지 못하게 하는 문제를 야기합니다.
이것을 해결하기 위해서는 테트리스처럼 메모리의 빈 공간을 최대한 만드는 노력을 하게 됩니다.이런 것을 해결하기 위해서 'Compact' 과정을 거치게 되는 것이죠.
파편화 문제 해결 방법은 뭘까
배열처럼 미리 할당해두는 것이 첫 번째 해결방법입니다.게임 서버를 미리 만들어두고, 사람이 들어오면 할당을 해주는 것이죠. 그리고 그 사람이 나가면 해당 메로리를 다시 회수하는 방식입니다.
이러한 방식은 단점이 있습니다. 우선 최대 크기가 이미 정해진 환경, 즉 서버를 일정한 크기로 결정하고 만드는 환경에서는 문제가 되지 않습니다만, 서버의 크기 자체가 유동적으로 변해야 한다면 이러한 방식은 단점입니다. 이미 만든 서버의 크기를 늘리지 못하니까요.
두 번째 방법은 JVM 메모리 구조의 Mark & Compact 라는 것을 차용하는 겁니다.
Java 에서는 new 를 통해서 인스턴스를 생성하는데요. 인스턴스들은 크기도 제각각 일 겁니다.Java 의 GC 는 이런 인스턴스들이 사용되다가 더 이상 사용되는 곳이 없어지면 mark 를 남겨두고, Collect 시점에 해당하는 것들을 지우고 메모리를 비워줍니다.
그런데, 이러한 방식도 결국 '파편화'의 문제에서 자유롭지 못합니다. 그래서 JVM 은 다음과 같은 전략을 구상했습니다. New 를 통해서 인스턴스가 새로 생성되면, 그 인스턴스는 Young Generation 으로 들어가게 됩니다.
Young Generation 은 안의 영역을 나눔으로써 시작되는데요.메모리가 쌓이고 이용되고, 파편화 발생하는 것은 그대로인데요. S0 공간이 다 찼거나, 새로운 인스턴스 크기가 큰데 메모리 공간이 없다면 S1 으로 이동합니다. S1 으로 덩치 큰 인스턴스를 우선 배정을 하고, 나머지 작은 인스턴스들을 S1 으로 따라서 보내서 S0 을 다시 빈 공간으로 만들어주는 것이죠.
JVM Young Generation 에서는 Mark & Compact 가 적용되면서 위와 같은 방식으로 동작하는데요. 힙 메모리 파편화의 해결 방법으로 참 좋은 방법 중 하나입니다.
이런 해결방법을 이용하겠다는 것은 나의 Application 에서 GC 을 직접 구현한다는 의미이며, JVM의 Young Generation 에서 적용되는 Mark & Compact 를 차용하겠다고 한다면 참 좋은 답변이 될 것 같습니다.
여기서 주의해야할 점은, GC 를 별도의 스레드로 구현을 할 텐데요. 이 경우 문제가 있습니다. 참조하고 있는 대상 자체에 대한 의존성을 갖는 녀석이 있을 수 있는데, 이 경우 참조 당하는 녀석의 메로리를 옮김으로서 참조에서 문제가 발생할 수 있습니다. 그런 부분들에 대한 동기화 부분이 중요한 문제가 될 것입니다.
또 다른 방법으로는 Best fit, Worst fit 전략이 있는데요. 이것은 메모리 효율성은 증가하나 반응 속도는 늦어집니다. 그래서 설계에 관한 문제이므로 각자 어플리케이션에 맞는 전략을 취하면 될 것입니다.