Silverlight를 설치하려면 여기를 클릭합니다.*
Korea 대한민국변경|Microsoft 전체 사이트
MSDN
|개발자 센터
MSDN Home   MSDN Home
MSDN 홈 > MSDN Magazine > 2000년 기사 > Garbage Collection: Microsoft .NET Framework의 자동 메모리 관리

Garbage Collection: Microsoft .NET Framework의 자동 메모리 관리

Jeffrey Richter 저
?
이 문서는 독자가 C와 C++를 잘 알고 있다는 전제 하에 작성된 것입니다.

요약: Microsoft .NET 공통 언어 런타임 환경에 속한 Garbage Collection을 사용하는 개발자는 메모리를 확보할 시기를 파악하고 메모리 사용량을 추적할 필요가 없게 됩니다. 그러나, 어떻게 이러한 일이 진행되는지 알고 싶은 분이 많을 것입니다. 두 부분으로 나뉜 이 .NET Garbage Collection 문서 중 1부에서는 리소스가 할당되고 관리되는 방법을 설명하고 Garbage Collection 알고리즘의 작동 과정에 대한 자세한 단계별 설명을 제공합니다. 또한, 가비지 수집기에서 리소스의 메모리를 확보하도록 결정할 때, 리소스를 적절히 정리할 방법과 메모리 확보 시 개체 정리 방법에 대해서 자세히 설명합니다.


응용 프로그램에 리소스 관리를 적합하게 이행하는 것은 어렵고도 힘든 작업입니다. 이 때문에, 계속하여 해결하고자 노력하고 있는 진짜 문제가 등한시될 수 있습니다. 따라서, 개발자에게 있어서 기계적인 메모리 관리 작업을 단순하게 처리해 주는 메커니즘이 제공된다면 상당히 흥미로운 일일 것입니다. 놀랍게도 .NET 안에는 Garbage Collection(GC)이라는 메커니즘이 있습니다.
잠시 생각해 봅시다. 모든 프로그램은 한두 가지의 메모리 버퍼와 스크린 공간, 네트워크 연결, 데이터베이스 리소스 등을 사용합니다. 사실, 개체 지향 환경에서는 각 유형마다 여러분의 프로그램에서 사용할 수 있는 몇 가지 리소스를 구별합니다. 이러한 리소스를 사용하기 위해서는 각 유형에 맞게 메모리가 할당되어 있어야 합니다. 리소스를 액세스하기 위해 필요한 단계로서, 다음을 들 수 있습니다.

  1. 리소스를 나타내는 유형에 대해 메모리를 할당합니다.
  2. 리소스의 초기 상태를 설정하고 리소스의 재사용이 가능하도록 메모리를 초기화합니다.
  3. 해당 유형의 인스턴스 구성원을 액세스하여 리소스를 사용합니다. (필요한 경우 반복하십시오.)
  4. 리소스 상태를 해체하여 정리합니다.
  5. 메모리를 늘립니다.
간단해 보이는 이 패러다임은 프로그래밍 오류를 가져오는 주요 원인 중 하나가 되어 왔습니다. 즉, 여러분은 더 이상 필요 없게 된 메모리를 늘리는 작업을 잊거나 이미 확보한 메모리를 사용하려고 시도한 적이 있을 것입니다.
이러한 두 가지 버그는 다음 결과가 어떠할 것인지, 다음 결과가 발생할 시기가 언제인지를 예측할 수 없다는 점에서 다른 어떤 응용 프로그램 버그보다도 나쁘다고 할 수 있습니다. 다른 버그의 경우, 응용 프로그램이 잘못 실행되는 것을 보면서 바로 교정할 수 있지만, 이 두 버그는 리소스 누출(메모리 소비)과 개체 손상(동요)을 불러 일으키므로 결국 응용 프로그램이 예측할 수 없는 시간에 예측할 수 없는 방법으로 수행되도록 하고 맙니다. 사실, 개발자들이 이러한 유형의 버그를 확인하는 데 도움을 주도록 특별히 만들어진 도구가 많이 있습니다(예: Task Manager, System Monitor ActiveX® Control, CompuWare의 BoundsChecker, Rational의 Purify 등).
필자가 GC를 검토해 본바로는, 이 제품이야말로 개발자가 메모리 사용을 추적하고 메모리 확보 시기를 알아야 할 부담에서 완전히 벗어나게 합니다. 그러나, Garbage Collection기는 메모리 내 유형에 의해 재현되는 리소스에 대해서는 알지 못합니다. 즉, 가비지 수집기에서는 위의 단계 중 4번(리소스 상태의 해체)을 수행할 방법을 알지 못한다는 것입니다. 리소스가 적합하게 정리되도록 하려면, 개발자는 리소스 정리 방법을 확인하는 코드를 작성해야 합니다. .NET Framework에서 개발자는 이 코드를 Close, Dispose, Finalize 등의 메서드로 작성하는데, 이는 차후에 설명하도록 합니다. 하지만, Garbage Collection기에서는 이 메서드를 자동으로 호출할 시기를 결정할 수 있습니다.
또한, 정리할 필요가 없는 리소스도 나타내는 유형이 여러 개 있습니다. 예를 들어, 직사각형 리소스는 해당 유형의 메모리 내에 들어 있는 왼쪽, 오른쪽, 너비, 높이 등의 필드를 삭제함으로써 바로 정리될 수 있습니다. 반면, 파일 리소스나 네트워크 연결 리소스를 나타내는 유형은 해당 리소스가 삭제될 때 명확한 정리 코드를 수행해야 합니다. 이 모든 작업이 어떤 식으로 진행되는지는 앞으로 설명할 것이며, 지금은 메모리 할당 방법과 리소스 초기화 방법에 대해 검토해 보도록 하겠습니다.

맨 위로


리소스 할당
Microsoft®.NET 공통 언어 런타임은 모든 리소스가 관리 힙에서 할당되어야 합니다. 이는 관리 힙에서 개체를 확보하지 않는다는 점만 제외하면 C-런타임 힙과 유사합니다. 개체는 해당 응용 프로그램에서 더 이상 필요로 하지 않을 때 자동으로 확보됩니다. 물론, 이 경우 어떻게 관리 힙에서 응용 프로그램의 개체 사용 중지 시기를 알 수 있는가 하는 문제가 발생합니다. 이 문제에 대해 간단히 언급하겠습니다.
오늘날 사용되고 있는 GC 알고리즘은 몇 가지가 있으며, 각각은 최적의 성능을 제공하도록 특수한 환경에 맞추어 미세하게 조정되어 있습니다. 여기서는 공통 언어 런타임에서 사용하는 GC 알고리즘을 집중적으로 다루고 있습니다.이제 기본 개념부터 시작해 봅시다.
한 프로세스를 초기화할 때 런타임은 처음에는 저장소가 할당되어 있지 않은 주소 공간의 인접 영역을 예약합니다. 이 주소 공간 영역은 관리 힙입니다. 이 힙에서는 포인터도 관리하고 있는데, 이를 NextObjPtr라고 하겠습니다. 이 포인터는 해당 힙 내에서 다음 개체가 할당될 위치를 가리킵니다. 처음에는 NextObjPtr가 예약된 주소 공간 영역의 기본 주소로 설정됩니다.
응용 프로그램에서 새 연산자를 사용하여 개체를 만듭니다. 이 연산자는 먼저 예약된 영역(필요한 경우 저장소 위임)에 새 개체를 맞추는 데 필요한 바이트를 확인합니다. 해당 개체가 맞으면, NextObjPtr에서 힙 내 개체를 가리키고 이 개체의 생성자가 호출되며 새 연산자가 해당 개체의 주소를 반환하게 됩니다.

Figure 1 Managed Heap
그림 1 관리 힙

이 때 NextObjPtr는 해당 개체를 넘어 증가하게 되고, 결국 힙 내 어느 위치에 새 개체가 있게 될 것인지를 나타내게 됩니다. 그림?1에서는 A, B, C라는 세 개체를 구성하는 관리 힙을 보여주고 있습니다. 다음 할당 개체는 NextObjPtr이 가리키는 위치(개체 C 바로 다음)에 있게 됩니다.
이제, C-런타임 힙에서 메모리를 할당하는 방법에 대해 살펴 봅시다. C-런타임 힙에서는 개체에 대한 메모리 할당에 있어서 데이터 구조의 연결 목록에 대한 이동이 필요합니다. 크기가 큰 블록이 발견되고 나면, 해당 블록을 분할하고 연결된 목록 노드의 포인터를 수정하여 모든 내용이 손상되지 않고 그대로 유지되도록 해야 합니다. 관리 힙의 경우, 개체 할당은 단순히 포인터에 값을 추가하는 것을 의미하며, 이는 비교에 의해 매우 빠르게 이루어집니다. 사실, 관리 힙에서 개체를 할당하는 것은 스레드 스택에서 메모리를 할당하는 것만큼이나 빠릅니다.
이제까지 살펴 본 바에 의하면, 그 속도와 이행의 단순성 때문에 관리 힙이 C-런타임 힙보다 훨씬 우수한 것처럼 보입니다. 물론, 관리 힙은 이러한 장점을 갖고 있는 것이 사실이며, 이는 하나의 큰 가정을 전제로 하고 있기 때문입니다. 즉, 주소 공간과 저장소가 무한하다는 가정입니다. 이러한 가정은 의심의 여지 없이 우스꽝스러운 것으로, 이러한 가정을 전제로 하는 관리 힙에 의해 채용된 메커니즘일 따름입니다. 이 메커니즘을 가비지 수집기라고 합니다. 이제 이 메커니즘의 작동 방법을 확인해 봅시다.
응용 프로그램에서 새 연산자를 호출하여 개체를 만들 때는 해당 개체를 할당할 영역 내에 충분한 주소 공간이 남아 있지 않을 수도 있습니다. 힙은 NextObjPtr로 새 개체의 크기를 추가함으로써 이를 파악해 냅니다. NextObjPtr가 주소 공간 영역의 끝을 넘어 서 있을 경우에는, 힙이 가득차므로 수집이 수행되어야 합니다.
실제로는 0 세대가 완전히 가득 찼을 때 수집이 발생합니다. 간단히 말해, 세대란 성능을 향상시키기 위해 Garbage Collection기가 수행하는 메커니즘입니다. 즉, 새로 만든 개체가 신세대의 일부이고, 응용 프로그램의 수명 주기에서 먼저 만든 개체는 구세대에 속합니다. 개체를 세대로 분할함으로써, Garbage Collection기는 관리 힙의 전체 개체를 수집하는 대신 측정 세대만 수집하면 됩니다.

맨 위로


Garbage Collection 알고리즘
Garbage Collection기는 응용 프로그램에서 더 이상 사용하지 않는 힙 내의 개체가 있는지 확인합니다. 해당되는 개체가 있을 때는 이들 개체에서 사용한 메모리를 재생할 수 있습니다. 힙에 대해 사용할 수 있는 메모리가 더 이상 없을경우 새 연산자가 OutOfMemoryException를 던지게 됩니다. 가비지 수집기에서 응용 프로그램의 개체 사용 여부를 어떻게 알 수 있을까요? 여러분이 예상하는 바와 같이, 이 문제는 그리 간단하지는 않습니다.
모든 응용 프로그램에는 루트 집합이 있습니다. 루트는 저장소의 위치를 확인하는 것으로, 관리 힙에서 개체를 찾거나 null로 설정된 개체를 찾습니다. 예를 들어, 응용 프로그램에 속한 모든 글로벌 개체 포인터와 정적 개체 포인터는 해당 응용 프로그램의 일부 루트로서 간주됩니다. 또한, 스레드의 스택에 있는 로컬 변수/매개 변수 개체 포인터도 응용 프로그램의 일부 루트로서 간주됩니다. 마지막으로, 관리 힙의 개체에 대한 포인터를 보유한 CPU 레지스터도 응용 프로그램의 루트로서 간주됩니다. 유효한 루트의 목록은 JIT(just-in-time) 컴파일러와 공통 언어 런타임에 의해 관리되며, Garbage Collection기의 알고리즘으로 액세스할 수 있습니다.
Garbage Collection기가 실행되기 시작하면 힙 내 모든 개체는 가비지라는 가정을 전제로 하게 됩니다. 즉, 응용 프로그램 루트 중 어느 것도 힙 내 개체를 나타내지 않는다는 것입니다. 이제, Garbage Collection기는 루트로 가서 루트에서 접근할 수 있는 모든 개체의 그래프를 구성하게 됩니다. 예를 들어, Garbage Collection기는 힙 내 개체를 가리키는 글로벌 변수의 위치를 나타낼 수 있습니다.
그림?2에서는 응용 프로그램 루트에서 직접 A, C, D, F 개체를 참조하는 몇 가지 할당된 개체를 힙으로 나타내고 있습니다. 이 개체는 모두 그래프의 일부가 됩니다. 개체 D를 추가하면 수집기는 이 개체가 개체 H를 참조하는 것으로 인식하고, 개체 H도 그래프에 추가됩니다. 수집기는 접근할 수 있는 모든 개체를 반복하여 돌아다니게 됩니다.

Figure 2 Allocated Objects in Heap
그림 2 힙 내 할당된 개체

그래프에서 이 부분이 완성되면, Garbage Collection기는 다음 루트를 확인하고 다시 개체 사이를 돌아 다닙니다. 가비지 수집기가 개체 사이를 다닐 때 이전에 추가된 그래프에 개체를 추가하려고 하면, 해당 가비지 수집기는 이동을 멈출 수 있습니다. 이는 두 가지 목적 때문인데, 하나는 한 번 이상 개체 집합 사이를 다니지 않음으로써 성능을 훨씬 증대시키기 위한 것이고, 다른 하나는 원형으로 된 개체의 연결 목록이 있을 때 무한 루프가 발생하지 않도록 하려는 것입니다.
루트 전부를 확인한 다음에는 Garbage Collection기의 그래프에 응용 프로그램의 루트에서 접근할 수 있는 모든 개체 집합이 포함됩니다. 이 그래프에 포함되지 않은 개체는 해당 응용 프로그램에서 액세스할 수 없는 개체로서, 가비지로 간주됩니다. 가비지 수집기는 이제 힙을 직선 방향으로 돌아다니며 가비지 개체의 블록(이제 자유 공간으로 간주)을 찾게 됩니다. 그리고 나서, 표준 memcpy 함수를 사용하여 메모리 내에서 가비지가 아닌 메모리를 바꾸어, 힙 내 모든 갭을 없애도록 합니다. 물론, 메모리 내에서 개체를 움직이는 것은 개체에 대한 포인터를 무효화하는 일입니다. 따라서, Garbage Collection기는 반드시 응용 프로그램의 루트를 수정하여 해당 포인터가 개체의 새 위치를 가리키도록 해야 합니다. 또한, 개체에 다른 개체에 대한 포인터가 없으면 Garbage Collection기가 책임을 지고 이러한 포인터의 수정 작업을 수행해야 합니다. 그림?3에서는 수집 후의 관리 힙을 보여줍니다.

Figure 3 Managed Heap after Collection
그림 3 수집 후의 관리 힙

가비지가 모두 확인된 후에는 가비지가 아닌 모든 메모리가 압축되고 가비지가 아닌 포인터는 모두 수정되며 NextObjPtr는 가비지 아닌 최종 개체 다음에 나타납니다. 이 때, 새로운 작업이 다시 시도되고 응용 프로그램에서 요청한 리소스는 성공적으로 만들어지게 됩니다.
여러분도 아시다시피, GC는 중요한 성능 상의 성공을 가져왔고, 이는 관리 힙 사용의 기반을 이룹니다. 그러나, GC는 오직 힙이 가득 찼을 때만 발생한다는 점과 그렇게 되기까지는 관리 힙이 C-런타임 힙보다 훨씬 빠르다는 점을 기억해야 합니다.런타임의 Garbage Collection기도 Garbage Collection의 성능을 향상시키는 최적화 내용을 일부 제공합니다.
이 시점에서 유의해야 할 몇 가지 중요한 사항이 있습니다. 여러분은 더 이상 응용 프로그램에서 사용하는 리소스의 수명 주기를 관리하도록 코드를 이행하지 않아도 됩니다. 그리고, 앞서 언급한 두 가지 버그도 이제는 존재하지 않습니다. 먼저, 리소스의 누출 가능성이 없는데, 이는 응용 프로그램의 루트에서 액세스할 수 없는 리소스가 어떤 시점에서 수집될 수 있기 때문입니다. 둘째로, 확보된 리소스를 액세스할 가능성이 없는데, 이는 해당 리소스가 접근 가능한 경우 늘릴 수 없기 때문입니다. 리소스에 접근할 수 없을경우, 응용 프로그램에서 이를 액세스할 방법이 없습니다. 그림?4의 코드에서는 리소스가 할당되고 관리되는 방법을 보여주고 있습니다.
GC의 크기가 상당히 클 때는, 왜 ANSI C++ 내에 들어 있지 않은지 이유를 궁금히 여길 수도 있습니다. 이는 가비지 수집기에서 응용 프로그램의 루트를 확인할 수 있어야 하고 모든 개체 포인터를 찾을 수 있어야 하기 때문입니다. C++의 문제는 한 유형에서 다른 유형으로 포인터를 보낼 수 있고, 어떤 포인터를 나타내는지 알 방법이 없다는 데 있습니다. 공통 언어 런타임에서, 관리 힙은 항상 개체의 실제 유형을 알고 있으며 메타 데이터 정보를 사용하여 다른 개체를 참조하는 개체의 구성원이 무엇인지 확인합니다.

맨 위로


실행 마무리
Garbage Collection기는 실행 마무리(finalization)를 이용할 수 있는 기능을 제공합니다. 실행 마무리 기능은 리소스가 수집 이후에 잘 정리되도록 합니다. 이를 사용함으로써 파일이나 네트워크 연결을 나타내는 리소스는 가비지 수집기에서 해당 리소스의 메모리를 늘리도록 결정할 때 적합하게 자신을 정리할 수 있습니다.
간단히 설명하면, 개체가 가비지임을 Garbage Collection기가 파악할 때 해당 Garbage Collection기는 개체의 Finalize 메서드를 호출한 다음, 해당 개체의 메모리를 재생한다는 것입니다. 예를 들어, C# 내에 다음과 같은 유형을 갖고 있다고 합시다.
public class BaseObj {
    public BaseObj() {
    }

    protected override void Finalize() {
        // Perform resource cleanup code here... 
        // Example: Close file/Close network connection
        Console.WriteLine("In Finalize."); 
    }
}
이제 다음을 호출하여 이 개체의 인스턴스를 만들 수 있습니다.
BaseObj bo = new BaseObj();
앞으로는 Garbage Collection기에서 이 개체는 가비지임을 결정하게 됩니다. 이 때 가비지 수집기는 해당 유형에 Finalize 메서드가 있음을 알고 이를 호출하여 콘솔 창에 "In Finalize"가 나타나도록 하고 이 개체에 사용된 메모리 블록을 재사용하게 됩니다.
C++로 프로그래밍을 수행하던 개발자들은 소멸자와 Finalize 메서드 간에 즉각적인 상호 관계를 그리게 됩니다. 그러나, 저는 여러분께 이 사실을 말씀드리고 싶습니다. 즉, 개체 마무리와 소멸자는 서로 다른 기능을 갖고 있으며, 마무리에 대해 생각할 때는 소멸자에 대해 알고 있는 모든 사항을 잊는 것이 좋다는 것입니다. 관리 개체에는 소멸자 기간이 없습니다.
유형을 디자인할 때는 Finalize 메서드를 사용하지 않는 것이 좋습니다. 이유는 다음과 같습니다.
  • 실행 마무리가 가능한 개체는 구세대로 수준이 올라가게 되는데, 이로써 메모리 압력이 늘어나고 가비지 수집기가 개체의 가비지 여부를 결정할 때 개체의 메모리가 수집되는 것을 막게 됩니다. 또한, 이 개체에 의해 직접 또는 간접으로 참조된 모든 개체도 함께 수준이 올라갑니다.
  • 실행 마무리가 가능한 개체는 할당에 시간이 걸립니다.
  • Garbage Collection기에 Finalize 메서드 실행을 강제로 하게 하면 심각한 성능 문제가 발생합니다. 각 개체는 마무리되어 있음을 기억하십시오. 따라서 10,000 개의 개체 배열이 있을 경우에는 각 개체가 모두 각자의 Finalize 메서드를 호출해야 합니다.
  • 실행 마무리가 가능한 개체는 다른(실행 마무리가 가능하지 않은) 개체를 참조할 수 있고, 이에 따라 수명 주기가 불필요하게 늘어나게 됩니다. 실제로 여러분은 하나의 유형을 두 개의 서로 다른 유형으로 분할하는 문제를 생각해 볼 수 있을 것입니다. 즉, 다른 개체를 참조하지 않도록 Finalize 메서드를 가진 가벼운 유형과 다른 개체를 참조하도록 Finalize 메서드가 없는 별도의 유형 두 가지를 말합니다.
  • Finalize 메서드가 실행될 시기에 대해 제어할 수 없습니다. 가비지 수집기가 실행되는 다음 시간까지 리소스에 개체가 유보되어 있습니다.
  • 응용 프로그램이 종결될 때, 일부 개체에 접근할 수 있어 이들의 Finalize 메서드가 호출되지 않게 됩니다. 이는 백그라운드 스레드에서 개체를 사용하거나 응용 프로그램 종료 또는 AppDomain 언로드시 개체가 만들어지는 경우에 발생합니다. 기본적으로, Finalize 메서드는 응용 프로그램이 종료될 때 접근할 수 없는 개체에 대해서는 호출되지 않으며, 이는 응용 프로그램이 빨리 종료되도록 하기 위해서입니다. 물론, 모든 운영 체제 리소스는 재사용이 가능하지만, 관리 힙의 개체는 자연스럽게 정리되기가 힘듭니다. 이러한 기본적인 작동은 System.GC 유형의 RequestFinalizeOnShutdown 메서드를 호출함으로써 바꿀 수 있습니다. 그러나, 이 메서드를 사용할 때는 조심해야 하는데, 이는 여러분의 유형이 전체 응용 프로그램에 대한 정책을 제어하기 때문입니다.
  • 런타임은 어떤 Finalize 메서드가 호출되는가 하는 순서의 문제에 관해서는 아무 보증도 하지 않습니다. 예를 들어, 내부 개체에 대한 포인터를 보유한 개체가 있다고 합시다. 가비지 수집기에서는 두 개체가 모두 가비지라는 사실을 파악했습니다. 덧붙여, 내부 개체의 Finalize 메서드가 먼저 호출됩니다. 이제, 외부 개체의 Finalize 메서드는 내부 개체를 액세스하고 그 위에 메서드를 호출하도록 되어 있으나, 내부 개체는 마무리되었고 그 결과는 예측할 수 없는 상태입니다. 따라서, Finalize 메서드는 내부 구성원 개체를 액세스하지 않는 것이 좋습니다.
여러분의 유형에서 반드시 Finalize 메서드를 이행하도록 결정한 경우에는, 해당 코드를 가능한 한 빨리 실행하도록 하십시오. 스레드 동기화 작업을 포함하여 Finalize 메서드를 차단하는 어떠한 작업도 피하도록 해야 합니다. 또한, 어떤 예외 코드로 인해 Finalize 메서드를 끝내는 경우에는 시스템에서 해당 Finalize 메서드가 다른 개체의 Finalize 메서드를 반환하고 계속해서 호출하는 것으로 가정합니다.
컴파일러에서 생성자에 대한 코드를 만들 때, 해당 컴파일러는 기본 유형의 생성자에 대한 호출을 자동으로 삽입합니다. 이와 같이, C++ 컴파일러에서 소멸자에 대한 코드를 생성할 때는 기본 유형의 소멸자에 대한 호출을 자동으로 삽입하게 됩니다. 그러나, 앞서 언급한 바와 같이, Finalize 메서드는 소멸자와 다릅니다. 컴파일러에 Finalize 메서드에 관한 특별한 지식이 없으므로, 기본 유형의 Finalize 메서드를 호출하는 코드는 자동으로 만들지 않습니다. 이 작동을 원하는 경우에는 기본 유형의 Finalize 메서드를 여러분 유형의 Finalize 메서드에서 명확히 호출해야 합니다.
public class BaseObj {
    public BaseObj() {
    }

    protected override void Finalize() {
        Console.WriteLine("In Finalize."); 
        base.Finalize();    // Call base type's Finalize
    }
}
파생된 유형의 Finalize 메서드의 마지막 명령문에서 기본 유형의 Finalize 메서드를 호출하게 된다는 점을 유의하십시오. 즉, 기본 개체는 가능한 한 오래 살아 있게 됩니다. 기본 유형 Finalize 메서드의 호출이 일반적이기 때문에 C#에는 이러한 작업을 단순하게 처리하는 구문이 있습니다. C#에서는 다음 코드를 사용합니다.
class MyObject {
    ~MyObject() {
        ???
    }
}
이로써, 컴파일러에서 다음 코드를 만들 수 있습니다.
class MyObject {
    protected override void Finalize() {
        ???
        base.Finalize();
    }
}
이러한 C# 구문은 소멸자를 정의하는 데 있어서 C++ 언어의 구문과 동일해 보입니다. 그러나, C#는 소멸자를 지원하지 않는다는 사실을 유의하십시오. 무조건 구문을 똑같이 사용하지 않도록 해야 합니다.

맨 위로


실행 마무리의 내부 사항
표면상으로는 실행 마무리가 매우 간단해 보입니다. 즉, 여러분은 개체를 만들고, 이 개체가 수집될 때 해당 개체의 Finalize 메서드가 호출된다는 것입니다. 그러나,실행 마무리에는 이보다 복잡한 내용이 있습니다.
응용 프로그램에서 새 개체를 만들 때는 새로운 연산자가 힙에서 메모리를 할당합니다. 이 개체의 유형에 Finalize 메서드가 포함되어 있으면, 해당 개체에 대한 포인터가 실행 마무리의 대기열에 오르게 됩니다. 실행 마무리의 대기열은 Garbage Collection기에서 제어하는 내부 데이터 구조입니다. 이 대기열의 각 항목은 해당 개체의 메모리가 재사용되기 전에 자신의 Finalize 메서드가 호출되도록 하는 개체를 가리킵니다.
그림?5에서는 개체를 여러 개 포함하는 힙을 보여 줍니다. 이 개체 중 일부는 응용 프로그램의 루트에서 접근할 수 있으나, 일부는 접근할 수 없습니다. C, E, F, I, J 등의 개체가 만들어졌을 때 시스템에서는 이들 개체에 Finalize 메서드가 있고 이들 개체에 대한 포인터가 실행 마무리의 대기열에 추가되었음을 파악했습니다.

Figure 5 A Heap with Many Objects
그림 5

맨 위로


개체가 여러 개인 힙

GC가 발생할 때, B, E, G, H, I, J 등의 개체는 가비지로 판명됩니다. 가비지 수집기는 실행 마무리의 대기열을 흩어 보고 이러한 개체에 대한 포인터를 찾습니다. 포인터를 발견하면 실행 마무리의 대기열에서 해당 포인터를 제거하고 F-접근할 수 있는 대기열에 부가합니다. F-접근할 수 있는 대기열이란 가비지 수집기에서 제어하는 또 다른 내부 데이터 구조입니다. F-접근할 수 있는 대기열의 각 포인터는 자신의 Finalize 메서드가 호출될 준비가 되어 있는 개체를 구별하여 확인합니다.
수집 이후에, 관리 힙은 그림?6처럼 보입니다. 여기서, 여러분은 B, G, H 등의 개체에 의해 점유된 메모리가 재사용된 것을 볼 수 있는데, 이는 이들 개체가 호출되어야 할 Finalize 메서드를 갖고 있지 않았기 때문입니다. 그러나, E, I, J 등의 개체에 의해 점유된 메모리는 아직 이들의 Finalize 메서드가 호출되지 않았으므로 재사용될 수 없었습니다.

Figure 6 Managed Heap after Garbage Collection
그림 6 Garbage Collection 후의 관리 힙

Finalize 메서드의 호출 전용인 특수한 런타임 스레드가 있습니다. F-접근 가능한 대기열이 비어 있을 때(일반적인 경우에 해당), 이 스레드는 중지됩니다. 그러나, 항목이 나타나면 이 스레드가 깨어나 대기열에서 각 항목을 제거하고 각 개체의 Finalize 메서드를 호출합니다. 이러한 이유로 인해, 여러분은 코드를 실행하는 스레드에 관해 가정하는 Finalize 메서드에서 어떤 코드도 실행하지 말아야 합니다. 예를 들어, Finalize 메서드에서 스레드 로컬 저장소를 액세스하지 않도록 하십시오.
실행 마무리의 대기열과 F-접근 가능한 대기열의 상호 작용은 매우 환상적입니다. 먼저, F-접근 가능한 대기열이 그 이름을 얻게 된 내력을 말씀드리겠습니다. F는 확실히 실행 마무리(Finalization)를 가리키는 것으로서, F-접근 가능한 대기열에 있는 모든 항목은 자신의 Finalize 메서드가 호출되어야 합니다. "접근 가능한(reachable)"이란 부분은 해당 개체에 접근할 수 있다는 뜻입니다. 달리 말하면, 글로벌 변수와 정적 변수가 모두 루트인 것처럼 F-접근 가능한 대기열도 루트로 간주됩니다. 그러므로, F-접근 가능한 대기열에 한 개체가 있으면 이 개체는 접근할 수 있으면서 가비지가 아니라는 뜻이 됩니다.
간단히 말해서, 개체에 접근할 수 없는 경우 Garbage Collection기에서는 이 개체를 가비지로 여깁니다. 그리고 나서 Garbage Collection기가 실행 마무리의 대기열에서 F-접근 가능한 대기열로 개체의 항목을 옮길 때 해당 개체는 더 이상 가비지가 아니고 그 메모리를 재사용할 수 없게 됩니다. 이 때 Garbage Collection기는 가비지 확인 작업을 끝내게 됩니다. 가비지로 확인된 일부 개체는 가비지가 아닌 것으로 재분류됩니다. Garbage Collection기는 재사용 메모리를 압축하고 특수런타임 스레드는 F-접근 가능한 대기열을 비움으로써 각 개체의 Finalize 메서드를 실행하게 됩니다.

Figure 7 Managed Heap after Second Garbage Collection
그림 7 2차 Garbage Collection 후의 관리 힙

Garbage Collection기가 두 번째로 시작되면 마무리된 개체가 진정한 가비지인지 확인하는데, 이는 응용 프로그램의 루트가 이를 나타내지 않고 F-접근 가능한 대기열이 더 이상 이를 가리키지 않기 때문입니다. 이제, 개체에 대한 메모리는 단순하게 재사용됩니다. 여기서 중요한 것은 실행 마무리가 필요한 개체에서 사용된 메모리를 재사용하는 데는 두 번의 GC가 필요하다는 것입니다. 실제로, 두 번 이상 수집해야 개체가 구세대로 수준을 올릴 수 있습니다. 그림?7에서는 2차 GC 이후 관리 힙을 보여줍니다.

맨 위로


부활
실행 마무리의 전체적인 개념은 매우 훌륭합니다. 그러나, 분명 이제까지 설명한 이상의 것이 있다고 할 수 있습니다. 여러분은 앞서 언급한 설명에서 응용 프로그램이 더 이상 살아 있는 개체를 액세스할 수 없게 될 때 가비지 수집기는 개체를 죽은 것으로 간주한다는 것을 확인하게 됩니다. 그러나, 개체에 실행 마무리가 필요한 경우, 해당 개체는 다시 살아 있는 것으로 간주되며 실제로 마감된 때에만 영구히 죽은 것으로 본다는 것을 기억해야 합니다. 즉, 실행 마무리가 필요한 개체는 죽었다가 살아나며 다시 죽는다는 것입니다. 이렇게 흥미로운 현상을 부활이라 부릅니다. 이름 자체에서 의미하듯이 부활(resurrection)은 개체가 죽은 상태에서 회복되는 것을 말합니다.
위에서 부활의 한 형태를 설명했습니다. Garbage Collection기가 F-접근 가능한 대기열의 개체에 참조를 둘 때, 해당 개체는 루트에서 접근할 수 있고 다시 살 수 있습니다. 결국, 해당 개체의 Finalize 메서드가 호출되고 이 개체에 대해 아무 루트도 지정되지 않으면, 이 개체는 영원히 죽게 됩니다. 그러나, 개체의 Finalize 메서드가 글로벌 변수나 정적 변수의 개체에 포인터를 두는 코드를 실행하면 어떻게 될까요?
public class BaseObj {

    protected override void Finalize() {
        Application.ObjHolder = this; 
    }
}

class Application {
    static public Object ObjHolder;    // Defaults to null
???
}
이런 경우에는 해당 개체의 Finalize 메서드가 실행될 때, 개체에 대한 포인터가 루트에 놓이게 되고 해당 개체는 응용 프로그램의 코드에서 접근할 수 있게 됩니다. 이 개체는 이제 부활된 것으로서, 가비지 수집기는 이를 가비지로 간주하지 않게 됩니다. 응용 프로그램에서는 해당 개체를 자유롭게 사용하지만, 해당 개체가 마무리되었고 이 개체를 사용하면 예측할 수 없는 결과가 나타날 수 있다는 점을 간과해서는 안됩니다. 또한, BaseObj에서 다른 개체를 가리킨 구성원을 포함(직접 또는 간접으로)했다면, 모든 개체가 모두 응용 프로그램의 루트에서 접근 가능하므로 이들이 모두 부활된다는 것을 유의해야 합니다. 그러나, 이러한 다른 개체 중 일부도 마무리되었다는 점을 알아야 합니다.
사실, 여러분이 자체의 개체 유형을 디자인할 경우에는 여러분의 유형에 해당하는 개체가 마무리되었다가 여러분이 제어할 수 없는 상태에서 부활될 수 있습니다. 여러분의 코드를 이행하여 이러한 문제를 직접 해결할 수 있도록 하십시오. 여러 가지 유형에 있어서 이는 개체의 실행 마무리 여부를 나타내는 부울 플래그를 의미합니다. 이렇게 되면, 메서드가 마무리된 개체에서 호출될 때 예외 코드를 만들 수 있습니다. 어떤 기술을 사용할 것인지에 대한 문제는 여러분의 유형에 따라 다릅니다.
이제, 기타 코드의 일부에서 Application.ObjHolder를 null로 설정하는 경우, 해당 개체는 접근할 수 없습니다. 결국, 가비지 수집에서는 해당 개체를 가비지로 인식하고 해당 개체의 저장소를 재사용하게 됩니다. 해당 개체에 대한 포인터가 실행 마무리의 대기열에 없으므로 이러한 개체의 Finalize 메서드가 호출되지 않음을 유의하십시오.
부활을 긍정적으로 사용하는 예는 별로 없으므로, 되도록이면 이를 사용하지 않도록 해야 합니다. 그러나, 사람들이 부활을 사용하는 경우는 개체가 죽을 때마다 잘 정리하기 위해서입니다. 따라서, GC 유형은 ReRegisterForFinalize라는 메서드를 제공하여 단일 매개 변수로 개체에 대한 포인터를 사용하도록 합니다.
public class BaseObj {

    protected override void Finalize() {
        Application.ObjHolder = this; 
        GC.ReRegisterForFinalize(this);
    }
}
이 개체의 Finalize 메서드가 호출되면, 해당 개체에 대한 루트 포인트를 통해 부활됩니다. 다음으로 Finalize 메서드는 ReRegisterForFinalize를 호출하는데, 이는 실행 마무리의 대기열 끝에 지정된 개체의 주소를 부가합니다. 이 개체가 다시 접근 불가 상태가 된 것을 가비지 수집기에서 파악할 때, F-접근 가능한 대기열에 해당 개체의 포인터가 놓이게 되고 Finalize 메서드는 다시 호출됩니다. 이러한 예는 끊임 없이 부활하여 다시 죽지 않는 개체를 만드는 방법을 보여 주지만, 일반적으로 바람직한 것은 아닙니다. Finalize 메서드 안에 개체를 참조하도록 루트를 조건적으로 설정하는 것이 훨씬 일반적인 일입니다.
한 번의 부활에 ReRegisterForFinalize를 한 번씩만 호출하도록 하십시오. 그렇지 않으면, 개체의 Finalize 메서드가 여러 번 호출됩니다. 이는 ReRegisterForFinalize에 대한 각각의 호출로 실행 마무리 대기열의 끝에 새로운 항목이 부가되기 때문에 발생합니다. 개체가 가비지인 것으로 판명될 경우, 이러한 항목이 모두 실행 마무리의 대기열에서 F-접근 가능한 대기열로 옮겨지고 해당 개체의 Finalize 메서드가 여러 번 호출됩니다.

맨 위로


개체의 강제 정리
가능하다면, 정리가 필요 없는 개체를 정의하는 것이 좋습니다. 그러나, 불행하게도 이는 불가능한 일입니다. 이러한 개체에 있어서는 해당 유형의 정의로서 Finalize 메서드를 이행해야 하지만, 해당 유형의 사용자가 원할 때 명확히 해당 개체를 정리할 수 있도록 유형에 메서드를 추가하는 것이 좋습니다. 규정에 따라, 이 메서드는 Close나 Dispose로 불러야 합니다.
일반적으로, 개체가 닫힌 후 다시 열리거나 재사용할 수 있을 때 Close를 사용합니다. 또한, 파일과 같이 개체가 닫힌 것으로 간주될 때도 Close를 사용할 수 있습니다. 반면에, 개체가 처분된 이후 더 이상 사용될 수 없도록 하려면 Dispose를 사용하도록 합니다. 예를 들어, System.Drawing.Brush 개체를 삭제하려면, Dispose 메서드를 호출해야 합니다. 일단 처분된 이후에는 이 Brush 개체를 사용할 수 없고, 이 개체를 조작하기 위해 메서드를 사용하면 예외 코드가 나타날 수 있습니다. 다른 Brush로 작업해야 할 때는, 새로운 Brush 개체를 생성해야 합니다.
이제, Close/Dispose 메서드가 어떤 작업을 수행하게 되는지 살펴 봅시다. System.IO.FileStream 유형을 사용하는 사용자는 파일을 열어 읽거나 쓸 수 있습니다. 성능을 향상시키기 위해 이 유형의 이행으로 메모리 버퍼의 사용을 만들게 됩니다. 버퍼가 채워질 때만 유형이 버퍼의 내용을 파일에 플러시합니다. 여러분은 새로운 FileStream 개체를 만들고 여기에 몇 바이트의 정보만 작성하면 됩니다. 이러한 바이트로는 버퍼를 채우지 않으며, 해당 버퍼는 디스크에 작성되지 않습니다. FileStream 유형은 Finalize 메서드를 이행하고, FileStream 개체가 수집될 때 Finalize 메서드가 메모리에서 디스크로 남은 데이터를 플러시한 다음, 해당 파일을 닫습니다.
하지만, 이러한 접근법은 FileStream 유형의 사용자에게는 충분하지 않을 수도 있습니다. 처음 FileStream 개체가 아직 수집되지 않았으나, 응용 프로그램에서 동일한 디스크 파일을 사용하여 새로운 FileStream 개체를 만들려고 하는 경우를 예로 들어 봅시다. 이 때, 처음 FileStream 개체에 배타적인 파일 열기 권한을 갖는 경우 둘째 FileStream 개체는 해당 파일을 열 수 없습니다. FileStream 개체의 사용자는 디스크로 마지막 메모리 파일을 강제 플러시하고 해당 파일을 닫는 몇 가지 방법을 보유해야 합니다.
FileStream 유형의 설명서를 보면 Close라는 메서드가 이 유형에 들어 있음을 알 수 있습니다. 이 메서드는 호출 시, 메모리에 남아 있는 데이터를 디스크로 플러시하고 해당 파일을 닫습니다. 이제 FileStream 개체의 사용자에게는 이러한 개체의 작동을 제어하는 권한이 있습니다.
그러나, 이렇게 되면 매우 흥미로운 문제가 하나 나타납니다. 즉, FileStream의 Finalize 메서드는 FileStream 개체가 수집될 때 어떤 작업을 수행해야 하는가 하는 문제입니다. 확실히 말해, 이에 대한 응답은 전혀 없다입니다. 사실, 응용 프로그램에서 명확히 Close 메서드를 호출했다면, FileStream의 Finalize 메서드가 수행될 이유는 전혀 없습니다. 여러분은 Finalize 메서드가 억제됨을 알면서도, 이 시나리오에서 시스템이 아무 작업도 하지 않는 Finalize 메서드를 호출하도록 하고 있습니다. 이는 시스템에서 개체의 Finalize 메서드를 호출하지 못하도록 하는 방법이 있어야 한다는 것을 의미합니다. 다행히도 이러한 방법이 있으며, System.GC 유형에는 개체의 주소라는 단일 매개 변수를 취하는 SuppressFinalize라는 정적 메서드가 있습니다.
그림?8에서는 FileStream의 유형 이행을 보여 줍니다. 이는 SuppressFinalize를 호출할 때, 해당 개체와 관련된 커다란 플래그로 나타나게 됩니다. 이 플래그가 나타나면, 런타임은 이 개체의 포인터가 F-접근 가능한 대기열로 이동되지 않음을 알게 되고, 그 개체의 Finalize 메서드가 호출되지 않도록 합니다.
관련된 다른 문제를 확인해 봅시다. 이는 FileStream 개체와 StreamWriter 개체를 함께 사용하는 경우와 매우 비슷합니다.
FileStream fs = new FileStream("C:\\SomeFile.txt", 
    FileMode.Open, FileAccess.Write, FileShare.Read);
StreamWriter sw = new StreamWriter(fs);
sw.Write ("Hi there");

// The call to Close below is what you should do
sw.Close();   
// NOTE: StreamWriter.Close closes the FileStream. The FileStream
//       should not be explicitly closed in this scenario
StreamWriter의 생성자는 FileStream 개체를 매개 변수로 취한다는 사실을 기억해야 합니다. 내부적으로, StreamWriter 개체는 FileStream의 포인터를 저장합니다. 이 두 개체 모두 여러분이 파일 액세스를 끝낼 때 해당 파일에 플러시되도록 할 내부 데이터 버퍼가 있습니다. StreamWriter의 Close 메서드를 호출하면, FileStream에 마지막 데이터를 작성하게 되고 내부적으로는 FileStream의 Close 메서드를 호출하게 됩니다. 이 메서드는 디스크 파일에 마지막 데이터를 작성하고 해당 파일을 닫습니다. StreamWriter의 Close 메서드가 이와 관련된 FileStream 개체를 닫기 때문에 여러분은 fs.Close를 직접 호출하지 말아야 합니다.
Close에 대한 두 가지 호출을 제거하면 어떤 상황이 발생할까요?Garbage Collection기는 개체가 가비지인 것과 개체의 마무리가 완료된 것을 정확히 파악하게 될 것입니다. 그러나, Garbage Collection기는 Finalize 메서드가 호출되는 순서를 보증하지는 않습니다. 따라서, FileStream이 처음으로 마무리되면, 이는 파일을 닫습니다. 그리고 나서, StreamWriter가 마무리되면 닫힌 파일에 데이터를 쓰려고 할 것이며, 이에 따라 예외 코드가 발생하게 됩니다. 물론, StreamWriter가 먼저 마무리되면 데이터는 안전하게 파일에 작성될 것입니다.
Microsoft에서는 이 문제를 어떻게 해결했을까요? 개체에는 서로에 대한 포인터가 있을 수 있고 가비지 수집기가 이러한 개체를 마무리하는 순서를 정확히 파악하도록 하는 방법은 없으므로 가비지 수집기가 특정한 순서에 따라 개체를 마무리하도록 하는 것은 불가능합니다. 그러므로, Microsoft의 해결 방법은 StreamWriter 유형이 Finalize 메서드를 전혀 이행하지 않도록 하는 것입니다. 물론, 이는 StreamWriter 개체를 명확히 닫는 것을 잊어버릴 경우에는 데이터 손실이 발생한다는 의미가 됩니다. Microsoft에서는 개발자들이 이러한 데이터 손실을 확인하게 되면서 Close에 대한 호출을 명백히 삽입하여 코드를 수정할 것으로 기대하고 있습니다.
앞서 언급한 바와 같이, SuppressFinalize 메서드는 개체의 Finalize 메서드가 호출되지 않아야 함을 알리는 플래그만 단순히 설정합니다. 그러나, 런타임에서 Finalize 메서드를 호출할 시간을 결정할 때 이 플래그는 다시 설정됩니다. 즉, ReRegisterForFinalize에 대한 호출은 SuppressFinalize에 대한 호출에 의해 균형을 맞출 수 없다는 뜻입니다. 그림?9의 코드는 이러한 내용을 정확히 보여주고 있습니다.
ReRegisterForFinalize와 SuppressFinalize는 성능상의 이유로 이행되는 방법입니다. SuppressFinalize에 대한 각각의 호출에 ReRegisterForFinalize에 대한 중재 호출이 있을 경우에만, 모든 작업이 순조롭게 진행됩니다. ReRegisterForFinalize나 SuppressFinalize를 연속하여 여러 번 호출하지 않도록 하는 것은 여러분께 달려 있으며, 이러한 주의를 놓치게 되면 개체의 Finalize 메서드에 대해 여러 번 호출하는 일이 발생할 수 있습니다.


결론
Garbage Collection 환경에 대한 동기는 개발자들이 메모리 관리를 보다 간편하게 하도록 하는 데 있습니다. 이 개요의 1부에서는 일반적인 GC 개념과 내부 사항에 대해 검토해 보았습니다. 먼저, 크기가 큰 개체에 의해 관리 힙에 부여되는 메모리 압력을 줄이는 데 사용할 수 있는 WeakReferences라는 기능에 대해 알아 보고, 관리 개체의 수명 주기를 인공적으로 늘리는 데 사용할 메커니즘에 대해 설명할 예정입니다. 마지막으로, 가비지 수집기의 성능에 대한 여러 가지 측면에 대해 논의하겠습니다. 또한, 세대와 멀티 스레드 수집, 공통 언어 런타임에서 나타나는 성능 카운터 등을 설명하겠습니다. 여기서 성능 카운터는 여러분이 가비지 수집기의 실시간 작동을 모니터할 수 있도록 하는 기능입니다.

Jeffrey RichterProgramming Applications for Microsoft Windows (Microsoft Press, 1999)의 저자이며, 소프트웨어 교육, 디버깅, 컨설팅 회사인 Wintellect (http://www.wintellect.com/)의 공동 설립자입니다. Richter는 .NET 및 Win32의 프로그래밍과 디자인에 정통한 전문가로서, 현재 Microsoft .NET Frameworks 프로그래밍 교재를 집필 중이며 .NET 기술 세미나를 개최하고 있습니다.

? 최종수정일: 2000년 12월 8일

Top of Page Top of Page


Microsoft