이 글은 오래된 전에 작성된 글입니다. 따라서 최신 버전의 기술에 알맞지 않거나 오류를 유발할 수 있습니다.
저자는 이 글에 대한 질문을 받지 않을 것입니다. 하지만 이 글이 리뉴얼 되면 이 글에 대한 질문을 하거나
토론을 할 수도 있습니다.
이 글은
월간 마이크로소프트웨어(일명 마소) 2004년 10월호 닷넷 칼럼에 기고한 글입니다. 여러모로 도움이 되리라 생각하여 HTML 화 하였습니다. 여기에 실린 글은 마소 편집부에서 편집하기 전의 원본 글이며 약간의 수정을 가했습니다. 항상 그러하듯(-_-) 스크롤의 압박이 예상되므로 여유를 갖고 읽어 보시길...
.NET Garbage Collection 돌아보기
필자가 컨설팅을 나가게 되면 곧잘 닷넷 관련 교육을 하는 경우가 많다. 이때 수강생들이 강의 내용을 얼마나 잘 흡수하는가는 그 사람의 기본이 얼마나 잘 되어있는가에 따라 확연히 달라진다. 기초가 잘 갖추어진 사람은 그 기초하에 새로이 배우는 내용이 어떻게 달라졌는가를 빨리 잡아내며, 새로운 컨셉의 적응 역시 매우 빠르다. 프로그램을 작성할 때 역시 기초가 튼튼한 사람은 원리로부터 코드를 생성하며 정확하게 문제를 집어내는 경우가 많다. HTTP 프로토콜을 알고 웹 개발을 하는 사람과 TCP/IP와 HTTP를 모두 알고 웹 개발을 하는 사람, 그리고 이들 프로토콜에 대해서 전혀 모르고 웹 개발을 하는 사람의 코드나 문제 해결 능력이 차이가 남은 분명하다.
이번 칼럼은 닷넷의 기초중의 기초로서 가비지 컬렉션(Garbage Collection; GC)에 대해 살펴보도록 하겠다. 닷넷 프로그래밍을 하는 사람은 누구나 가비지 컬렉션에 대해 들어봤을 것이며 이것에 의존해서 프로그램을 작성하고 있을 것이다. 닷넷이나 자바와 같이 가비지 컬렉션에 의존하는 플랫폼은 개발자의 메모리 관리 부담을 확연히 덜어준다. 하지만 정말로 가비지 컬렉션이 메모리 관리에 대한 모든 고려 사항을 없애주는 것일까? 무식하면 용감하다는 말이 있다. 가비지 컬렉션이야말로 무식하면 용감해질 수 있는 좋은 예제 중 하나일 수 있다. 가비지 컬렉션은 도깨비 방망이가 아니다. 내용을 잘 알고 잘 사용하면 편리한 것이지만 무턱대고 가비지 컬렉션을 믿었다가 낭패를 볼 수 있다.
이번 칼럼에서는 가비지 컬렉션의 작동 방식과 가비지 컬렉션을 이해하는데 도움이 되는 도구들, 그리고 조심해야 할 메모리 사용 패턴을 몇 가지 살펴보도록 하겠다.
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)가 유지함으로써 관리되었다. 즉, 런타임 라이브러리는 힙 상에서 사용 가능한 메모리 블록의 리스트를 유지하고, 메모리 할당 요청(new 혹은 malloc)이 있을 때마다 메모리 블록 리스트를 검색하여 요청된 크기의 메모리를 할당할 메모리 블록을 찾는다. 메모리 해제 요청(delete 혹은 free)이 있을 경우 할당된 메모리는 다시 사용 가능한 메모리 리스트에 삽입되게 된다.
이렇게 메모리 블록 리스트를 유지하는 기존 메모리 관리 방식은 메모리 할당에 소요되는 시간이 상대적으로 길다. 메모리 할당/해제가 반복적으로 일어남에 따라서 힙의 메모리 사용 패턴은 조각(fragment)나기 쉽고 조각난 메모리는 메모리 할당 시 자유 메모리 블록을 검색해야 하는 오버헤드를 갖기 때문이다. 메모리 해제 역시 오버헤드를 갖게 되는데, 메모리가 해제 될 때 인접한 자유 메모리 블록을 검사해야 하고 만약 존재한다면 인접한 메모리 블록을 병합(merge)하여 보다 큰 메모리 블록으로 만들어야 한다.
그림 1은 C 런타임 라이브러리가 메모리를 할당하고 해제하는 과정을 개념적으로 표시한 그림이다. 앞서 설명한 대로 힙 상의 사용 가능한 메모리 블록들은 리스트로서 관리된다. 맨 위의 힙 상황에서 3KB의 메모리 할당이 요구되면 런타임 라이브러리는 자유 메모리 블록 리스트를 검색하며 4KB 메모리를 수용할 수 있는 자유 메모리 블록을 찾는다. 그림 1의 경우 B 블록이 해당되는 블록이며 B 블록은 새로이 할당된 메모리 블록 F와 사용 가능한 메모리 블록 G로서 분할 되게 된다. 이 상황에서 메모리 블록 C가 해제(free)되면 C와 인접한 B블록과 병합되어 새로운 자유 메모리 블록 H가 생성되게 된다.
<<그림1>> 런타임 라이브러리의 메모리 할당과 해제
그림 1과 같은 메모리 할당/해제 방식은 메모리 블록의 할당과 해제가 잦아질수록, 그리고 메모리 블록의 크기가 작을수록, 작은 크기의 사용중인 메모리블록과 사용 가능한 메모리 블록으로 쪼개어 지는 메모리 조각 현상이 두드러지게 된다. 메모리 조각 현상은 전체 사용 가능한 힙의 크기가 충분함에도 불구하고 큰 메모리 블록의 할당을 저해하는 요소로도 작용된다. 메모리 할당은 파일 블록과는 다르게 할당이 요구된 크기 내에서는 연속적이어야 하기 때문이다.
Welcome to GC world
닷넷의 가비지 컬렉터(이하 GC)의 작동 방식은 선형 메모리 할당과 사용하지 않는 메모리 블록을 찾아 제거하는 형태로 이루어 진다. 선형 메모리 할당이라 함은 C/C++와 같은 자유 메모리 블록 리스트를 사용하지 않고 다음 메모리 할당을 위한 포인터(NextObjPtr)만을 유지하는 것을 말한다. 따라서 객체에 대한 메모리 할당이 이루어지면 메모리 할당을 위한 작업은 단순히 포인터 값을 증가 시키고 메모리를 초기화하는 것에 지나지 않는다. 그림 2는 이와 같은 과정을 보여주고 있다.
<<그림2>> GC의 메모리 할당
GC의 메모리 할당은 C/C++의 그것과 비교해 보았을 때 매우 빠르다. C/C++ 처럼 자유 메모리 블록을 검사할 필요 없이 단순히 포인터 값을 증가 시키는 것이 전부 이므로 메모리 할당이 C/C++에 비교해 볼 때 매우 빠른 것이다. 또한 메모리 할당에서 조각이 발생하지 않으므로 메모리 할당 성능은 더욱 좋아지게 되는 것이다.
닷넷 환경에서는 매우 다양하고 많은 객체를 사용하게 된다. 단순히 문자열만을 생각하더라도 System.String 객체는 불변(immutable)의 객체 이므로 문자열 연산(concatenation)의 결과는 항상 새로운 String 객체가 된다. 때문에 작은 크기의 객체들이 힙 상에 아주 많이 존재하게 된다. 이렇게 작고 잦은 메모리 할당에서 최적의 성능을 내기 위해서는 메모리 조각(fragment)이 발생하지 않는 방식의 메모리 할당을 필요로 하는 것이다.
그렇다면 GC는 어떻게 메모리 해제를 수행하는 것일까? 많은 독자들이 알겠지만 GC는 특정 조건(힙의 사용 가능한 영역이 특정 수준 이하로 줄어든다든가 등등)을 만족하는 상황이 되면 현재 수행중인 쓰레드들을 모두 중단시키고 GC 쓰레드를 활성화(평소에는 아무런 작업 없이 잠들어 있는 쓰레드이다)한다. GC 쓰레드는 힙 상에서 사용 중인 객체들의 그래프를 생성하고 사용 중인 객체의 위치를 재조정(relocate; compaction)함으로써 사용하지 않는 객체들을 힙 상에서 제거한다.
말은 간단하지만 객체가 현재 사용 중이라는 것을 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는 참조 그래프를 생성하면서 필요한 정보를 모두 보유하고 있으므로 생각보다 느리지 않다.
<<그림 3>> Garbage Collection 수행 과정
고급 Garbage Collection
지금까지 닷넷 CLR에서 제공하는 GC의 기본적인 작동 방식을 살펴보았다. Microsoft의 .NET CLR 팀은 GC의 성능 향상 및 효율을 극대화하기 위해 여러 가지 최적화를 사용하고 있다. 이제 GC에 사용된 최적화 몇 가지를 살펴보기로 하자.
Generational Garbage Collection
닷넷의 GC의 최적화 기법 중 대표적인 것은 세대별 가비지 컬렉션(generational garbage collection)이라 불리는 것으로서 힙 상의 객체를 객체의 생존 시간(live time)을 기준으로 객체를 몇 세대로 구분하여 가비지 컬렉션을 수행하는 것을 말한다. 즉, 최근에 생성된 객체는 0 세대(Gen 0)가 되며 1회의 GC 동안 살아남은(?) 객체는 1세대(Gen 1), 2회 이상의 GC 동안 살아남은 객체는 2세대(Gen 2)가 되는 방식으로 가비지 컬렉션이 반복되는 동안 객체가 계속 나이를 먹는 방식을 말한다. 현재 닷넷 프레임워크의 버전에서 최고의 세대는 2세대이며 2세대의 객체들은 GC가 수행되는 동안 계속 2세대에 남게 된다.
닷넷의 GC는 0 세대에 대해 집중적으로 가비지 컬렉션을 수행한다. 즉, 기본 가비지 컬렉션은 0 세대에 대해서만 이루어지며 1 세대, 2세대의 객체에 대해서는 가비지 컬렉션을 수행하지 않는다. 이렇게 0 세대에 대해 집중적으로 가비지 컬렉션을 수행하면 GC의 성능과 효율을 올릴 수 있는데, 그 근거는 다음과 같다.
- 최근 생성된 객체일수록 생명주기는 짧다.
(작은 객체일수록 생성된 후 짧은 시간 동안 사용되고 더 이상 사용되지 않는다)
- 오래된 객체일수록 생명주기는 길다.
- 최근에 생성된 객체들끼리는 서로 연관성이 높으며(참조 관계) 비슷한 시점에서 자주 액세스 된다.
- 일부분의 힙을 가비지 컬렉션 하는 것은 전체를 가비지 컬렉션 하는 것보다 빠르다.
이 가정은 Microsoft 독자만의 연구가 아니라 학계에 다양한 연구의 결과이다. 독자들이 프로그램을 작성할 때, 메모리 사용 패턴을 곰곰히 생각해 보라. 아마도 위 가정하고 얼추 맞아 떨어질 것이다.
세대별 가비지 컬렉션을 보다 구체적으로 예를 들어 살펴보면 그림 4와 같다. 기본적으로 new(혹은 CreateInstance 등의 객체 생성 메쏘드들)를 통해 새로이 생성된 객체들은 항상 0 세대가 된다. 이후 CLR에 의해 GC가 발생하면 0 세대에 대해서만 GC가 발생한다. 앞서 설명한 것과 같이 객체의 참조 그래프가 만들어지고 객체들이 컴팩션(compaction)되면 위에서 두 번째의 그림과 같은 힙이 된다. 이때 0 세대의 GC동안 살아남은 객체 A, C, E, F 객체는 모두 1 세대로서 승급되게 되는 것이다. 이 이후 추가적으로 객체 할당이 진행되면 새로운 객체들은 다시 0 세대 객체로서 할당되게 된다(3 번째 그림). 이렇게 0 세대와 1 세대가 공존하는 상황에서 다시 GC 가 발생하면 재미있는 일이 발생한다. 세 번째 그림에 의하면 GC의 대상이 되는 객체는 E, I, J 객체이다. 하지만 GC는 0 세대에 대해서만 발생했기 때문에 1 세대의 객체인 E는 가비지 컬렉션이 되지 않는다. 결론적으로 마지막 그림처럼 I, J 객체가 메모리 컴팩션에 의해 사라지게 되고 H, K 객체는 GC 동안 살아 남아 1세대로 승급하게 된다.
<<그림 4>> Generational Garbage Collection (GC 0 Operation)
그렇다면 1세대에 존재하는 객체는 절대로 메모리에서 사라지지 않는 것일까? 그렇지 않다. 시간이 흘러감에 따라 1 세대의 크기도 점차로 커질 것이며 힙의 사용 가능한 메모리 영역 역시 줄어들 것이다. 닷넷 GC는 충분히 영리하다. 0 세대가 사용할 메모리 공간이 줄어들고 1 세대 늘어남에 따라 CLR 내부에 결정된 특정 한계에 도달하면(이 한계점은 버전마다 달라질 수 있으며 문서화되어 있지도 않다) GC는 0세대와 1세대에 대해서 가비지 컬렉션을 수행한다. 이렇게 0 세대에 대해서만 수행하는 가비지 컬렉션을 GC 0라고 하고 0 세대와 1 세대에 대해서 가비지 컬렉션을 수행하는 것을 GC 1 이라고 한다. GC 1 동안 살아남은 1 세대의 객체는 2 세대로 승급하며 GC 1 동안 살아남은 0 세대의 객체는 1 세대로 승급한다.
그림 5는 GC 1의 작동 방식을 보여주고 있다. 그림 4의 마지막 힙 상황에서 L, M, N, O 객체가 추가로 할당되었고 A, E, N 객체가 더 이상 사용되지 않는다고 가정해 보자. 이때의 힙 상황이 그림 5의 맨 위의 그림이다. 이 상황에서 GC가 힙의 부족으로 가비지 컬렉션을 해야 한다고 판단했고 1 세대의 가비지 컬렉션이 수행되어야 한다고 판단했다면, 가비지 컬렉션의 결과는 그림 5의 두 번째 힙 상황이 되게 된다. 1세대에 남아있던 A, E 객체 역시 가비지 컬렉션 되어 힙 상에서 사라졌음을 주의하기 바란다. 또한 1세대 객체였던 C, F, H, K 객체가 2 세대로 승급되었음 역시 주목해야 한다. 이 이후 추가적으로 P, Q, R 이 할당되었고 GC 1 (1세대 가비지 컬렉션)가 수행되었다고 가정하면, 그림 5의 맨 마지막 힙 상황이 된다. F 객체는 2 세대에 존재하고 더 이상 사용 중이 아니지만 가비지 컬렉션의 대상이 되지 않았음에 유의하기 바란다. 그림 5의 예제에서는 GC 1이 연속 2회 발생한 것을 보였지만 실제의 경우 GC 1이 연속 2회 발생하는 경우는 매우 드물다. GC 1이 발생한 이후에 객체가 계속 할당되면 새로운 객체들은 0 세대에 생성될 것이며 GC는 수 차례에 걸쳐 GC 0을 반복한 이후에야 GC 1을 수행할 것이다.
<<그림 5>> GC 1 Operation
2 세대에 존재하는 객체들은 GC가 2세대를 가비지 컬렉션 하기 전까지는 힙에 남는다. 비슷하게 GC가 2세대를 가비지 컬렉션 하는 상황은 힙 상에 사용 가능한 공간이 GC 1을 수행한 이후에도 부족한 경우에 발생한다. 2세대 가비지 컬렉션은 항상 0세대 및 1 세대 가비지 컬렉션을 포함한다. 2세대 가비지 컬렉션을 GC 2 혹은 풀 가비지 컬렉션이라고 한다. 풀 가비지 컬렉션은 전체 힙에 대한 가비지 컬렉션을 의미한다. 2세대에 존재하는 객체들이 GC 2를 통해 살아 남았다면 그 객체들은 2세대 이상의 세대가 존재하지 않기 때문에 계속 2세대에 남게 됨은 물론이다. 그림 5의 마지막 힙 그림에서 GC 2가 발생한다면 F가 힙에서 사라지게 되며 C, H, K, L, O, Q, R 이 모두 2세대에 남게 될 것이다. 리스트 1의 코드는 세대별 가비지 컬렉션을 확인하는 코드이다. GC 클래스의 Collect 메쏘드는 프로그램적으로 가비지 컬렉션을 강제하는 메쏘드이며 GetGeneration 메쏘드는 주어진 객체의 세대를 반환한다. 최초의 객체 할당은 0 세대에, 그리고 가비지 컬렉션이 발생함에 따라서 객체가 0세대에서 1세대로, 1세대에서 2 세대로 바뀌어 가는 것을 확인할 수 있다.
object obj1 = new object(); // Gen 0에 객체 할당
Console.WriteLine("\nAllocation obj1 ..................................\n");
Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
GC.Collect(0); // Gen 0 Collection (default GC behavior)
Console.WriteLine("GC 0 ---------------------"); // will be 1
Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
GC.Collect(1); // Gen 1 Collection
Console.WriteLine("GC 1 ---------------------");
Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
GC.Collect(2); // Gen 2 Collection (full collection)
Console.WriteLine("GC 2 ---------------------");
Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
<<리스트 1>> Generational GC 확인
2 세대 객체들이 가비지 컬렉션 되는 주기는 상당히 길다. GC는 1 세대 가비지 컬렉션 조건이 만족될 때까지 0 세대 가비지 컬렉션을 반복할 것이고 여러 회의 1 세대 가비지 컬렉션이 수행된 이후라야 GC 2가 수행될 것이기 때문이다. 이는 닷넷 GC가 앞서 언급한 대로, “최근 생성된 대부분의 객체는 임시적 성격이 강하며 그렇지 않은 객체는 오랫동안 사용된다”는 가정에 충실 따르고 있음 알 수 있다. 세대별 가비지 컬렉션은 대부분의 경우 긍정적으로 적용되지만 부주의한 코드는 역효과를 낼 수도 있다. 이러한 상황에 대한 구체적인 예는 잠시 후에 상세히 살펴보기로 하겠다.
Weak Reference
닷넷에서 객체에 대한 참조는 강력한 참조(strong reference)와 약한 참조(weak reference)로 나뉘어 진다. 강력한 참조는 일반적인 객체 참조로서 지금까지 우리가 다루어온 참조를 말한다. 약한 참조는 System.WeakReference 클래스를 통한 참조로서 약한 참조에 의해 참조되는 객체는 루트 참조로부터 참조 그래프 내에 포함되어 있더라도 가비지 컬렉션의 대상이 된다. 따라서 약한 참조에 의해 참조되는 객체는 항상 그 객체가 실제로 살아 있는지를 검사해야 한다. WeakReference 클래스의 IsAlive 프로퍼티는 약한 참조에 의해 참조되는 객체가 힙 상에 아직 살아 있는가를 반환한다. 객체가 살아 있다면 WeakReference 클래스의 Target 프로퍼티로부터 강력한 참조를 얻은 후에 접근이 가능하다. 약한 참조의 주된 용도는 캐시이다. 언제 사용될지 모르는 (심지어 전혀 다시 사용되지 않을 수도 있는) 데이터 객체를 계속 힙 상에 상주 시키는 것 보다 약한 참조를 생성해 두는 것이다. 나중에 이 객체에 접근하고자 한다면 WeakReference.IsAlive를 통해 객체가 가비지 컬렉트 되었는지 확인하고 만약 힙에 존재하지 않는다면 다시 생성하면 될 것이다. 다음 코드 조각은 전형적인 WeakReferece 클래스를 사용하는 용법을 보여주고 있다.
SomeType obj = new SomeType();
WeakReference ref = new WeakReference(obj);
// … 다른 로직 등등..
SomeType obj;
if (ref.IsAlive) {
obj = (SomeType)ref.Target;
}
else {
// 객체 다시 생성
obj = new SomeType();
}
// obj 변수를 통해 대상 액세스
리스트 2는 약한 참조를 통해 세대별 가비지 컬렉션이 작동하는 것을 보여주는 예제이다. Obj2 객체가 생성되고 GC 0를 한번 수행함으로써 obj2 객체는 1 세대에 상주한다. 그리고 obj2 변수에 null을 할당함으로써 강력한 참조를 제거했다. 이제 obj2는 가비지 컬렉션의 대상이 된다. 하지만 GC 0 가 발생하면 obj2 객체는 여전히 힙에 남아 있게 된다. Obj2 는 GC 1 혹은 GC 2 가 발생한 다음에야 힙에서 제거된다.
object obj2 = new object();
Console.WriteLine("\nAllocation obj2 ..................................\n");
WeakReference wref = new WeakReference(obj2);
GC.Collect(0); // obj2는 이제 Gen 1에 존재
Console.WriteLine("GC 0 ---------------------------------------------");
obj2 = null; // root reference에서 제거 (obj2는 GC 대상임)
Console.WriteLine("Is obj2 alive ? : {0}", wref.IsAlive); // true
GC.Collect(0); // 다시 Gen 0 GC
Console.WriteLine("GC 0 ---------------------------------------------");
Console.WriteLine("Is obj2 alive ? : {0}", wref.IsAlive); // true
GC.Collect(1); // Gen 1 Collection
Console.WriteLine("GC 1 ---------------------------------------------");
Console.WriteLine("Is obj2 alive ? : {0}", wref.IsAlive); // false
<<리스트 2>> WeakReference를 통한 세대별 가비지 컬렉션 확인
Finalizer와 가비지 컬렉션
닷넷에는 C++의 파괴자(destructor)에 해당하는 기능이 없다. 다만 그와 비슷한, 하지만 내용이 많이 다른 Finalizer를 갖는다. 대부분의 독자들이 Finalizer가 무엇인지는 잘 알 것이므로 더 이상 설명하지 않겠다. Finalizer는 가비지 컬렉션과 관계가 깊다. Finalizer가 수행되는 시점이 바로 가비지 컬렉션이 수행되는 시점이기 때문이다. GC는 가비지 컬렉션의 결과로 더 이상 참조되지 않는 객체들을 제거할 때 해당 객체의 클래스가 Finalizer를 정의하고 있는가 검사한다(좀더 엄밀히 말해 Finalizer가 호출되어야 하는지 검사한다. GC.SuppressFinalizer() 메쏘드 호출을 통해 Finalizer의 호출을 막을 수 있다). 만약 그렇다면 그 객체는 곧바로 힙 상에서 제거할 수 없는 객체가 되고 만다. 왜냐 하면 Finalizer가 수행된 이 후에야 그 객체를 완전히 제거할 수 있기 때문이다.
GC는 Finalizer 처리를 위해 내부적으로 Freachable(F ? reachable) 큐를 유지한다. 가비지 컬렉션이 수행될 때, Finalizer를 갖는 클래스의 인스턴스가 제거되어야 한다면 이 객체를 곧바로 제거하지 않고 Freachable 큐에 삽입한다. 그리고 GC는 Finalizer 수행을 위한 별도의 쓰레드를 수행시킨다. 이 쓰레드는 Freachable 큐에서 객체를 꺼내어 하나씩 Finalizer를 수행하게 된다. 이러한 Finalizer의 작동방식은 잘 알고 있을 필요가 있다. 첫째로 Finalizer가 수행되는 쓰레드는 어플리케이션 쓰레드가 아니라는 점이다. 두 번째로 알아둘 것은 Finalizer가 수행되는 순서나 시기를 전혀 예측할 수 없다는 점이다.
Finalizer와 가비지 컬렉션의 관계를 그림 6이 보여주고 있다. 힙에 생성된 객체들 중 A, D, G (객체 위에 막대가 그려진 것들) 객체는 Finalizer를 갖고 있는 객체들이다. 이 상황에서 가비지 컬렉션이 발생하면 GC는 루트 참조로부터 참조 그래프를 생성할 것이며 A, C, E 는 이 참조 그래프에 포함될 것이다. 참조 그래프에 포함되지 않은 B, D, F, G 객체 들 중에서 Finalizer를 갖는 D, G 객체는 별도로 Freachable 큐에 삽입되게 되며, 이 큐는 힙 상의 객체에 대해 강력한 참조를 갖는다. Finalizer를 수행하기 위해 Freachable 큐에서 이들 객체에 대해 참조를 갖기 때문에 D, G 객체는 가비지 컬렉션의 대상에서 제외된다는 사실을 기억하자. 따라서 D, G 객체가 힙 상에서 제거되는 시점은 Finalizer 쓰레드가 객체의 Finalizer를 수행하고 그 객체를 Freachable 큐에서 제거 한 후, 다음에 발생되는 (언제 발생할지 모르는) 가비지 컬렉션 시점이 된다. 더욱이 D, G 객체는 가비지 컬렉션 동안 Finalizer 덕택에 살아 남았기 때문에 세대 역시 증가 된다. 0 세대 있던 객체라면 1 세대가 될 것이며 1 세대에 있던 객체라면 2 세대로 남게 될 것이다. 이렇게 세대가 증가 해 버림에 따라서 일반적으로 자주 발생하는 0 세대 가비지 컬렉션(GC 0)에 대해서는 D, G 객체는 여전히 힙 상에서 메모리를 점유하고 있게 된다.
<<그림 6>> GC와 Finalizer
일반적으로 다양한 문서들에서 Finalizer의 사용을 권장하지 않는 것을 알 수 있다. Finalizer가 수행되는 시점을 정확히 알 수 없는 등 다양한 이유에서 권장되지 않지만 가장 큰 이유 중 하나는 Finalizer가 가비지 컬렉션에 미치는 영향이 거의 쥐약 수준이기 때문이다. Finalizer를 갖는 객체는 비록 더 이상 사용 중이 아니더라도 가비지 컬렉션이 발생하면 기본적으로 1 세대로 승급되어 버린다. 그 이유는 그림 6에서 잘 알 수 있을 것이다. 일단 0 세대가 아닌 객체는 힙 상에서 완전히 제거되는데 오랜 시간이 소요된다. GC 0는 자주 발생할 수 있지만 GC 1이 발생되는 주기는 상대적으로 길기 때문이다. Finalizer를 갖는 객체는 기본적으로 힙 상에서 제거되는데 오랜 시간이 걸리기 마련이다.
가비지 컬렉션 관련 도구들
지금까지 다양한 가비지 컬렉션의 내용들을 살펴 보았다. 여기서 다룬 내용 외에도 Finalizer를 이용하여 가비지 컬렉션이 된 객체를 되 살리는 객체 부활(object resurrection), 세대간 참조를 해결하는 쓰기 장벽(write barrier), 80 KB 이상의 큰 객체들을 위한 Large Object Heap(C/C++와 비슷한 형태의 자유 메모리 리스트를 유지) 등의 이슈들이 존재한다. 지면 관계상 이들을 모두 다룰 수 없음을 독자들에게 사과하는 바이며 상세한 내용은 참고 문헌을 살펴보기 바란다.
닷넷의 GC가 어떻게 행동하는가를 테스트하거나 살펴보는 방법은 어떤 것이 있을까? 앞서 살펴보았지만 닷넷 프레임워크의 System.GC 클래스가 가장 기본적인 도구라고 할 수 있다. 이 클래스는 강제로 가비지 컬렉션을 발생시키는 Collect 메쏘드나, 객체가 어떤 세대에 존재하고 있는지 알려주는 GetGeneration 메쏘드, Disposing 패턴과 함께 사용되는 SupressFinalize 메쏘드 등의 기능을 제공한다.
또 다른 가비지 컬렉션 모니터링 도구는 윈도우 성능 카운터이다. 닷넷 프레임워크가 설치되면 닷넷에 관련된 다양한 성능 카운터가 설치되는데, 이들 중 한 그룹이 가비지 컬렉션이 발생하는 주기, 각 세대에 존재하는 객체들의 크기 등을 보고한다. 이 성능 카운터들을 통해 현재 어플리케이션이 어떻게 힙을 사용하고 있는지를 감시해 볼 수 있다.
이외의 도구로는 상용으로 판매되는 다양한 프로파일러들을 들 수 있겠다. 이들 프로파일러는 가비지 컬렉션과 관련되어 메모리가 어떻게 사용되고 있는가를 프로파일링하고 결과를 리포트 해준다. 만약 이러한 상용도구를 구입할 여건이 안 된다면 Microsoft가 예제(?)로서 제공하는 CLR Profiler를 사용할 수도 있다. CLR Profiler는 예제로서 소스까지 제공되지만 관련된 지원을 받을 수는 없다. 하지만 프로그램이 힙을 어떻게 사용하고 있는가에 대해서 그래픽 UI로서 상세한 정보를 제공한다. CLR Profiler에 대한 사용법은 참고 문헌을 살펴보기 바란다.
가비지 컬렉션의 고려사항들
이제까지 살펴본 가비지 컬렉션의 기본적인 작동 방식과 GC에 가해진 여러 가지 최적화를 잘 염두 해두면서 가비지 컬렉션이 갖는 장단점을 생각해보고, 가비지 컬렉션 환경에서 주의해야 할 몇가지 사항을 알아보도록 하자.
가비지 컬렉션의 장단점
GC의 메모리 할당 속도는 번개처럼 빠르다. 메모리 해제는 참조 그래프를 생성하고 객체의 위치를 변화시켜야 하며 참조 값 역시 모두 변경시켜야 하므로 C/C++ 의 메모리 해제 속도에 비해 느리다고 할 수 있다. 하지만 GC가 발생하는 주기가 C/C++ 의 메모리 해제 함수 호출 빈도보다 매우 낮으므로 전체적인 성능이 GC가 나쁘다고 말할 수는 없다.
이제 가비지 컬렉션을 사용하면 무엇이 좋은가를 생각해보자. CPU와 메모리 등 하드웨어의 속도는 갈수록 빨라지고 있다. 하지만 소프트웨어의 개발 속도는 하드웨어에 비하면 제자리 걸음이며 프로그램의 복잡도가 증가함에 따라 개발 생산성이 오히려 감소하기도 한다. 비록 가비지 컬렉션이 빠르다 할지라도 일정 시간의 시간이 소요되며 메모리 컴팩션에 소요되는 시간은 무시할 수는 없다. 하지만 CPU의 속도가 갈수록 빨라지며 CPU의 캐시가 갈수록 커지는 것을 고려해 본다면 가비지 컬렉션은 빠른 하드웨어의 성능을 충분히 활용하는 것이 된다. 더욱 더 중요한 것은 낮은 소프트웨어 개발 생산성에서 개발자에게 메모리 관리라는 부담을 덜어주는 것이 가비지 컬렉션의 커다란 장점이라 할 수 있을 것이다.
반면 가비지 컬렉션 환경에서 단점은 GC에 의해 관리되는 힙(이전의 힙 관리방식과 구분하기 위해 managed heap이라 부른다)에 대해 개발자의 접근은 크게 제한이 된다. 가비지 컬렉션이 수행되고 메모리 컴팩션이 발생함에 따라서 객체의 참조값, 즉 포인터의 값은 변하게 된다. 따라서 프로그램은 특정 포인터 값에 의존해서 프로그래밍 되어서는 안 된다. 이러한 이유로 인해 닷넷 프레임워크의 수많은 클래스들과 메쏘드들 중 어느 하나라도 힙에 직접 접근 가능한 방법을 제공하는 것이 없다. 또한 Win32 API를 사용할 때, 이 API가 메모리 포인터를 요구하게 되면 상당히 상당히 복잡한 마샬링을 거쳐야 한다. 이러한 가비지 컬렉션의 제약사항은 Win32 API, COM 객체 등과 인터페이스 해야 하는 프로그램의 복잡도와 난이도를 올리는 역할을 하곤 한다.
필자의 개인적인 견해로 가비지 컬렉션은 부정적인 요소보다 긍정적인 요소가 매우 높다고 생각한다. C/C++에 아주 익숙한 프로그래머라도 메모리 할당과 해제에 상당히 신경 쓰지 않으면 메모리 누수가 발생하기 일쑤이다. 유수의 소프트웨어 회사에서 만드는 응용 프로그램들의 패치 중 많은 것이 메모리 누수(memory leak) 관련 패치임을 보더라도 개발자의 메모리에 대한 부담을 줄여주는 것은 커다란 장점이라 할 수 있다.
메모리 누수(memory leak)
하지만 가비지 컬렉션을 사용하는 환경에서도 개발자의 부주의한 코드는 메모리 누수를 발생할 수 있다는 점을 잊지 말아야 한다. 가비지 컬렉션을 사용함에도 불구하고 메모리 누수가 어떻게 발생할 수 있을까? 먼저 우리는 메모리 누수가 무엇인가를 곰곰이 생각해 볼 필요가 있다. 메모리 누수란 할당된 메모리가 더 이상 사용되지 않음에도 불구하고 해제되지 않고 메모리상에 남아 있음을 말한다. C/C++와 같은 닷넷 이전 환경에서는 new/malloc 등의 메모리 할당 함수를 통해 할당된 메모리가 delete/free 등의 메모리 해제 함수를 통해 해제되지 않은 상태가 메모리 누수의 일반적인 상황이다. 대개 이러한 메모리 누수는 프로세스가 종료될 때까지 메모리에 남아 있게 되어 메모리 자원이 낭비되고 최악의 경우, 메모리 부족으로 시스템 성능이 저하되거나 다운되는 결과를 낳는다.
닷넷과 같은 가비지 컬렉션은 delete/free와 같은 메모리 해제 함수를 명시적으로 호출하지 않는다. 메모리가 부족하면 GC가 수행되고 더 이상 사용되지 않는, 좀 더 정확히 말해서 참조를 갖지 않는 객체들을 힙 상에서 제거한다. 아주 엄밀히 말하자면 가비지 컬렉션 환경에서 메모리 누수는 발생하지 않을 것이다. 왜냐면 객체가 참조를 가지고 있다는 말은 여전히 ‘사용 중’임을 의미하는 것이고 사용 중인 메모리는 메모리 누수라고 볼 수 없다. 하지만 “사용 중이지 않는 객체”의 의미를 좀 더 확대 해석해서 “불필요한 참조를 갖는”으로 해석하거나 “불필요하게 길게 메모리를 점유하는”으로 해석면 닷넷 환경상에서도 메모리 누수는 존재한다고 말할 수 있다. 앞서 그림 5에서 보였듯이 2 세대에 존재하던 한 객체가 더 이상 참조를 갖지 않아 가비지 컬렉션의 대상이 되었다고 가정해 보자. 하지만 이 객체가 힙 상에서 제거되는 데는 상당히 오랜 시간을 소요된다. 왜냐하면 GC 2는 GC 0 만큼이나 자주 발생하지 않으며 GC 2는 전체 힙의 모든 객체를 검사하므로 소요되는 비용이 상당히 높기 때문이다. 이러한 경우도 넓은 의미의 메모리 누수로 볼 수 있는 것이다.
예를 들어 보자. 메모리 누수를 발생할 수 있는 일반적인 상황은 생성된 객체의 참조를 루트 참조에 기록해 두는 것이며, 가장 치명적이고 지워지지 않는 루트 참조는 클래스의 정적 필드이다. 물론 캐시나 클래스의 객체들 사이에서 공유, 등등의 목적으로 정적 필드에 기록해 두는 것이겠지만 이러한 코드를 작성해야 한다면, 캐시의 효율, 재사용 빈도를 다시 한번 고려해 보는 것이 좋으며 가능하다면 WeakReference 클래스를 이용하는 것이 메모리 누수를 방지하는 방법이 된다. 다음 코드는 상당히 비현실적인 코드이지만 정적 필드로서 ArrayList와 같은 컬렉션 내에 데이터 셋을 기록함으로 결코 가비지 컬렉션 대상이 되지 않는 무적의 데이터셋을 만든다. 데이터 셋은 내부에 DataTable, DataRow, DataColumn 등의 수 많은 객체들의 참조를 들고 있으므로 상당히 많은 객체들이 메모리상에 영영 상주하는 효과를 낳는다. 따라서 이 코드는 ArrayList에 DataSet을 직접 기록하는 대신 DataSet을 참조하는 WeakReference를 사용하거나 닷넷이 제공하는 Cache 객체와 같이 타임아웃이 제공되는 방식의 캐시를 사용하는 것이 좋다.
class BadUsage
{
static ArrayList list = new ArrayList();
void ReadData_CacheIt()
{
SqlDataAdpater a = new SqlDataAdapter(cmdStr, connStr);
DataSet ds = new DataSet();
a.Fill(ds);
list.Add(a);
}
}
메모리 누수의 형태는 매우 다양하므로 일일이 나열하기가 힘들다. 다만 메모리 누수가 발생하는 것을 먼저 감지하는 것이 중요하다. 앞서 언급한 성능 카운터를 이용하여 관리되는 힙의 크기가 어떻게 변화하는가를 주시할 필요가 있다. 만약 관리되는 힙에서 사용 중인 메모리의 크기가 늘었다 줄었다를 특정 한계 내에서 반복한다면 메모리 누수가 발생하는 것이 아니다. 그러나 가비지 컬렉션이 여러 회 발생했음에도 불구하고 힙의 크기가 커지면서 사용중인 메모리의 공간이 계속 늘어나기만 한다면 메모리 누수가 발생하고 있다는 것을 말한다. 이때는 CLR Profiler 나 상용 프로파일링 도구를 이용하여 구체적으로 프로그램의 어느 부분에서 메모리 누수가 발생하는가를 파악하고 코드를 수정해야 할 것이다.
몇 가지 조언들
닷넷의 가비지 컬렉션 알고리즘과 관련 최적화는 상당히 효율적이다. 메모리 할당에 소요되는 시간은 몇 나노 초 밖에 소요되지 않을 정도로 빠르며 0 세대에 대한 가비지 컬렉션은 대개의 경우 몇 밀리 초 밖에 소요되지 않는다. 하지만 이러한 성능은 닷넷 코드가 충분이 “잘” 작성되었을 때의 이야기이다. 가비지 컬렉션의 성능과 관련되어 몇 가지 주의해야 할 사항을 나열해 보기로 한다.
◎ 너무 잦은 객체 할당
당연하게 너무 많은 객체를 할당하게 되면 가비지 컬렉션의 효율을 떨어뜨린다. 객체가 많을수록 GC가 건드려봐야 할 객체들이 많아지게 됨은 당연하다. 그렇다고 해서 필요한 객체를 쓰지 말라는 것은 아니다. 다만 효율적으로 객체를 할당하기를 권유하는 것이다. 대표적인 예로서 닷넷의 문자열은 불변(immutable) 객체로 문자열 연산은 항상 새로운 문자열 객체를 생성하게 된다. 따라서 StringBuilder와 같은 클래스를 이용하는 것이 바람직(필자 주: 항상 StringBuilder가 좋은 것은 아니다. StringBuilder 에 대해서는 이 사이트의 StringBuilder 이야기 (1), (2), (3) 를 참조하도록...) 하며, 가급적 String.Split() 과 같이 다수의 문자열 객체를 양산하는 메소드의 무분별한 사용 역시 자제해야 한다.
◎ 너무 많은 참조
모든 것이 그러하듯이 지나치면 좋지 않다. 불필요한 참조를 필요 없이 많이 사용하는 것 역시 GC 가 해야 할 일의 양을 늘인다. GC가 루트 참조로부터 참조 그래프를 작성할 때 객체의 모든 참조를 검사하면서 그래프를 만들기 때문에 참조가 많으면 많을수록 GC가 검사해야 할 참조도 늘어나는 것이다.
class LinkedListNode
{
LinkedListNode top, tail;
LinkedListNode next, prev;
// 생략
}
위 코드는 연결 리스트(linked list)의 노드의 예를 든 것이다. 이 코드는 불필요한 참조를 둘이나 가지고 있다. top과 tail 참조가 그러한데, 모든 노드는 동일한 top 참조값, tail 참조값을 가질 것이다. 따라서 top, tail 참조는 인스턴스 필드보다는 정적 필드로 선언하는 것이 GC의 부담을 줄이는 것이 된다.
◎ 너무 많은 2 세대 객체들
세대별 가비지 컬렉션에서 언급했듯이 2세대의 객체는 생명 주기가 매우 긴 객체들이다. 2 세대의 객체들은 GC 2, 즉 전체 힙에 대해 가비지 컬렉션이 수행되는 경우에만 힙에서 제거된다. 이 말은 곧은 2 세대 객체들이 점유하는 영역은 잘 줄어들지 않는 다는 말도 된다. 0 세대의 객체들은 GC 0가 상대적으로 자주 발생하므로 0 세대 객체들이 많더라도 큰 문제가 안되지만 2 세대의 객체들은 GC 2가 상대적으로 드물게 발생하므로, 많은 2세대 객체 있다면 그만큼 프로그램이 사용하는 메모리의 양은 늘어난다. 그리고 2세대에 많은 객체들이 있다면 당연히 많은 객체를 검사해야 하므로 GC 2 의 성능 역시 감소함은 물론이다.
객체들이 2 세대까지 “늙지” 않게 하는 뾰족한 방법은 없다. 다만, 다음에 설명할 Finalizer의 사용을 최대한 자제하고 객체에 대한 불필요한 참조를 없앰으로써 GC 0 혹은 GC 1 이 발생할 때 객체들이 가비지 컬렉션 되어 힙에서 제거되도록 해야 한다.
◎ Finalizer & Dispose Pattern
Finalizer가 호출되어야 하는 객체는 항상 1 세대로 승급된다. GC 0 동안 가비지 컬렉션의 대상으로 선정되더라도 Finalizer 덕분에 살아남게 되어 1 세대로 승급되며, GC 1 은 GC 0 보다 발생 주기가 낮으므로 더 오랫동안 힙의 영역을 꿰어 차기 때문이다. 그렇다고 Finalizer을 전혀 안 쓸 수는 없는 노릇이다. 데이터베이스 연결을 위한 SqlConnection 클래스를 생각해보자. 비록 IDispose 인터페이스를 구현하고 있다 할지라도 초보 프로그래머가 실수로 Dispose 메쏘드 호출을 잊었다면(고급 프로그래머도 곧 잘 잊곤 하지만) 열린 데이터베이스 연결을 닫을 방법은 없다. 따라서 어쩔 수 없이 Finalizer를 구현해야만 한다. 하지만 Dispose() 혹은 Close() 가 호출되었다면 불필요하게 Finalizer가 호출되어 객체가 1 세대로 승급되는 상황을 막아줄 필요는 있다.
System.GC 클래스는 Finalizer가 호출되지 않도록 해주는 SuppressFinalize() 메쏘드를 제공한다. 이 메쏘드와 IDispose 인터페이스 그리고 Finalizer 가 닷넷의 Dispose 패턴을 구성한다. 다음 코드는 전형적인 Dispose 패턴을 보여주고 있다. 만약 개발자가 명시적으로 Dispose 메쏘드를 호출했다면 데이터베이스 연결을 닫는 작업을 수행할 수 있다. 그리고 SuppressFinalize() 메쏘드를 통해 Finalizer가 호출되지 않도록 막는다. 만약 개발자가 명시적으로 Dispose 메쏘드를 호출하지 않았다면 Finalizer가 GC에 의해 호출될 것이다. Finalizer가 호출된 것은 가비지 컬렉션과 메모리 상황에 그다지 좋은 상황은 아니지만 그보다 훨씬 비싼 데이터베이스 연결이 닫히지 않는 것 보다는 낫다.
class SampleDBConnection : IDispose
{
// 생략..
~SampleDBConnection() // Finalizer의 C# 표현
{
DoDispose();
}
public void Dispose()
{
DoDispose();
// 명시적으로 Dispose가 호출되면 Finalizer가 호출될 필요가 없다.
GC.SupressFinalizer(this);
}
// 실제로 자원해제 작업을 수행한다.
Protected void DoDispose()
{
if (this.IsOpen)
this.Close();
}
}
이러한 Dispose 패턴은 관리되는 자원을 해제해야 하는가 그렇지 않은가에 따라 약간의 변화(MSDN은 위 코드와 약간 다른, 좀 더 복잡한 패턴을 소개하지만 원리는 같음)가 있지만 닷넷 프레임워크 전반에 걸쳐 광범위하게 사용되고 있다.
◎ 작지만 큰 객체
닷넷 객체는 관리되지 않는(unmanaged) 자원을 사용할 수 있다. 예를 들어 System.Runtime.InteropServices.Marshal 클래스의 AllocHGlobal 과 같은 메쏘드를 통해 관리되지 않는 힙에 메모리를 할당할 수도 있다. 그리고 이 관리되지 않는 메모리에 대한 포인터 혹은 핸들을 닷넷 객체의 필드에 기록해 둘 수도 있다. 이러한 경우 닷넷 객체 자체는 관리되는 힙에 십 수 바이트 혹은 수십 바이트 밖에 차지 하지 않지만 관리되지 않는 힙에 수 메가 혹은 수십 메가 바이트를 차지할 수도 있다. 만약 이 객체에 대해 dispose 패턴을 사용하지 않았다면 이 객체와 이 객체가 점유하고 있는 많은 관리되지 않는 메모리가 해제되는 데에는 시간이 소요될 것이다. 비록 객체에 대한 참조가 전혀 없더라도 관리되는 힙이 어느 정도 수준까지 사용될 때까지 가비지 컬렉션은 수행되지 않을 것이기 때문이다.
비슷한 유형의 문제는 윈폼(WinForm) 프로그램을 작성 할 때에도 발생한다. 윈폼 프로그램은 많은 GDI 객체를 랩핑(wrapping)하는 객체들을 사용한다. System.Drawing 네임스페이스의 SolidBrush, Pen 클래스나 System.Windows.Forms 네임스페이스의 OpenFileDialog, SaveFileDialog 클래스는 GDI 객체나 윈도우 객체(윈도우 핸들)을 랩핑 하고 있다. 따라서 객체의 사용이 완료되면 즉각 Dispose 메쏘드를 호출해주는 것이 좋다. 그렇지 않으면 다음 가비지 컬렉션이 발생할 때까지 이들 객체들이 랩핑 하는 GDI 객체 혹은 윈도우 객체들은 해제 되지 않을 것이다.
다음 코드를 살펴보자. 이 코드는 윈폼의 OnPaint 이벤트에 대한 매우 간단한 핸들러이다. 이 코드의 문제는 생성한 SolidBrush 객체에 대해 Dispose를 사용하지 않았다는 점이다. Dispose() 호출은 br 객체가 랩핑하는 GDI 브러쉬 객체를 파괴하는 DestroyObject() Win32 API를 호출할 것이다. 다음 코드와 같이 Dispose() 호출이 이루어 지지 않으면 SolidBrush 클래스가 정의하는 Finalizer에 의해 Dispose() 메쏘드가 호출된다(앞서 언급한 Dispose 패턴). 일단 Finalizer가 수행되어야 하므로 메모리 활용에 비 효율적이며, 보다 문제는 Form1_Paint 메쏘드가 종료된 후, 다음(next) 가비지 컬렉션에서나 GDI 브러쉬 객체가 사라진다는 것이다. Windows 2000, XP, 2003 과 같이 거의 무제한의 GDI 자원을 제공하는 경우, 충분한 메모리만 있다면 용서가 될지 모르지만, Win98, SE 와 같이 GDI 자원에 제한이 있는 경우, GDI 자원은 빠르게 소진될 것이다. 더욱 나쁜 상황을 가정하라면, 아주 재수 없이 br 객체가 생성된 직후에 가비지 컬렉션이 발생해 버렸다면 SolidBrush 객체는 1 세대 객체가 되어 버린다. GC 1은 GC 0 만큼이나 자주 발생하지 않기 때문에 GDI 브러쉬 객체는 쓸데 없이 더욱 더 오래 동안 점유된 채로 남는다. Win98과 SE는 GDI 자원에 한계가 있음을 유의할 필요가 있다.
private void Form1_Paint(object sender, PaintEventArgs e)
{
Graphics g = e.Graphics;
SolidBursh br = new SolidBrush(Color.Blue);
g.FillRect(br, 10, 10, 100, 100);
}
만일 독자가 C/C++ WIN32 API 프로그래밍 경험이 있다면, C/C++에서 코드를 작성했던 것처럼 GDI 객체를 래핑 할 것으로 예측되는 모든 객체를 Dispose 해주는 것이 좋다.
.NET Framework 2.0 에서는 이와 비슷한 상황을 해결하고자 GC 가 좀 더 자주 혹은 좀더 적게 가비지 컬렉션을 수행하도록 하는 방법을 제공한다. 이 방법은 GC 가 가비지 컬렉션을 수행하는 판단 기준에 영향(pressure)을 줌으로써 보다 잦게, 혹은 그 반대로 가비지 컬렉션의 주기를 조정할 수 있다. 비록 닷넷 객체는 작지만 커다란 관리되지 않는 자원을 점유하는 객체를 할당하는 경우, 영향을 증가시키면 보다 GC를 잦게 수행하게 된다. 이렇게 함으로써 앞서 언급한 GDI 자원 고갈 문제를 일부 해결할 수 있다.
Summary
가비지 컬렉션은 점차로 빨라지는 하드웨어에 부응하는 기능임에 틀림없다. CPU가 3.0 GHz를 훌쩍 넘어서고 1GB의 메모리를 장착한 PC(필자의 노트북도 2.0 GHz CPU에 1GB 메모리를 장착하고 있다)를 흔히 볼 수 있는 지금 개발자에게 메모리 관리라는 부담을 덜어주는 가비지 컬렉션은 개발 생산성 향상에 큰 도움을 주는 것이 사실이다. 닷넷 가비지 컬렉터와 이것이 사용하는 알고리즘은 충분히 최적화 되어 있어서 가비지 컬렉션에 소요되는 비용도 그다지 높지 않다(마이크로소프트의 CLR 팀은 0 세대 가비지 컬렉션에 소요되는 시간을 1ms 목표로 개발했다고 한다). 하지만 가비지 컬렉션을 너무 믿고 방만하게 메모리를 사용해서는 안될 것이다. 분명히 가비지 컬렉션 시스템 상에서도 메모리 누수는 존재하며 관리되지 않는 자원을 사용하는 경우에는 항상 조심스러운 코딩이 필요하다. 이 글에서 가비지 컬렉션에 대한 모든 내용을 다룰 수는 없다. 독자는 다양한 MSDN의 기술 문서와 온라인 문서, Pattern & Practice 문서를 통해 가비지 컬렉션의 작동 방식과 다양한 팁을 습득함으로써 부주의한 메모리 관리에 신경써야 할 것이다.
참고문헌
Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
Jeffrey Richter, MSDN Magazine, November 2000
Garbage Collection?Part 2: Automatic Memory Management in the Microsoft .NET Framework
Jeffrey Richter, MSDN Magazine, December 2000
Garbage Collector Basics and Performance Hints
Rico Mariani, MSDN Online Technical Article
How to: Use CLR Profiler
J.D. Meier 외, Pattern & Practice, Improving .NET Application Performance and Scalability
Comments (read-only)
#re: 2004년 10월호 닷넷 칼럼 :: Back to the Basic: .NET Garbage Collection / CCC / 1/11/2010 9:22:00 AM
글 잘 읽었습니다. 너무 정리를 깔끔하게 해주셔서 Garbage Collection에 대한 이해가 쉬웠습니다.
Garbage Collection에 관련해 문서를 작성할 일이 있는데 참조해서 작성할께요.
감사합니다.