CLR(Common Language Runtime)의 가비지 컬렉션(Garbage Collection)의 작동 원리를 파악한다는 것은 매우 중요한 일입니다. 가비지 컬렉션의 작동 방식을 충분히 파악해야만 어플리케이션의 메모리 문제를 해결할 수 있거나 왜 Dispose 코딩 패턴의 코드가 그 따구로 생겼는지 이해할 수 있기 때문입니다. 이 글은 월간 마이크로소프트웨어(일명 마소) 2004년 10월호 닷넷 칼럼에 기고한 글을 다시 재정리 한 것입니다. 원본 글은 오래되었고 닷넷 프레임워크 1.1 기준으로 작성되었던 것이기 때문에 이번에 새로이 리뉴얼 하는 것입니다. 리뉴얼 한 글은 여러 회에 나누어 게시될 것이고 내용이 상당히 보완될 것입니다.
글은 예전 글들처럼 조낸 싸가지 없이 작성할 겁니다. 뭐 이렇게라도 글 쓰는 재미가 있어야 하기에…
Review .NET Garbage Collection
닷넷의 기초중의 기초로서 가비지 컬렉션(Garbage Collection; GC)은 매우 중요한 개념 중 하나이다. 닷넷 프로그래밍을 하는 사람은 누구나 가비지 컬렉션에 대해 들어봤을 것이며 이것에 의존해서 프로그램을 작성하고 있을 것이다. 가비지 컬렉션이란 말을 처음 들어본 독자는 일단 조용히 숏을 잡고 3분간 반성을 한 후에 이 글을 천천히 곱씹으면서 읽어 보길 바란다. 항상 그러하듯이 이 글도 쉬운 내용은 아니다. (난 경고 해쓰~)
다시 본론으로 돌아와서… 닷넷이나 자바와 같이 가비지 컬렉션에 의존하는 플랫폼은 개발자의 메모리 관리 부담을 확연히 덜어준다. 하지만 정말로 가비지 컬렉션이 메모리 관리에 대한 모든 고려 사항을 없애주는 것일까? 무식하면 용감하다는 말이 있다. 가비지 컬렉션이야말로 무식하면 용감해질 수 있는 좋은 예제 중 하나일 수 있다. 가비지 컬렉션은 도깨비 방망이가 아니다. 내용을 잘 알고 잘 사용하면 편리한 것이지만 무턱대고 가비지 컬렉션을 믿었다가 낭패를 볼 수 있다.
Garbage Collection 기초
먼저 닷넷의 가비지 컬렉션의 기본적인 작동방식을 살펴보도록 하자. CLR(Common Language Runtime)의 가비지 컬렉터(garbage collector)가 메모리를 할당하는 방식과 가비지 컬렉션이 발생했을 때 메모리 해제를 하는 방식을 먼저 살펴본 후에 닷넷 가비지 컬렉션이 사용하는 세대별 가비지 컬렉션(Generational Garbage Collection), Finalizer 와 가비지 컬렉션의 관계 등을 차근차근 살펴 보도록 할 것이다.
New/Malloc 의 세계
가비지 컬렉션을 제공하지 않는 C/C++ 환경에서의 메모리 할당/해제(allocation/free)는 프로그래머의 몫 이였다. C/C++ 런타임 라이브러리는 유명한 new 연산자 혹은 malloc 함수를 통해 메모리를 할당했으며 delete 연산자 혹은 free 함수를 통해 메모리를 해제 했다. 기존 메모리 할당 방식은 힙(heap; 닷넷이 아닌 환경에서 힙을 구분하여 unmanaged heap이라 한다)에 자유메모리 블록(free memory block)을 런타임 라이브러리(runtime library)가 유지함으로써 관리되었다. 즉, C/C++ 런타임 라이브러리는 힙 상에서 사용 가능한 메모리 블록의 리스트를 유지하고, 메모리 할당 요청(new 혹은 malloc)이 있을 때마다 메모리 블록 리스트를 검색하여 요청된 크기의 메모리를 할당할 메모리 블록을 찾는다. 메모리 해제 요청(delete 혹은 free)이 있을 경우 할당된 메모리는 다시 사용 가능한 메모리 리스트에 삽입되게 된다.
이렇게 메모리 블록 리스트를 유지하는 C/C++의 기존 메모리 관리 방식은 메모리 할당에 소요되는 시간이 상대적으로 길다. 메모리 할당/해제가 반복적으로 일어남에 따라서 힙의 메모리 사용 패턴은 조각(fragment)나기 쉽고 조각난 메모리는 메모리 할당 시 자유 메모리 블록을 검색해야 하는 오버헤드를 갖기 때문이다. 메모리 해제 역시 오버헤드를 갖게 되는데, 메모리가 해제 될 때 인접한 자유 메모리 블록을 검사해야 하고 만약 존재한다면 인접한 메모리 블록을 병합(merge)하여 보다 큰 메모리 블록으로 만들어야 한다. 말로만 하면 이해가 잘 안 되니 구체적으로 예를 들어 보자.
그림 1은 C 런타임 라이브러리가 메모리를 할당하고 해제하는 과정을 개념적으로 표시한 그림이다. 앞서 설명한 대로 힙 상의 사용 가능한 메모리 블록들은 리스트로서 관리된다. 맨 위의 힙 상황에서 4KB의 메모리 할당이 요구되면 런타임 라이브러리는 자유 메모리 블록 리스트를 검색하며 4KB 메모리를 수용할 수 있는 자유 메모리 블록을 찾는다. 그림 1의 경우 D 블록이 해당되는 블록이며 D 블록은 새로이 할당된 메모리 블록 G와 사용 가능한 메모리 블록 H로써 분할 되게 된다. 이 상황에서 메모리 블록 C가 해제(free)되면 C와 인접한 B블록과 병합되어 새로운 자유 메모리 블록 I가 생성되게 된다. 졸라 복잡하게 들릴지도 모르지만 그림을 눈이 뚫어져라 쳐다보면, 닭이 아닌 이상 이해가 갈 것이라 믿는다.
그림1. C/C++ 런타임 라이브러리의 메모리 할당과 해제
그림 1과 같은 메모리 할당/해제 방식은 메모리 블록의 할당과 해제가 잦아질수록 그리고 할당하고자 하는 메모리 블록의 크기가 작을수록, 사용중인 메모리블록과 사용 가능한 메모리 블록이 작은 메모리 조각으로 쪼개어 지는 메모리 조각 현상이 두드러지게 된다. 메모리 조각 현상은 전체 사용 가능한 힙의 크기가 충분함에도 불구하고 큰 메모리 블록의 할당을 저해하는 요소로도 작용하기 때문에 문제가 될 수도 있다. 메모리 할당은 파일 블록과는 다르게 할당이 요구된 크기 내에서는 연속적이어야 하기 때문이다(위 그림에서 8K 메모리를 할당하기 위한 유일한 자유 메모리 블록은 F 뿐이다). 이러한 메모리 할당/해제 방식은 C/C++ 런타임 라이브러리 뿐만 아니라 운영체제와 같은 일반적인 메모리 관리 코드들이 사용하는 기법이다. 물론, 이들 메모리 관리 코드들은 보다 빠르고 효율적으로 메모리를 할당하고 해제하기 위해 다양한 최적화 알고리즘을 사용하기도 한다. 하지만 여전히 메모리 할당에 소요되는 시간은 자유 메모리 블록을 검색을 요구 한다.
Welcome to GC world
닷넷의 가비지 컬렉터(이하 GC)의 작동 방식은 선형 메모리 할당(linear memory allocation)과 사용하지 않는 메모리 블록을 찾아 제거하는 형태로 이루어 진다. 선형 메모리 할당이라 함은 C/C++와 같은 자유 메모리 블록 리스트를 사용하지 않고 다음 메모리 할당을 위한 포인터(NextObjPtr)만을 유지하는 것을 말한다. 따라서 객체에 대한 메모리 할당이 이루어지면 단순히 이 포인터 값을 할당할 크기만큼 증가 시키고 할당한 메모리를 0으로 초기화 하는 것에 지나지 않는다. 조낸 간단하지 않은가? 그림 2는 이와 같은 과정을 보여주고 있다.
그림2. GC의 메모리 할당
GC의 메모리 할당은 C/C++의 그것과 비교해 보았을 때 매우 빠르다. C/C++처럼 자유 메모리 블록을 검사할 필요 없이 단순히 포인터 값을 증가 시키는 것이 전부이므로 메모리 할당이 C/C++에 비교해 볼 때 매우 빠른 것이다. 할당한 메모리를 0으로 초기화 하는 것 역시 CPU가 제공하는 명령(instruction)을 통해 매우 빠르게 수행할 수 있기 때문에 걱정 거리 조차 되지 않는다. 더욱이 훌륭한 것은 메모리 할당 시에 메모리 조각이 발생하지 않으므로 메모리 할당 성능은 더욱 좋아지게 되는 것이다.
닷넷 환경에서는 매우 다양하고 많은 객체를 사용하게 된다. 단순히 문자열만을 생각하더라도 System.String 객체는 불변(immutable)의 객체 이므로 문자열 연산(concatenation)의 결과는 항상 새로운 String 객체가 된다. 때문에 작은 크기의 객체들이 힙 상에 아주 많이 존재하게 된다. 이렇게 작고 잦은 메모리 할당에서 최적의 성능을 내기 위해서는 메모리 조각(fragment)이 발생하지 않는 방식의 메모리 할당을 필요로 하는 것이다.
그렇다면 GC는 어떻게 메모리 해제를 수행하는 것일까? 많은 독자들이 알겠지만 GC는 특정 조건(힙의 사용 가능한 영역이 특정 수준 이하로 줄어든다든가 등등)을 만족하는 상황이 되면 현재 수행중인 쓰레드(thread)들을 모두 중단시키고 GC 쓰레드를 활성화(평소에는 아무런 작업 없이 잠들어 있는 쓰레드이다)한다. GC 쓰레드는 힙 상에서 사용 중인 객체들의 그래프를 생성하고 사용 중인 객체의 위치를 재조정(relocate; compaction)함으로써 사용하지 않는 객체들을 힙 상에서 제거한다. GC 쓰레드와 다른 일반 쓰레드가 수행/중단 되는 상황은 어플리케이션이 어떤 GC 모드를 사용하고 있는가에 따라서 다르다. 나중에 별도의 포스트로 이야기 하겠지만 궁금해 미칠 것 같은 성격 급한 독자들은 MSDN에서 Fundamentals of Garbage Collection 글을 참고하기 바란다.
앞서 GC 쓰레드가 사용 중인 객체들의 그래프를 생성한다고 했었다. 말이 간단하지 객체가 현재 사용 중이라는 것을 GC가 어떻게 알아낼까? GC는 객체의 참조 그래프(reference graph)를 만듦으로써 이를 해결한다. 참조 그래프를 만들기 위해서는 루트 참조가 필요한데, 루트 참조에 해당하는 것은 현재 각 쓰레드가 수행중인 메서드의 로컬변수(스택 변수), 그리고 CPU 레지스터 변수가 가지고 있는 참조, 그리고 현재 사용중인 각 타입(클래스)의 정적 필드(static field), 전역 변수 등이 되겠다. 이 루트 참조를 출발점으로 해서 각 루트 참조가 참조하는 객체, 그리고 다시 그 객체가 참조하는 다른 객체들을 참조 그래프에 추가함으로써 현재 사용중인 객체의 그래프를 작성하는 것이다. 참 쉽죠잉?
객체 참조 그래프가 완성되면 이 그래프에 포함되지 않은 모든 객체는 현재 사용 중이 아닌, 즉 이 객체에 대한 참조가 존재하지 않는 객체가 되며 가비지 컬렉션의 대상이 되는 것이다. 실제로 GC는 이들 가비지 컬렉션의 대상이 되는 객체에 대해서 특별한 작업을 수행하지 않는다. 실제 작업은 참조 그래프상의 객체들을 힙 상에서 재배치 하고 메모리 할당 포인터를 감소시킴으로써 가비지 컬렉션을 수행하는 것이다. 이러한 과정을 메모리 컴팩션(compaction)이라고 한다. 역시 말로만 하면 이해가 안 되는 법. 구체적인 예를 그림을 통해 살펴보자.
그림 3의 위쪽 부분은 루트 참조와 힙 상의 객체들의 참조 관계를 나타내고 있다. 루트 참조가 객체 A, E를 참조하고 있으며 A, E 객체는 각각 객체 C, F를 다시 참조하고 있다. 이 때 GC가 수행되면 가비지 컬렉션의 대상이 되는 객체는 B와 D가 되는 것이다. GC는 참조 그래프 상의 객체들의 위치를 재조정하고 메모리 할당 포인터를 조정하며, 그리고 각 참조 값들을 변경된 위치로 변화시키는 작업을 포함한다 (GC 수행 후, 메모리 컴팩션에 의해 객체 C, E, F의 물리적 위치가 바뀌게 되므로 해당 참조값 역시 변화되어야 한다). 참조값을 변경하는 것이 복잡하고 오랜 시간이 소요되는 것처럼 느껴지지만 GC는 참조 그래프를 생성하면서 필요한 정보를 모두 보유하고 있으므로 생각보다 느리지 않게 작업을 수행한다. 이 작업은 매우 빨라서 200MHz의 팬티엄 PC에서 조차 채 0.001초가 소요되지 않았다고 한다. 필자의 인텔 i7 820 (1.7~3.0GHz) CPU와 1333MHz DDR3 메모리라면 훨씬 더 빠르게 작업하지 않을까 싶다.
These tests also show that it takes less than 1 millisecond on a 200Mhz Pentium to perform a full GC of generation 0. It is Microsoft's goal to make GCs take no more time than an ordinary page fault. – MSDN Magazine, December 2000, Garbage Collection-Part 2: Automatic Memory Management in the Microsoft .NET Framework
그림3. 가비지 컬렉션의 수행 과정과 메모리 컴팩션
다음 글에서는…
이번에는 닷넷 가비지 컬렉션의 기본적인 작동 방식을 살펴보았으니 다음 칼럼에서는 조금 더 고급 주제로써 소위 세대별 가비지 컬렉션(Generational Garbage Collection)에 대해서 살펴보도록 하겠다.
경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.