닷넷 플랫폼 환경은 메모리 회수를 가비지 컬렉션에 의존하고 있습니다. 그래서 파일, 데이터베이스 연결과 같은 시스템 자원을 해제하기 위해 기존 C/C++과 다른 방식을 취합니다. 그 중 하나가 Finalizer 입니다. Finalizer는 시스템 자원을 해제하기 위한 최후의 보루로써 사용되곤 하지만 잘못된 사용 패턴은 관리되는 힙이 비 효율적으로 사용되게 만듭니다. 과거 제 글에서 아주 간단히 다룬 적이 있는 내용이지만, 이번 포스트에서는 Finalizer의 기본 내용과 사용 시 주의 사항들을 좀 더 자세히 살펴보도록 하겠습니다. 특히, Finalizer는 세대별 가비지 컬렉션과 연관이 있으며 다음에(언젠지 모르지만) 다루게 될 Dispose 패턴과도 밀접한 관련이 있으니 잘 알아 두시면 여러모로 도움이 되리라 생각합니다.

About Finalizer

닷넷의 메모리 관리는 CLR의 가비지 컬렉터에 의해 수행된다. 메모리 할당과 해제가 모두 CLR에 의해 관리된다는 말이 되겠다. 가비지 컬렉션에 대해서는 이미 입이 닳도록 설명한 바, 아직 가비지 컬렉션에 대해 잘 알지 못하는 독자는 30초 정도 숏을 잡고 반성한 후에 최소 다음 2개의 글을 읽어 보아야 할 것이다.

    위 글을 읽어 본 후에 조금 더 여력이 생긴다면 다음 두 글까지도 읽어 보면 매우 좋다.

이렇게 메모리 관리를 CLR이 알아서 해주므로 개발자는 매우 편리하게 프로그램을 작성할 수 있지만 항상 그런 것은 아니다. 어떤 객체가 파일이나 데이터베이스 연결, 그리고 CLR에 의해 관리되지 않는 메모리 등등 시스템 자원을 사용하는 경우, 이들 자원들은 CLR에 의해 자동으로 해제되지 않는다. 따라서 개발자가 이들을 해제해 주어야만 하는 것이다. 여기서 문제가 발생한다. 객체에 대한 정리는 가비지 컬렉터가 알아서 한다고 했는데, 도대체 언제 이들 시스템 자원을 반환할 것인가 이다. 그래서 CLR이 제공하는 기법이 Finalizer 이다.

Finalizer 기초

Finalizer는 생성자(constructor)와 더불어 클래스의 특수 메서드로 분류되는 메서드이다. CLR에게 Finalizer 메서드는 반환 타입이 없고 매개변수도 없으며 이름이 “Finalizer”인 메서드이다. 하지만 C#이나 VB.NET, C++/CLI와 같은 닷넷 기반 프로그래밍 언어는 각기 Finalizer 메서드를 나타내는 방법이 다르다(졸라 귀찮게 시리). C#은 ~ 문자와 클래스 이름을 사용하여 다음과 같이 Finalizer 메서드를 정의하며,

class SomeType
{
    ~SomeType()
    {
        // Finalizer
    }
}

이젠 근본이 뭔지도 모르게 변해 버린 VB.NET에서는 Finalize라는 메서드를 오버라이드(override) 하는 형태로 다음과 같이 정의 한다.

Public Class DisposeExample
    Protected Overrides Sub Finalize()
        ' Finalizer
    End Sub
End Class

사실 Finalizer는 Object 클래스의 protected virtual 멤버 메서드로써 VB.NET의 표현이 좀더 정확하다고 할 수도 있다. 한편, C++/CLI에서는 C++ 언어 자체의 파괴자(destructor)와 구분을 위해 좀 쌩뚱 맞게 ! 를 사용하여 Finalizer를 표현하고 있다. (주: C++/CLI에서 ~ 문자를 사용하는 파괴자는 Dispose 메서드로 사용된다)

public ref class SomeType
{
public:
    !SomeType()
    {
        // Finalizer
    }
}

Finalizer 메서드는 객체가 가비지 컬렉터에 의해 제거 될 때에만 호출되며 명시적으로 호출할 방법은 없다. 앞서 간곡히 부탁했던 가비지 컬렉션 관련 글들을 읽어 보았다면, 가비지 컬렉션이 언제 발생할지는 며느리도 모른다는 것을 이해했을 것이다. 따라서 Finalizer가 언제 호출될지도 역시 알 수 없다. 언제일지는 몰라도 CLR은 반드시 Finalizer 메서드를 호출해 주기 때문에 이 메서드 내에서 열어 두었던 파일, 데이터베이스 연결 등을 닫거나 할당해 두었던 관리되지 않는 메모리를 해제하면 되는 것이다.

다음 코드는 Finalizer 메서드가 언제 호출되는지를 확인해 주는 코드이다. obj = null 문장이 수행되더라도 아직 가비지 컬렉션이 수행되지 않았으므로 Finalizer가 호출되지 않으며, GC.Collect 메서드를 호출하여 강제로 가비지 컬렉션을 수행한 이후에 Finalizer 메서드가 호출됨에 주목하자. 이 코드에서 GC.Collect 메서드를 호출하지 않더라도 Finalizer 메서드가 호출되는데 그 이유는 프로그램이 종료함에 따라서 CLR이 Finalizer 메서드를 호출해 주기 때문이다.

class SomeType
{
    ~SomeType()
    {
        Console.WriteLine("Finalizer()...");
    }
}
 
class Program
{
    static void Main(string[] args)
    {
        SomeType obj = new SomeType();
        obj = null;                     // GC 대상으로 만든다.
        Console.WriteLine("Press any key to GC.Collect()");
        Console.ReadKey(false);
        GC.Collect();
    }
}

일반적으로 Finalizer 메서드에서는 관리되지 않는 자원들을 해제하는 작업을 한다. 예를 들자면, SqlConnection 클래스의 Finalizer 메서드는 데이터베이스 연결이 아직 닫히지 않았다면 데이터 베이스 연결을 닫으며, FileStream 클래스, StreamReader/StreamWriter 역시 파일이 아직 닫히지 않았다면 Finalizer 메서드에서 파일을 닫는 작업을 수행한다.

Finalizer의 꽁꼬 깊숙한 곳

Finalizer 메서드는 단순해 보이지만 상당한 수준의 복잡성을 내포하고 있다. 첫째로, 닷넷의 Finalizer 메서드는 C++의 파괴자(destructor)와 달리 호출 시점을 예측할 수 없다. C++의 파괴자는 로컬 객체의 영역을 벗어나거나 명시적으로 delete 연산자를 호출함으로써 호출할 수 있지만 닷넷의 Finalizer는 그렇게 간단하지 않다.

Finalizer 메서드가 정의된 타입(클래스)의 인스턴스가 생성되면 CLR은 Finalizer 메서드를 가진 객체들 목록에 이 객체를 추가해 둔다. 그리고 나중에 가비지 컬렉션이 수행되고 Finalizer 메서드를 가진 객체가 정리의 대상이 된다면 이 객체를 Finalizer 큐(FReachable 큐라고도 부른다)에 삽입한다. 가비지 컬렉션은 이렇게 종료된다. 그리고 별도의 Finalizer 스레드가 Finalizer 큐에 삽입된 객체들을 꺼내어 이 객체의 Finalizer 메서드를 호출하게 된다.

그림 1은 이와 같은 상황을 보여주고 있다. 객체 위에 까만 일자 눈썹을 달고 있는 놈들이 Finalizer 메서드를 가지고 있는 객체이다. 가비지 컬렉션이 수행됨에 따라 B, D, F, G 객체가 가비지 컬렉션의 대상이 되었다고 가정해 보자. 가비지 컬렉션이 수행된 후에 D, G 객체는 Finalizer 메서드를 포함하는 객체이기 때문에 Finalizer 큐에 삽입되며 Finalizer 스레드가 이들 객체를 큐에서 꺼내어 Finalizer 메서드를 호출할 때까지 이 객체들은 힙에 살아 남게 된다.

image
그림1. Finalizer 메서드를 가진 객체들에 대한 가비지 컬렉션 (만지면 커져요)

앞서 Finalizer 메서드가 호출되는 시점이 가비지 컬렉션이 수행될 때라고 했었지만 정확히 말하자면 가비지 컬렉션이 완료된 후 Finalizer 스레드에 의해 Finalizer 메서드가 호출되고 있음을 알 수 있을 것이다. C++의 파괴자에 비하면 Finalizer 메서드의 호출 시점은 더욱 더 결정하기 어려운(non-deterministic) 것이 되고 만다.

Finalizer 사용시 유의 사항

Finalizer 메서드는 관리되지 않는 자원을 해제하기 위한 유용한 방법이긴 하지만 권장되는 방법이 아니다. 그 이유를 하나씩 살펴보도록 하겠다.

첫째, Finalizer 메서드가 정의된 객체는 Finalizer 메서드가 없는 객체에 비해 오랫동안 관리되는 힙에 남아 있게 된다. 그림 1에서 알 수 있듯이 Finalizer 큐에서 D, G 객체에 대한 참조를 가지고 있기 때문에 이 객체들은 가비지 컬렉션 대상에서 제외되며 나이를 먹어 1세대 힙으로 프로모션 되고 있음에 주목하자(세대별 가비지 컬렉션 참조). 높은 세대(1 혹은 2 세대)의 힙에 대한 가비지 컬렉션은 0 세대에 비해 자주 발생하지 않기 때문에 상대적으로 오랫동안 관리되는 힙을 차지하게 되며 메모리 효율을 떨어뜨릴 수 있다. 그림 1에서도 B, F 객체는 힙에서 제거 되었지만 D, G 객체는 여전히 힙에 남아 있음을 확인할 수 있다.

Finalizer 스레드가 큐에 존재하는 객체들에 대해 Finalize 메서드를 호출함에 따라 큐에서 제거되고 이들 객체들에 대한 참조 역시 제거된다. 따라서 다음 1세대(0세대가 아니다!) 가비지 컬렉션이 발생하면 그때서야 이 객체들이 메모리에서 제거될 것이다. 만약 Finalize 메서드를 가지고 있는 어떤 객체가 사용 중인 상황이어서 0 세대 가비지 컬렉션(상당히 자주 발생할 수 있다)에서 살아남아 1세대 힙으로 프로모션 된 후에 가비지 컬렉션 대상이 되었다면, 이 객체는 무조건 2세대 힙으로 프로모션 될 것이다. 2세대 가비지 컬렉션의 발생 주기는 0, 1세대에 비해 상당히 길다는 것을 기억하자. 이와 같이 Finalize 메서드를 포함하는 객체는 그렇지 않은 객체에 비해 사용되지도 않으면서 체가 오랫동안 힙 상에 남아 있을 수 있다는 점을 잘 이해하기 바란다.

두 번째로, Finalizer 메서드를 호출하는 스레드가 Finalizer 스레드이기 때문에 특정 스레드에 선호도(affinity)를 갖는 객체일 경우 문제를 발생할 수 있다. 예를 들어, Finalizer 메서드 내에서 잠금을 해제하려는 시도는 매우 위험하다. 왜냐하면 잠금을 잠근 스레드는 어플리케이션의 스레드이지만 잠금을 해제하려는 스레드는 잠금을 잠근 스레드가 아닌 Finalizer 스레드이기 때문이다.

세 번째로, Finalizer 메서드가 호출되는 순서를 알 수 없다는 것이다. 예를 들어, 그림 1에서 D, G 객체 순서로 Finalizer 메서드가 호출되거나 G, D 순서로 Finalizer 메서드가 호출될 수도 있다. Finalizer 호출 순서가 뭐 그리 중요하냐고 반문할 독자가 있을 지 모르겠다. 그래서 예제 코드를 하나 들어보도록 하자. 우린 개발자니깐 한글보다 코드에 더 익숙하지 않은가?

다음 코드를 살펴보자. DangerousType 클래스는 생성자에서 StreamWriter 객체를 생성하고 Finalizer 메서드에서 StreamWriter를 닫는다. 따라서 파일이 닫히지 않는 경우는 발생하지 않을 것이다. 매우 훌륭해 보인다.

class DangerousType
{
    private StreamWriter _stream;
 
    public DangerousType()
    {
        _stream = new StreamWriter("Test.txt");
    }
 
    ~DangerousType()
    {
        _stream.Close();    // 오류가 발생할 수도 있다!
    }
 
    public void DoSomething()
    {
        //...... 생략 ......
    }
}

하지만 이 코드는 가비지 컬렉션이 발생하면 다음과 같은 ObjectDisposedException 예외를 유발한다.

처리되지 않은 예외: System.ObjectDisposedException: 닫혀 있는 파일에 액세스할 수 없습니다.
   위치: System.IO.__Error.FileNotOpen()
   위치: System.IO.FileStream.Write(Byte[] array, Int32 offset, Int32 count)
   위치: System.IO.StreamWriter.Flush(Boolean flushStream, Boolean flushEncoder)
   위치: System.IO.StreamWriter.Dispose(Boolean disposing)
   위치: System.IO.StreamWriter.Close()
   위치: SimpleFinalizerTest.DangerousType.Finalize() 파일 D:\Work\…\Program.cs:줄 28

왜 이러한 예외가 발생하는지 그림 2를 살펴보자. DangerousType 객체는 멤버 필드로 StreamWriter 객체를 참조하고 있다. 이 상황에서 DangerousType 객체가 가비지 컬렉션의 대상이 된다면 그림 2와 같이 두 객체가 모두 Finalizer 큐에 삽입될 것(StreamWriter 클래스도 Fianlizer 메서드를 정의하고 있음)이다. 그리고 Finalizer 스레드가 큐에서 객체들을 꺼내어 Finalizer 메서드들을 호출하기 시작하면서 문제가 발생한다. 만약 Finalizer 스레드가 StreamWriter 객체에 대해 먼저 Finalizer 메서드를 호출했다면 StreamWriter 객체는 파일을 닫게 될 것이다. 그 이후에 Finalizer 스레드가 DangerousType의 Finalizer 메서드를 호출하면 이미 닫힌 파일에 대해 또 다시 Close 메서드가 호출되고 그 결과 ObjectDisposedException 예외가 발생하게 되는 것이다. Finalizer 메서드 호출 순서가 반대로 되더라도 마찬가지로 ObjectDisposedExceptino 예외가  발생된다.

image
그림2. 관리되는 객체에 대한 참조를 가진 Finalizable 객체의 가비지 컬렉션(만지면 커져요)

이렇게 Finalizer 메서드가 호출되는 순서가 비결정적(non-deterministic)이기 때문에 Finalizer 메서드 내에서는 관리되는 객체들에 대한 해제 작업(Dispose 나 Close 메서드 호출 등)을 수행하지 않아야 한다. 하지만 Finalizer 메서드가 호출되는 시점이 객체가 더 이상 사용되지 않는 시점보다 훨씬 늦기 때문에 메모리나 데이터베이스 연결과 같은 중요한 시스템 자원들이 더 오랫동안 점유되고 낭비될 수 있다.

그래서… Dispose 패턴을 써야 한다

닷넷 환경에서 가비지 컬렉션 메커니즘에 의해 객체의 제거와 자원의 정리가 비결정적이라는 점을 해결하기 위해 제시된 것이 바로 Dispose 패턴이다. Dispose 패턴은 IDisposable 인터페이스를 사용하여 더 이상 사용되지 않는 시스템 자원들을 즉시 반납하도록 하며 Finalizer 메서드를 호출하기 위해 객체가 힙 상에 남아 있지 않게 해준다. 다음 포스트에서 Dispose 패턴에 대해 상세히 알아보도록 하겠다. To Be Continued……


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