아이템 11: .NET 리소스 관리에 대한 이해

.NET 프로그램은 관리 환경에서 수행되기 때문에 C# 프로그램의 설계에 적지 않은 영향을 미친다.

관리 환경의 장점을 온전히 활용하려면 다른 수행 환경에서 익숙해진 생각의 틀 .NET 공용 언어 런타임(CLR)에 맞게 근본적으로 변경해야 한다.

객체의 생명주기를 이해하려면 .NET의 가비지 수집기(CG)의 동작 방식을 잘 알아야 하고, 비관리 리소스를 어떻게 다룰지에 대해서도 정확히 이해해야 한다.

가바지 콜렉터는 관리되는 메모리를 관장하며 네이티브 환경과 다르게 메모리 누수, 댕글링 포인터, 초기화되지 않는 포인터, 여타의 메모리 관리 문제를 개발자들이 직접 다루지 않도록 자동화해준다.

그럼에도 개발자가 올바르게 해제 작업을 수행하면 더욱 효과적으로 가비지 수집이 이뤄질 수 있다.

이와 반대로 데이터베이스 연결, GDI+ 객체, COM 객체, 시스템 객체 등과 같은 비관리 리소스는 여전히 개발자가 직접 관리해야 한다.

여기에 더해 이벤트 핸들러나 델리게이트 등도 잘못 사용하면 이들이 참조하고 있는 객체들이 불필요하게 오랫동안 메모리에 남게 된다.

결과를 반환하는 쿼리등도 자칫 잘못 사용하면 예상보다 더 오랫동안 메모리를 점유하곤 한다.

하지만 C#의 경우 가비지 콜렉터가 메모리를 전반적으로 관리해주기 때문에 메모리 관리를 개발자가 온전히 책임져야 하는 환경에 비해 상대적으로 응용프로그램의 구조를 단순하게 유지할 수 있다.

메모리를 직접 관리해야 하는 경우는 상당히 복잡할 수밖에 없는 순환 참조의 문제나 여러 객체들 간의 복잡한 연관 관계의 문제도 관리 환경에서는 비교적 쉽게 구현할 수 있다.

마크/콤팩터 알고리즘은 여러 객체 사이의 연관 관계를 효율적으로 파악하여 더 이상 사용되지 않는 객체를 자동으로 제거한다.

가바지 콜랙터는 COM의 경우처럼 개별 객체가 스스로 자신의 참조 여부나 횟수 등을 관리하도록 하지 않고, 응용프로그램의 최상위 객체로부터 개별 객체까지 도달 가능여부를 확인하도록 설계되었다.

EntitySet클래스는 이러한 알고리즘을 통해서 객체의 소유권을 확인하는 과정이 얼마나 쉽게 수행될 수 있는지를 보여주는 좋은 예다.

통상 각각의 Entity는 데이터베이스에서 로드한 객체들의 집합으로 구성되며 다른 Entity를 참조하는 링크를 포함할 수 있다.

관계형 데이터베이스의 엔티티 집합 모델과 마찬가지로 이러한 링크는 간혹 순환 참조가 되기도 한다.

설사 EntitySet이 내부적으로 여러 객체 간의 복잡한 참조 관계를 가진다고 하더라도 가비지 수집기가 메모리 해제를 책임지기 때문에 .NET Framework를 이용하는 개발자들은 EntitySet 내부에 포함된 객체들을 개별적으로 해제할 필요도 없고 객체 간의 참조 관계로 인해 발생하는 복잡한 문제를 고민할 필요도 없다.

가바지 컬렉터의 이런 알고리즘 덕분에 어떤 객체가 가비지인지를 확인하는 과정도 쉽게 이뤄진다.

가비지 콜렉터는 응용프로그램 내의 최상위 객체로부터 참조 트리를 구성하여 도달 가능한 객체를 살아 있는 객체로 판단하고 도달 불가능한 객체를 가비지로 간주한다.

가비지 콜렉터 실행 과정

가비지 콜렉터가 수행되면 관리 힙에 대하여 콤팩트(compact)작업을 수행한다.

콤팩트 작업이란 사용 중인(도달 가능한)객체들을 한쪽으로 차곡차곡 옮겨서 조각난 가용 메모리를 단일 메모리의 큰 메모리 공간으로 만드는 과정을 말한다.
즉, 가비지 콜렉터는 사용되지 않는 객체를 제거할 뿐 아니라 사용중인 객체를 옮겨서 조각난 가용 메모리를 단일의 큰 메모리 공간으로 만든다.

image

이처럼 관리 힙에 대한 메모리 관리는 가비지 콜렉터가 완전히 책임을 진다.

하지만 그 외의 비관리 리소스는 여전히 개발자가 관리해야 한다.

.NET Framework는 비관리 리소스의 생명주기에 대해서도 개발자가 더 손쉽게 관리할 수 있도록 finalizse와 IDisposable 인터페이스라는 두 가지 메커니즘을 제공한다.

finalizer

finalizer는 비관리 리소스에 대한 해체 작업이 반드시 수행될 수 있도록 도와주는 방어적인 메커니즘이다.

불행히도 finalizer는 단점이 많기 때문에 이보다는 IDisposable 인터페이스를 통해서 적시에 비관리 리소스가 빠르게 해제될 수 있도록 구현하는 것이 좋다.

finalizer를 가지고 있는 객체는 가비지로 간주된 이후에도 꽤 긴 시간 메모리를 점유하게 되며, 사용자가 구현한 finalizer는 상당한 시간이 경과한 다음에야 비로소 가바지 수집기에 의해서 호출된다.

불편한 진실은 정확히 어느 시점에 finalizer가 호출될지를 아무도 알 수 없다는 것이다.

단지 객체가 가비지가 되면 언젠가는 호출되리라는 것만 알 수 있을 뿐이다.

C++에 익숙한 개발자라면 이를 매우 심각한 문제로 생각할 수 있다.

게대가 이로 인해 응용프로그램의 구조도 변경하지 않을 수 없게 된다.

// C++에서는 좋지만, C#에서는 좋지 않은 코드
class CriticalSection
{
    // 생성자 내에서 시스템 리소스를 할당한다.
    public CriticalSection()
    {
        EnterCriticalSection();
    }

    // 소멸자 내에서 시스템 리소스를 해제한다.
    ~CriticalSection()
    {
        ExitCriticalSection();   
    }

    private void ExitCriticalSection()
    {
        // 시스템 리소스를 해제한다.
    }

    private void EnterCriticalSection()
    {
        // 시스템 리소스를 사용한다.
    }
}

// 사용 예
void Func()
{
    // s 객체를 통해 시스템 리소스에 대한
    // 생명주기가 관리된다.
    CriticalSection s = new CriticalSection();

    // 실제 작업을 수행한다.
    // ...

    // 컴파일러는 소멸자를 호출하는 코드를 생성하며
    // 이를 통해 Critical Section을 벗어난다.
}

C++에서 대중적으로 사용디는 리소스 해제 구문은 예외가 발생하는 경우에도 올바르게 작동한다.

그러나 C#에서는 제대로 작동하지 않으며 설사 작동한다고 해도 동일한 방식으로 동작하지 않는다.

.NET 환경이나 C#에서는 원하는 시점에 정확히 객체를 해제하는 기능을 제공하지 않는다..

이런 이유로 C++에서 보편적인 코딩 스타일이 C#에서는 잘 동작하지 않는다.

C#에서도 finalizer가 언젠가 호출된다는 것을 보장하곤 있지만 그 시점을 알수없기 때문에 앞과 같이 코드를 짜게 되면 함수를 빠져나오는 시점에 Critical Section 객체가 해제되지 않으며, 그냥 언젠가 해제될 것이라는 사실만 알 수 있다.

문제는 finalizer가 할당된 비관리 리소스를 최종적으로 해제할 수 있는 유일한 방법이라는 것이다.

하지만 위에서 언급한 문제들 때문에 실제로 타입을 설계하고 코딩할 때는 finalizer를 가능한 사용하지 않아야 하며 finalizer가 필요한 구조를 회피해야 한다.

이번 장을 통해 finalizer를 회피하는 방법을 알아본다..!

  • finalizer를 사용하면 성능이 나빠진다.
  • finalizer를 포함하고 있는 객체를 사용하면 가비지 수집 과정이 더 길어진다.
  • 가비지 콜렉터가 finalizer를 포함하는 객체를 가비지로 판단한 경우 그 즉시 객체가 점유하고 있는 메모리 공간을 해제하지 못한다.(finalizer를 호출해야 하기 때문에)
    • 문제는 가비지 수집을 수행하는 스레드를 통해 직접 finalizer를 호출할 수 없기 때문에 가비지 수집기는 이 객체에 대한 참조를 다른 큐에 삽입하여 나중에 finalizer가 호출될 수 있도록 사전 준비만 수행한다.
    • 이 과정에서 finalizer가 없는 객체는 메모리로부터 즉각 제거된다.
    • 일정 시간이 경과한 이후에 다시 한 번 기비지 수집 절차가 수행되면 앞 서 다른 큐에 삽입해 두었던 객체의 참조를 꺼내어 해당 객체가 가지고 있던 finalizer를 순차적으로 호출한 후 점유하고 있던 메모리를 반납한다.

image

그림과 같이 B(finalizer)가 포함된 객체는 가비지 수집기에 의해서 가비지로 판단되면 즉시 메모리에서 제거되지 않고 다른 큐에 삽입된 후 일정 시간이 경과한 이후에 다시 한 번 가비지 수집 절차가 수행되면서 finalizer가 호출된다.

즉, 메모리를 더 오래 점유하고 수행을 위해(비동기) 추가적인 스레드가 필요하다..

실제 가비지 수집 과정이 이 보다 훨씬 복잡하다.

세데 개념

.NET의 가비지 콜렉터는 가비지 수집 과정을 최적화 하기 위해서 세대라는 개념을 사용한다.

가비지 수집기에 세대라는 개념을 도입하면서 가비지가 될 가능성이 높은 객체를 더 빠르게 찾아낼 수 있게 됐다.

예를 들어 마지막으로 가비지 수집기가 수행된 이후 임의의 객체가 생성됐다고 한다면 이 객체를 0세대 객체라 한다.

이제 또 한번의 가비지 수집 절차가 수행됐다.

만약 기존에 생성한 객체가 여전히 쓰이고 있다면 이번 가비지 수집 절차의 정리 대상이 되지 않을 것이다.

이렇게 살아남은 객체를 1세대 객체라 한다.

만약 두 번 혹은 그 이상의 가비지 수집 절차가 수행된 이후에도 살아남은 객체는 2세대 객체라 한다.

이처럼 세대를 구분하는 이유는 응용프로그램 내에서 비교적 짧은 시간만 사용되는 객체를 다른 객체와 구분하기 위함이다.

0세대 객체는 가장 최근에 생성된 객체임을 유추할 수 있고, 멤버 변수나 전역 변수는 금세 1세대 객체나 2세개 객체가 될 것이다.

가비지 수집은 1세대와 2세대 객체에 대해서는 제한적으로 가비지 수집을 수행하며 가비지 수집 절차가 시작되면 우선 0세대 객체에 대해서만 가비지 수집을 수행한다.

대략 10번에 한 번 꼴로 추가적으로 1세대 객체에 대해서 가비지 수집을 수행한다.

그리고 100번에 한 번 꼴로 2세대 객체를 포함한 모든 세대의 객체를 대상으로 가비지 수집을 수행한다.

이쯤에서 finalizer의 비용에 대해서 다시 생각해본다면, finalizer를 가진 객체는 즉각 제거되지 못하므로 1세대 객체가 되고, 이 경우 9번의 가비지 수집 절차에서 추가적으로 수행된 이후에나 비로소 메모리에서 제거될 가능성이 있다.

만약 이 과정에서도 정리되지 못한다면 2세대 객체가 되므로 100번의 과정이 더 필요하다.

이렇게 finalizer가 좋은 해결책이 될 수 없음을 설명했다.

그렇다면 리소스를 해제하는 가장 좋은 방법은 무엇일까?

앞서 말했지만 IDisposable 인터페이스와 표준 Dispose 패턴을 사용하는 것이다. (나중에 item 17에 등장)

댓글남기기