본문 바로가기
프로그래밍/Effective C#

Effective C# Item 17 : 표준 Dispose 패턴을 구현하라

by bantomak 2023. 11. 3.

표준 Dispose 패턴을 구현하라

비관리 리소스(Unmanaged Resource)를 포함하는 타입을 작성할 때 리소스 관리를 어떻게 해야 할지를 살펴보자.

.NET Framework 내부에서는 비관리 리소스를 정의하는 표준화된 패턴을 사용하고 있으므로 새로운 타입을 만들 때도 동일한 패턴을 이용하는 것이 좋다. 바로 Dispose 패턴을 사용하면 된다.

 

이 패턴을 이용하면 개발자들에게 IDisposable 인터페이스를 통해서 리소스를 삭제할 수 있는 기능을 안정적으로 제공할 수 있다. 게다가 비관리 리소스를 명시적으로 정리해야 한다는 사실을 잊어버리거나 인지하지 못한 경우에도 finalizer를 통해 올바르게 리소스가 정리될 수 있도록 해준다. 표준 Dispose 패턴은 가비지 수집기와 연계되어 동작하며 불가피한 경우에만 finalizer를 호출하도록 하여 성능에 미치는 부정적인 영향을 최소화한다. 이 패턴은 비관리 리소스를 다루기 위한 가장 효과적인 방법으로 알려져 있으므로, .NET 개발자라면 철저하게 그 내용을 이해해야만 한다. 실제로 .NET에서는 비관리 리소소를 System.Runtime.Interop.SafeHandle을 상속한 파생 클래스를 통해 표현하는데 이 클래스 또한 Dispose 패턴을 완벽하게 구현하고 있다.

 

상속 계통상 최상위 베이스 클래스는 다음과 같은 작업을 수행해야 한다.

  • 리소스를 정리하기 위해서 IDisposable 인터페이스를 구현한다.
  • 멤버 필드로 비관리 리소스를 포함하는 경우에만 finalizer를 추가한다.
  • Dispose와 finalizer (존재하는 경우) 실제 리소스 정리 작업을 수행하는 다른 가상 메서드에 작업을 위임하도록 작성돼야 한다. 파생 클래스가 고유의 리소스 정리 작업이 필요한 경우 이 가상 메서드를 재정의할 수 있도록 하기 위함이다.

파생 클래스는 다음 작업을 수행해야 한다.

  • 파생 클래스가 고유의 리소스 정리 작업을 수행해야 된다면 베이스 클래스에서 정의된 가상 메서드를 재정의한다.
  • 멤버 필드로 비관리 리소스를 포함하는 경우에만 finalizer를 추가해야 한다.
  • 베이스 클래스에 정의하고 있는 가상 함수를 반드시 재호출해야 한다.

가장 먼저 알아둬야 하는 것은 비관리 리소스를 포함하는 클래스는 반드시 finalizer를 구현해야 한다는 것이다. 사용자가 Dispose() 메서드를 항상 올바르게 호출할 것이라고 가정할 수 없는 노릇이다. finalizer도 없고 Dispose()를 호출하는 것조차 잊어버리면 리소스가 누수된다. 어떤 경우에도 비관리 리소스가 누수되지 않고 올바르게 정리될 것임을 보장하는 유일한 방법은 finalizer를 구현하는 것이다. 다시 강조하지만 비관리 리소스를 포함하고 있다면 무조건 finalizer를 구현하라.

 

가비지 수집 작업이 수행되면 finalizer가 없는 가비지 객체는 즉각 메모리에서 제거되지만 finalizer를 가진 객체는 여전히 메모리에 남게 된다. 가비지 수집기는 finalizer 큐라는 곳에 이 객들의 참조를 삽입해 두고, finalizer 스레드라는 특별한 스레드를 이용하여 이 큐에 포함된 객체들의 finalizer를 순차적으로 호출한다. finalizer를 호출한 객체들에 대해서는 더 이상 finalizer를 호출할 필요가 없음을 나타내는 플래그를 설정하고 이제 메모리로부터 제거될 수 있는 대상으로 간주한다. 불행한 것은 앞서 finalizer 큐에 삽입된 객체들은 이전에 수행된 가비지 수집 작업을 통해서 정리되지 못한 객체이므로 자연스럽게 한 세대가 높아진다는 것이다. 이 때문에 다른 객체에 비해서 상대적으로 메모리에 오래 살아남게 된다. 가비지 수집 작업이 수차례 반복되어 해당 세대에 대한 가비지 수집이 수행되면 비로소 이 객체들은 제거될 기회를 얻게 된다. 앞서 말한 바와 같이 비관리 리소스를 포함하는 타입은 반드시 finalizer를 구현해야 하며 다른 대안은 존재하지 않는다. 성능 문제는 아직 걱정하지 말자.

 

IDisposable을 구현한다는 것은 사용자와 .NET 런타임에게 적시에 리소스를 정리할 수 있는 방법이 있다는 것을 알려주기 위한 표준화된 방법이기도 하다. IDisposable 인터페이스는 단 하나의 메서드만을 가진다.

 

public interface IDisposable
{
   void Dispose();
}

 

IDisposable.Dispose() 메서드는 다음 네 가지 작업을 반드시 수행해야 한다.

  • 모든 비관리 리소스를 정리한다.
  • 모든 관리 리소스를 정리한다.
  • 객체가 이미 정리되었음을 나타내기 위한 상태 플래그 설정. 앞서 이미 정리된 객체에 대하여 추가로 정리 작업이 요청될 경우 이 플래그를 확인하여 ObjectDisposed 예외를 발생시킨다.
  • finalizer 호출 회피, 이를 위해 GC.SuppressFinalizer(this)를 호출한다.

IDisposable은 비관리 리소스를 정리하는 표준화된 방법이며, 이를 온전히 구현했다면 최종 사용자에게 적시에 리소스를 해제할 수 있는 메커니즘을 안전하게 제공한다고 할 수 있다. 최종 사용자는 IDisposable을 이용함으로써 finalizer 과정으로 인해 발생하는 불필요한 비용을 피할 수 있다. 이 정도면 외부에 내놓아도 부끄럽지 않은 타입이라 할 만하다.

 

하지만 여전히 부족한 부분이 있다. 첫 번째로 개선해야 할 부분은 이 클래스를 상송하여 파생 클래스를 정의하는 경우다. 파생 클래스가 자신이 포함하고 있는 리소스를 정리하는 것은 그렇다 치더라도 베이스 클래스가 포함하고 있는 리소스는 어떻게 정리해야 할까? 이러한 문제점을 해결하려면 파생 클래스가 finalizer나 자신만의 IDisposable을 구현할 때 반드시 베이스 클래스에서 구현한 함수를 호출하도록 코드를 작성해야 한다. 이렇게 해야만 베이스 클래스도 올바르게 리소스를 정리할 수 있기 때문이다. 또 다른 개선 사항으로는 finalizer와 Dispose() 메서드는 일반적으로 동일한 역할을 수행하므로 중복된 코드가 여러 번 나타날 수 있다는 점이다. 

 

이러한 문제를 해결하기 위해서 인터페이스 함수를 재정의하는 방법을 생각할지도 모르겠다. 하지만 인터페이스 함수는 기본적으로 가상 함수이므로 이를 재정의하면 예상처럼 동작하지 않는다. 따라서 이 문제를 해결하려면 번거롭더라도 추가적인 작업을 할 수밖에 없다.

 

표준 Dispose 패턴에서 정의하고 있는 세 번째 메서드는 protected로 선언된 가상 헬퍼 함수(virtual helper function)이다.

protected virtual void Dispose(bool isDisposing);

 

이 가상 함수를 구현해 두면 finalizer와 Dispose 양쪽에서 사용할 수 있다. 게다가 가상 함수이므로 파생 클래스에서 이 메서드를 재정의하여 자신이 소유한 리소스를 정리하는 코드를 작성할 수 있다. 코드의 마지막 부분에서는 반드시 베이스 클래스에서 정의하고 있는 Dispose(bool) 함수를 호출해야 한다. Dispose(bool) 함수를 호출할 때는 매우 중요한 약속이 있다. 관리 리소스와 비관리 리소스 모두를 제거하려면 isDisposing으로 true를 전달하고 비관리 리로스만 정리하려면 false를 전달해야 한다는 것이다. 당연한 이야기지만 어떤 값을 주더라도 베이스 클래스의 Dispose(bool) 메서드로 진입할 것이므로 베이스 클래스 또한 리소스를 정리할 기회를 갖는다.

 

이 패턴을 구현할 때 참고할 수 있도록 .NET Framework의 코드 일부를 가져와서 짧게 수정해 봤다. MyResourceHog 클래스는 IDisposable 인터페이스의 구현 방법과 가상 Dispose(bool) 메서드를 구현하는 방법을 보여준다.

 

public class MyResourceHog : IDisposable
{
   // 이미 dispose 되었을지를 나타내는 플래그
   private bool alreadyDisposed = false;
   
   // IDisposable을 구현
   // 가상 Dispose 매서드를 호출하고
   // finalize를 회피하도록 한다.
   public void Dispose()
   {
      Dispose(true);
      GC.SuppressFinalize(this);
   }
   
   // 가상 Dispose 메서드
   protected virtual void Dispose(bool isDisposing)
   {
      // Dispose 는 한번만 수행되도록 한다.
      if (alreadyDisposed) return;
      
      if (isDisposing)
      {
         // 여기서 관리 리소스를 정리한다.
      }
      
      // 여기서 비관리 리소스를 정리한다.
      
      // disposed 플래그 설정
      alreadyDisposed = true;
   }
   
   public void ExampleMethod()
   {
      if (alreadyDisposed)
      {
         throw new ObjectDisposedException("MyResourceHog",
         "Called Example Method on Disposed Object");
      }
   }
}

 

파생 클래스의 구현 방법에 대해서도 알아보자. 파생 클래스가 추가적인 정리 작업을 수행해야 하는 경우 다음과 같이 Dispose(bool) 메서드를 재정의해야 한다.

 

public class DerivedResourceHog : MyResourceHog
{
   // 자신만의 Disposed 플래그
   private bool disposed = false;
   
   protected override void Disposed(bool isDisposing)
   {
      // Dispose는 한번만 수행되도록 한다.
      if (disposed) return;
      
      if (isDisposing)
      {
         // 여기서 관리 리소르를 정리한다.
      }
      
      // 여기서 비관리 리소스를 정리한다.
      
      // 베이스 클래스가 자신의 리소스를 정리할 수 있도록 해주어야 한다.
      // 베이스 클래스는 GC.SuppressFinalize()를 호출해야 한다.
      base.Dispose(isDisposing);
      
      // 파생 클래스의 리소스가 정리되었음을 표시
      disposed = true;
   }
}

 

베이스 클래스와 파생 클래스가 각자 자신의 dispose 여부를 나타내기 위해 고유의 플래그를 가졌음을 유심히 살펴보기 바란다. 사실 이는 순전히 방어적으로 코드를 작성하기 위함인데, 플래그를 이중으로 배치하여 베이스 클래스 혹은 파생 클래스의 일부만이 정리된 경우에 혹시 발생할지도 모를 문제를 피하기 위해서다.

 

Dispose와 finalizer는 방어적으로 작성되어야 한다. Dispose 메서드는 한 번 이상 호출될 수 있으므로 여러 번 호출되더라도 반드시 동일하게 동작하도록(멱등성, idempotent) 구현해야 한다. 타입 내에 여러 개의 필드가 있는 경우 각각의 필드가 어떤 순서로 정리될지는 엄밀하게 정의돼 있지 않다. 이런 이유로 Dispose() 메서드가 호출됐을 때 해당 객체의 필드 중 하나가 이미 정리된 상황이라면 예상하기 어려운 문제가 발생하지 않도록 만들었다면 이런 걱정은 하지 않아도 된다. 이미 정리된 객체에 대하여 멤버 메서드를 호출한 경우 ObjectDisposedException 예외를 발생시키는 것이 표준 Dispose 패턴의 규칙이기도 하다. 이미 정리된 객체를 가지고는 작업을 제대로 수행할 수 없을 것이기 때문이다. 이미 dispose 된 객체 혹은 초기화조차 제대로 완료되지 않은 객체에 대해서도 finalizer가 호출될 수 있다. 상태가 온전하지 않을 수는 있겠으나 객체가 메모리에서 완전히 제거된 것은 아니니 때문에 null 참조를 걱정하지는 않아도 된다. 하지만 이미 정리된 객체의 필드에는 접근하지 않아야 한다.

 

앞 코드의 MyResourceHog와 DerivedResourceHog가 finalizer를 가지고 있지 않음에도 주목하기 바란다. 이 예제는 비관리 리소스를 포함하고 있지 않으므로 finalizer를 구현하지 않았으며, Dispose(false)가 호출되는 경우도 없다. 이 또한 Dispose 패턴의 구현 규칙 중 하나인데 클래스가 비관리 리소스를 직접 포함하지 않은 경우라면 finalizer를 구현하지 말라는 것이다. 반드시 비관리 리소스를 포함하는 경우에만 finalizer를 구현하기 바란다. 호출 여부와 상관없이 finalizer를 그냥 두면 안 되냐고 물을 수 있겠으나 finalizer가 존재하는 것만으로도 상당한 성능상의 손해를 감수해야 한다. finalizer가 필요 없는 경우라면 절대로 추가하지 말라. 또 다른 주의 사항은 베이스 클래스가 비관리 리소스를 포함하지 않더라도 파생 클래스가 비관리 리소스를 포함할 수 있으므로 이 패턴의 구현부는 그대로 유지하는 편이 낫다는 것이다. 이렇게 해야만 파생 클래스에서 비관리 리소스를 정리해야 할 경우 finalizer와 Dispose(bool)을 손쉽게 구현할 수 있다.

 

제거 혹은 정리 작업이 있어서 핵심적이고도 중요한 지침은 Dispose 메서드 내에서는 리소스 정리 작업만을 수행해야 한다. 다른 작업은 절대로 수행해서는 안된다.

Dispose나 finalizer에서 다른 작업을 수행하게 되면 객체의 생명주기와 관련된 심각한 문제를 일으킬 수 있다. 객체는 생성을 시도할 때 생성돼야 하고, 가비지 수집기가 정리하려 할 때 삭제돼야 한다. 이것이 원활하지 않은 객체는 혼수상태에 빠진 객체처럼 살아도 살아 있는 게 아니다. 이 객체에 접근할 수도 없고 이 객체가 가진 메서드를 호출할 수도 없다.

 

finalizer를 가진 객체는 최종적으로 정리가 완료되기 이전에 다시 한번 코드를 수행할 기회를 얻게 된다. 하지만 이 순간에도 비관리 리소스를 삭제하는 작업 외에는 다른 어떤 작업도 해서는 안된다. finalizer 내에서 객체를 다시 도달 가능 상태로 만들어버리면 사라져야 할 객체가 죽지 않고 다시 살아남게 된다. 객체가 살아남긴 했지만 혼수상태나 마찬가지다. 

 

다음에 관련 예를 살펴보자.

public class BadClass
{
   // 전역 객체에 대한 참조를 저장한다.
   private static readonly List<BadClass> finalizedList = new List<BadClass>();
   
   private string msg;
   
   public BadClass(string msg)
   {
      // 참조를 캐싱한다.
      msg = (string)msg.clone();
   }
   
   ~BadClass()
   {
      // 이 객체를 다시 리스트에 추가한다.
      // 객체는 다시 도달 가능 상태가 되었으며
      // 더 이상 가비지가 아니다. 살아났다!
      finalizedList.Add(this);
   }
}

 

BadClass 객체는 finalize를 실행할 때 전역 목록에 자신을 추가한다. 이는 결국 자신을 다시 도달 가능한 객체로 변경한 꼴이다. 그러니 다시 살아난 것이다. 하지만 이렇게 하면 여러 가지 문제가 발생한다. 

 

첫째로 가비지 수집기는 이 객체에 대해서 이미 finalizer를 호출했으므로 더 이상 finalizer를 호출할 필요가 없다고 간주한다. 때문에 되살아난 객체를 정말로 삭제하려 해도 다시 finalizer를 호출할 방법이 없다. 

 

둘째로 객체가 살아난 것처럼 보이겠지만 이 객체에 포함된 여타의 필드들은 사용할 수가 없다. 가비지 수집기는 finalizer 큐에 삽입된 객체에서 도달 가능한 다른 객체들 또한 삭제하지 않는다. 하지만 이미 finalize 과정이 완료된 이후라면 이야기가 다르다. 이 경우에는 도달 가능 객체라 하더라도 명백히 가비지로 간주된다. 따라서 BadClass가 가진 필드들은 설사 지금 당장은 메모리를 점유하고 있더라도 언젠가 정리될 것이다. 언어 차원에서 finalize 작업이 수행되는 순서를 제어할 수 있는 방법은 없다. 따라서 이러한 작업은 절대 해서는 안된다.

 

앞서 설명한 표준 Dispose 패턴을 준수하라. 그래야 모두 발 뻗고 편히 잘 수 있다.

 

출처

 

이펙티브 C# - 예스24

더 나은 C# 코드를 작성하는 새로운 방법 50가지 C#은 전통적인 .NET 기반 개발에서 유니티 게임 엔진으로 개발 영역을 확대하면서 더욱 주목받고 있다. 또한 자마린으로 다양한 모바일 플랫폼에

댓글