이 글은 월간 마이크로소프트웨어(일명 마소) 2004년 10월호 닷넷 칼럼에 기고한 글을 다시 리뉴얼 한 것입니다. 원본 글이 오래되었기 때문에 현재와 맞지 않는 부분을 수정하면서 내용도 보강하였습니다. 지난 글이 어려웠다면 이번 글은 더 어려울 수 있습니다. 하지만 그림을 잘 이해하시면 도움이 되리라 믿습니다. 그럼 시작해 보죠.

Generational Garbage Collection

지난 글에서는 닷넷 CLR에서 제공하는 GC의 기본적인 작동 방식을 살펴보았다. Microsoft의 .NET CLR 팀은 GC의 성능 향상 및 효율을 극대화하기 위해 여러 가지 최적화를 사용하고 있다. 이 최적화 기법 중 하나가 소위 세대별 가비지 컬렉션(generational garbage collection)이다. 세대별 가비지 컬렉션 내용은 앞으로 다루게 될 Finalizer나 Dispose 패턴 등을 이해하는데 매우 중요할 뿐더러, 닷넷 어플리케이션의 메모리 문제를 분석하는데도 매우 중요한 요소이다. 이걸 이해하지 않고서는 고급 닷넷 개발자라고 말하기 어렵다. 그다지 어렵지 않은(응?) 내용이므로 천천히 시간을 가지고 ‘꼭’ 이해하길 바라는 바이다.

개요

닷넷의 GC의 최적화 기법 중 대표적인 것이 세대별 가비지 컬렉션(generational garbage collection)이다. 세대별 가비지 컬렉션은 관리되는 힙(managed heap) 상의 객체를 객체의 생존 시간(live time)에 따라 몇 세대(generation)로 구분하여 가비지 컬렉션을 수행하는 것을 말한다. 즉, 최근에 생성된 객체는 0 세대(Gen 0)가 되며 1회의 GC 동안 살아남은(?) 객체는 1세대(Gen 1), 2회 이상의 GC 동안 살아남은 객체는 2세대(Gen 2)가 되는 방식이다. 이처럼 닷넷 객체들은 GC가 수행됨에 따라 필자처럼 나이를 쳐먹게 되는 것이다. 현재 닷넷 프레임워크의 버전에서 최고의 세대는 2세대이며 2세대의 객체들은 GC가 수행되는 동안 계속 2세대에 남게 된다. (조케따 어느 정도 이상이면 나이를 안 먹는다니……)

닷넷의 GC는 0 세대에 대해 집중적으로 가비지 컬렉션을 수행한다. 즉, 닷넷이 기본적을 수행하는 가비지 컬렉션은 0 세대에 대해서만 이루어지며 1 세대, 2세대의 객체에 대해서는 가비지 컬렉션을 수행하지 않는다. 이렇게 0 세대에 대해 집중적으로 가비지 컬렉션을 수행하면 GC의 성능과 효율을 올릴 수 있는데, 그 근거는 다음과 같다.

  • 최근 생성된 객체일수록 생명주기는 짧다.
    (작은 객체일수록 생성된 후 짧은 시간 동안 사용되고 더 이상 사용되지 않는다)
  • 오래된 객체일수록 생명주기는 길다.
  • 최근에 생성된 객체들끼리는 서로 연관성이 높으며(참조 관계) 비슷한 시점에서 자주 액세스 된다.
  • 일부분의 힙을 가비지 컬렉션 하는 것은 전체를 가비지 컬렉션 하는 것보다 빠르다.

이 가정은 메모리 관리를 연구해 온 여러 머리 좋은 신 분들의 다양한 연구의 결과이며 필자도 학교 다닐 때 들어본 듯한 내용이다. 모르긴 몰라도 Java의 가비지 컬렉션도 이러한 방식을 따르지 싶다(실제 그러한지 어쩐지는 알고 싶지도 않다). 예를 들어, 여러분이 자주 사용하는 ToString() 메서드를 생각해 보자. 문자열 타입을 제외한 거의 모든 타입의 ToString() 메서드는 새롭게 문자열 객체를 생성하여 반환한다. 그리고 이 문자열 객체는 화면 표시나 HTML 렌더링 등등 짧은 시간 동안 사용된 후 더 이상 사용하지 않는 객체로 남게 된다. 여러분이 ASP.NET 개발자라고 하면 웹 페이지 하나를 렌더링 하는 동안 얼마나 많은 문자열 객체가 사용되는지 생각해 보라. 웹 페이지 렌더링이 끝나고 나면 그 문자열 객체들은 다 어딜 가겠는가? 그렇다. 웹 페이지를 렌더링 하는 짧은 시간 동안만 사용되고 사라지는 임시 객체인 것이다.

프로그램이 메모리를 임시적으로만 사용하는 것은 아니다. 클래스의 정적 필드(static field)에 기록된 객체들은 명시적으로 해당 필드에 null 을 할당하지 않는 한 결코 사라지지 않는다. 바로 이런 객체가 오래된 객체이며 생명 주기는 벽에 똥칠할 때까지로 매우 길다.

GC 0

세대별 가비지 컬렉션을 보다 구체적으로 예를 들어 살펴보면 그림 1와 같다. 기본적으로 new(혹은 CreateInstance 등의 객체 생성 메쏘드들)를 통해 새로이 생성된 객체들은 항상 0 세대가 된다. 이후 CLR에 의해 가비지 컬렉션이 발생하면 0 세대에 대해서만 가비지 컬렉션을 수행한다. 지난 글에서 설명한 것과 같이 객체의 참조 그래프가 만들어지고 객체들이 컴팩션(compaction)되면 위에서 두 번째와 같은 힙이 구성된다. 이때 0 세대의 GC동안 살아남은 객체 A, C, E, F 객체는 모두 1 세대로서 승급(promotion)되게 되는 것이다(나이 먹는 걸 승급이라 해야 하나?). 이 이후 추가적으로 객체 할당이 진행되면 새로운 객체들은 다시 0 세대 객체로서 할당되게 된다(3 번째 힙). 이렇게 0 세대와 1 세대가 공존하는 상황에서 다시 GC 가 발생하면 재미있는 일이 발생한다. 세 번째 힙 그림에 의하면 GC의 대상이 되는 객체는 E, I, J 객체이다. 하지만 GC는 0 세대에 대해서만 발생했기 때문에 1 세대의 객체인 E는 가비지 컬렉션이 되지 않는다. 결론적으로 마지막 힙처럼 I, J 객체가 메모리 컴팩션에 의해 사라지게 되고 H, K 객체는 GC 동안 살아 남아 1세대로 승급하게 되는 것이다.

image
그림1. Generational Garbage Collection (GC 0 Operation)

GC 1

그렇다면 1세대에 존재하는 객체는 절대로 메모리에서 사라지지 않는 것일까? 그렇지 않다. 지들이 무슨 용가리 통뼈라고… 시간이 흘러감에 따라 1 세대 힙의 크기도 점차로 커질 것이며 힙의 사용 가능한 메모리 영역 역시 줄어들 것이다. 닷넷 가비지 컬렉터는 충분히 영리하다. 0 세대가 사용할 수 있는 메모리 공간이 줄어들고 1 세대 늘어남에 따라 CLR 내부에 결정된 특정 한계에 도달하면(이 한계점은 버전마다 달라질 수 있으며 문서화되어 있지도 않다. 대개 1 세대 힙의 크기가 늘어나고 0 세대 힙의 크기가 줄어 들면 0 세대 힙을 늘이지만 0 세대는 일정 크기 이상 커지지 않는다) GC는 0세대와 1세대에 대해서 가비지 컬렉션을 수행한다. 이렇게 0 세대에 대해서만 수행하는 가비지 컬렉션을 GC 0라고 하고 0 세대와 1 세대에 대해서 가비지 컬렉션을 수행하는 것을 GC 1 이라고 한다. GC 1 동안 살아남은 1 세대의 객체는 2 세대로 승급하며 GC 1 동안 살아남은 0 세대의 객체는 1 세대로 승급한다.

그림 2는 GC 1의 작동 방식을 보여주고 있다. 그림 1의 마지막 힙 상황에서 L, M, N, O 객체가 추가로 할당되었고 A, E, N 객체가 더 이상 사용되지 않는다고 가정해 보자. 이때의 힙 상황이 그림 2의 맨 위의 그림이다. 이 상황에서 가비지 컬렉터가 힙의 부족으로 가비지 컬렉션을 해야 한다고 판단했고 GC 1이 필요하다고 한다고 판단했다면, 가비지 컬렉션의 결과는 그림 2의 두 번째 힙 상황이 되게 된다.

1세대에 남아있던 A, E 객체 역시 가비지 컬렉션 되어 힙 상에서 사라졌음에 주목하자. 또한 1세대 객체였던 C, F, H, K 객체가 2 세대로 나이를 먹었음에도 역시 주목해야 한다. 이 이후 추가적으로 P, Q, R 이 할당되었고 F 객체가 사용되지 않은 후 GC 1 (1세대 가비지 컬렉션)가 수행되었다고 가정하면, 그림 2의 맨 마지막 그림과 같은 힙이 된다. F 객체는 2 세대에 존재하고 더 이상 사용 중이 아니지만 가비지 컬렉션의 대상이 되지 않았음에 유의하기 바란다. 그림 2의 예제에서는 GC 1이 연속 2회 발생한 것을 보였지만 실제의 경우 GC 1이 연속 2회 발생하는 경우는 거의 없다고 보면 된다. GC 1이 발생한 이후에 객체가 계속 할당되면 새로운 객체들은 0 세대에 생성될 것이며 GC는 수 십 차례에 걸쳐 GC 0을 반복한 이후에야 GC 1을 수행할 것이다.

image
그림2. GC 1 Operation

GC 2

2 세대에 존재하는 객체들은 가비지 컬렉터가 2세대를 가비지 컬렉션 하기 전까지는 힙에 남는다. 비슷하게 GC가 2세대를 가비지 컬렉션 하는 상황은 힙 상에 사용 가능한 공간이 줄어들어 GC 1을 수행해도 0세대 힙이 부족할 것 같은 상황에서 발생한다. 2세대 가비지 컬렉션은 항상 0세대 및 1세대 가비지 컬렉션을 포함하여 수행하기 때문에 2세대 가비지 컬렉션을 GC 2 혹은 풀 가비지 컬렉션(full garbage collection)이라고 한다. 풀 가비지 컬렉션은 전체 힙에 대한 가비지 컬렉션을 의미한다. 2세대에 존재하는 객체들이 GC 2를 통해 살아 남았다면 그 객체들은 2세대 이상의 세대가 존재하지 않기 때문에 계속 2세대에 남게 됨은 이미 언급한 바 있다. 그림 2의 마지막 힙 그림에서 GC 2가 발생한다면 F가 힙에서 사라지게 되며 C, H, K, L, O, Q, R 이 모두 2세대에 남게 될 것이다.

예제 코드: 세대별 가비지 컬렉션 직접 확인하기

리스트 1의 코드는 세대별 가비지 컬렉션을 확인하는 간단한(응?) 예제 코드이다. GC 클래스Collect 메서드는 프로그램적으로 가비지 컬렉션을 강제하는 메서드이며 GetGeneration 메서드는 주어진 객체의 세대를 반환한다. 최초의 객체 할당은 0 세대에, 그리고 가비지 컬렉션이 발생함에 따라서 객체가 0세대에서 1세대로, 1세대에서 2 세대로 바뀌어 가는 것을 확인할 수 있다.

   1: object obj1 = new object();   // Gen 0에 객체 할당
   2: Console.WriteLine("\nAllocation obj1 ..................................\n");
   3: Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1)); 
   4:  
   5: GC.Collect(0);         // Gen 0 Collection (default GC behavior)
   6: Console.WriteLine("GC 0 ---------------------");  
   7: Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
   8:  
   9: GC.Collect(1);         // Gen 1 Collection
  10: Console.WriteLine("GC 1 ---------------------");
  11: Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1));
  12:  
  13: GC.Collect(2);         // Gen 2 Collection (full collection)
  14: Console.WriteLine("GC 2 ---------------------");  
  15: Console.WriteLine("Generation of obj1 : {0}", GC.GetGeneration(obj1)); 
  16:  
  17: ----------------------- 수행 결과 ------------------------------------
  18:  
  19: Allocation obj1 ..................................
  20:  
  21: Generation of obj1 : 0
  22: GC 0 ---------------------
  23: Generation of obj1 : 1
  24: GC 1 ---------------------
  25: Generation of obj1 : 2
  26: GC 2 ---------------------
  27: Generation of obj1 : 2
  28:  

2 세대 객체들이 가비지 컬렉션 되는 주기는 상당히 길다. GC는 1 세대 가비지 컬렉션 조건이 만족될 때까지 0 세대 가비지 컬렉션을 반복할 것이고 여러 회의 1 세대 가비지 컬렉션이 수행된 이후라야 GC 2가 수행될 것이기 때문이다. 이는 닷넷 가비지 컬렉터가 앞서 언급한 대로, “최근 생성된 대부분의 객체는 임시적 성격이 강하며 그렇지 않은 객체는 오랫동안 사용된다”는 가정에 충실 따르고 있음 알 수 있을 것이다. 세대별 가비지 컬렉션은 대부분의 경우 긍정적으로 적용되지만 부주의한 코드는 역효과를 낼 수도 있다. 이러한 상황에 대한 구체적인 예는 다른 포스트에서 상세히 살펴보기로 하겠다.

다음 글에서는…

이번 글에서는 세대별 가비지 컬렉션에 대해서 그림을 통해 매우 상세히 살펴보았다. 이 정도 설명했으면 이해를 해줘야 필자도 글 쓰는 재미가 나기 마련이다. 아직도 잘 이해가 안 간다면 어쩔 수 없다. 술과 담배를 빨리 끊고 다시 시도해 보기 바란다. 이미 술과 담배를 하지 않는 독자라면 조상을 살짝 의심해 볼 필요가 있다.

다음 글에서는 가비지 컬렉션이 구체적으로 언제 발생하는가에 대해서 살펴보도록 하겠다. 글이 진행될 수록 내용이 약간씩 어려워지고 지난 글을 자주 참조하게 될 것이므로 필자를 위해서라도 이 글과 이전 글의 내용을 충분히 숙지하길 바라는 바이다.


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.