IDisposable 패턴의 올바른 구현 방법

.NET Framework에서 새로운 클래스를 만들 때 여러가지 메모리 관리 디자인 패턴과 기법을 적용할 수 있지만, 한시적으로 사용해야 할 필요가 있는 자원들을 묶어서 관리할 때에는 IDisposable 패턴을 적극적으로 활용하는 것이 매우 유용합니다.

하지만 생각보다 IDisposable 패턴을 제대로 구현해서 사용하는 것은 쉽지 않으며 잘못 구현하기 쉽습니다.

이 아티클에서는 IDisposable 패턴을 구현하는 몇 가지 일반적인 전략들을 소개합니다. 잘못 설명된 부분이 있거나 보충이 필요한 부분은 댓글로 피드백을 자세히 남겨주시면 적극 반영하겠습니다.

IDisposable 인터페이스에 대한 이해

IDisposable 인터페이스가 제공하는 Dispose 메서드는 명시적이고 코드 작성자가 직접 호출할 수 있는 finalizer로, 이 메서드가 불리면 가비지 컬렉터에 의하여 나중에 호출되는 finalizer의 역할을 대체하도록 되어있습니다. 물론, 메모리 상에 할당된 메모리 블록의 해제까지 건너뛴다는 의미는 아닙니다.

그리고 무엇을 Dispose 메서드에서 제거해야 하는지 기준을 세운다면 객체에 대한 소유 권한을 정의하는 것이 필요합니다. 적어도 다음의 경우에는 확실히 Dispose 메서드 내에서 정리가 되어야 합니다.

  • 해당 객체를 Dispose 하게 되면 외부에서 더 이상 사용하는 것이 의미가 없는 객체 (예를 들어 클래스 내부에서 사용하던 파일 입출력 관련 객체, 비동기 작업을 위하여 만들어 놓은 스레드 관련 객체)
  • 외부에서 전달받은 객체이지만 객체의 생명 주기를 위탁하여 관리하도록 지정한 객체 (예를 들어 StreamReader나 StreamWriter가 객체 생성 시 인자로 Stream을 받는 사례)

가장 기본이 되는 IDisposable 구현 패턴

.NET 은 finalizer에 해당되는 멤버를 재정의할 수 있습니다. 하지만 finalizer가 언제 호출이 될 것인지 기약할 수 없으므로 이 finalizer를 대신하여 좀 더 이른 시기에 명시적으로 소멸자와 동등한 효과를 낼 수 있도록 만든 것이 바로 IDisposable.Dispose 메서드가 되겠습니다.

IDisposable 패턴을 처음 구현할 때에는 다음의 사항들이 핵심이 됩니다.

  • protected virtual void Dispose(bool disposing) 메서드를 추가합니다. sealed 클래스에 대해서는 private void Dispose(bool disposing)으로 바꾸어 정의합니다.
  • 객체가 dispose 처리가 이루어진 상태인지를 관리할 수 있는 boolean 필드를 하나 추가하고 이 필드는 기본값을 false로 설정합니다.
  • Dispose(bool disposing) 호출 시 다음의 로직을 구현합니다.
    • 만약 객체가 dispose 상태인 경우에는 함수를 종료합니다.
    • 객체가 dispose 상태임을 필드에 지정합니다. (true로 설정)
    • disposing 매개 변수의 상태와 관계없이 P/Invoke 등을 활용하여 메모리 할당을 받은 나머지 모든 리소스들에 대해 할당을 해제하는 코드를 추가합니다.
    • disposing 매개 변수가 true로 전달되는 경우는 명시적으로 Dispose 메서드를 호출한 경우이며, 이 때에 다른 모든 IDisposable을 구현하는 객체들을 Dispose 처리합니다.
  • IDisposable.Dispose 메서드 구현 시 Dispose(true)를 호출하고 finalizer 호출을 건너뛰기 위하여 GC.SuppressFinalize(this)를 호출합니다.
  • 소멸자에서는 Dispose(false)를 호출합니다.

다음은 코드 예시입니다.

public class DisposableSample : IDisposable
{
    public DisposableSample()
    { }

    ~DisposableSample()
    {
        this.Dispose(false);
    }
    
    private bool disposed;

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (this.disposed) return;
        if (disposing)
        {
            // IDisposable 인터페이스를 구현하는 멤버들을 여기서 정리합니다.
        }
        // .NET Framework에 의하여 관리되지 않는 외부 리소스들을 여기서 정리합니다.
        this.disposed = true;
    }
}

IDisposable 객체의 컬렉션에 대한 소거

소켓, 스레드 풀, 혹은 커넥션 풀 같은 컬렉션을 객체 내부에 보관해야 하는 경우도 있습니다. 이러한 경우에도 컬렉션 내의 모든 IDisposable 객체에 대해서 정리를 하는 것이 필요합니다.

  • 컬 렉션 내의 모든 요소가 IDisposable 인터페이스를 구현하고 있다면 Dispose(bool disposing) 메서드에서 disposing이 true일 때 정리를 하면 됩니다. 그렇지 않은 경우, disposing 매개 변수의 상태에 무관하게 정리합니다.
  • Dispose 작업 도중 새로운 항목이 추가되는 것을 방지하기 위하여 disposed 필드를 확인하도록 하는 코드를 추가해야 할 수 있습니다.
  • 컬렉션 내의 모든 요소를 배열에 복사하여 Immutable Collection으로 변환한 다음 하나씩 방문하여 Dispose를 진행하고, 최종적으로 컬렉션의 요소들을 모두 제거합니다.

다음은 코드 예시입니다.

public class DisposableSample : IDisposable
{
    public DisposableSample()
    {
        this.items = new List<IDisposable>();
    }

    ~DisposableSample()
    {
        this.Dispose(false);
    }
    
    private bool disposed;
    private List<IDipsosable> items;

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (this.disposed) return;
        if (disposing)
        {
            // IDisposable 인터페이스를 구현하는 멤버들을 여기서 정리합니다.
            IDisposable[] targetList = new IDisposable[this.items.Count];
            this.items.CopyTo(targetList);
            foreach (IDisposable eachItem in targetList)
            {
                eachItem.Dispose();
            }
            this.items.Clear();
        }
        // .NET Framework에 의하여 관리되지 않는 외부 리소스들을 여기서 정리합니다.
        this.disposed = true;
    }
}

Dispose 메서드 내의 예외 처리

만약 Dispose 메서드를 실행하는 도중에 예외가 발생한다면, CA1065의 지침에 따라 명시적인 Dispose 호출이었든 아니었든 예외를 전파하지 않도록 처리하는 것이 필요합니다. 다만 명시적으로 Dispose를 호출하면서 예외가 발생했다면 예외를 전파하지 않는 대신 적절한 예외 처리는 필요합니다.

이 부분에 대한 자세한 내용은 https://msdn.microsoft.com/ko-kr/library/bb386039.aspx 페이지의 내용을 참고하시면 도움이 될 것입니다.

컬렉션 내의 모든 요소들을 Dispose 하는 코드를 조금 더 보강하면 다음과 같이 고쳐쓸 수 있겠습니다.

public class DisposableSample : IDisposable
{
    public DisposableSample()
    {
        this.items = new List<IDisposable>();
    }

    ~DisposableSample()
    {
        this.Dispose(false);
    }

    private bool disposed;
    private List<IDisposable> items;

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (this.disposed) return;
        try
        {
            if (disposing)
            {
                // IDisposable 인터페이스를 구현하는 멤버들을 여기서 정리합니다.
                IDisposable[] targetList = new IDisposable[this.items.Count];
                this.items.CopyTo(targetList);
                foreach (IDisposable eachItem in targetList)
                {
                    try { eachItem.Dispose(); }
                    catch (Exception ex) { /* 예외 처리를 수행합니다. */ }
                    finally { /* 정리 작업을 수행합니다. */ }
                }
                this.items.Clear();
            }
            try { /* .NET Framework에 의하여 관리되지 않는 외부 리소스들을 여기서 정리합니다. */ }
            catch { /* 예외 처리를 수행합니다. */ }
            finally
            {
                /* 정리 작업을 수행합니다.  */
                this.disposed = true;
            }
        }
        finally { /* 정리 작업을 수행합니다. */ }
    }
}

소유하고 있는 객체들에 대한 Dispose 또는 정리 작업들을 각각 try, catch, finally 블록안에 두어 예외가 발생하면 적절한 예외 처리를 할 수 있게 하고, Dispose 메서드 전체에 대해서 try, finally 블록 안에 두어 예외가 전파되지 않도록 하였습니다.

결론

.NET Framework 기반의 응용프로그램이 안정적으로 장시간 실행될 수 있게 만들어야 할 때 고려해야 할 요소들 가운데에서 가장 비중있게 다루어야 할 부분이 바로 메모리 관리입니다. IDisposable 인터페이스를 통한 명시적인 finalizer 호출은 적절하게 활용하면 응용프로그램의 메모리 관리를 단순하게 만드는데 큰 도움을 줍니다.

IDisposable 패턴의 올바른 구현 방법”에 대한 2개의 생각

  1. 안녕하세요.
    Dispose 패턴의 활용법까지 깔끔하게 정리되어 있어 많은 도움이 되었습니다. 좋은 글 감사드립니다.

    한 가지 궁금한 것은
    “IDisposable 인터페이스에 대한 이해” 부분에서 IDisposable.Dispose() 메서드가 뭔가 특별한 처리가 되는 것처럼 설명하신 같아 오해의 소지가 있어 보이는 것인데요.

    제가 알기로는 IDisposable 인터페이스가 컴파일러 수준에서 using 키워드를 사용할 수 있다는 것 외에는
    일반적인 닷넷의 인터페이스와 동일한 것으로 알고 있습니다.

    그런데 “IDisposable 인터페이스에 대한 이해” 부분에서는 finalizer를 대체할 수 있다는 내용이 있어서
    뒷 부분에 “물론, 메모리 상에 할당된 메모리 블록의 해제까지 건너뛴다는 의미는 아닙니다.” 의 내용 전달 마저 모호해 지는 것 같습니다.

    이것이 곧 IDisposable.Dispose() 메서드를
    finalizer나 GC가 동작하면서 자동으로 호출해 주거나 무언가 별도의 동작을 하는 것으로 오해할 수도 있겠다 싶은 느낌이 들었습니다.
    finalizer에서도 직접 호출해야하는 것은 물론이고 GC가 수행되어도 IDisposable 인터페이스의 구현여부는 GC 동작에 아무 영향을 미치지 않는 것으로 알고 있습니다.

    오히려 GC 동작에 영향을 주는 것은 명시적으로 구현한 finalizer 이며
    finalizer 의 구현이 GC의 finalizer 큐에 할당 되고 이것에 의해 메모리 해제까지 더 많은 오버헤드를 가지게 되는 것이며
    때문에 IDisposable.Dispose() 구현부에서 GC.SuppressFinalize()를 호출해 GC의 finalizer 큐 할당을 막는 것이
    Dispose 패턴에서 중요한 부분이 아닌가 하는 생각이 들었습니다.

    IDisposable.Dispose() 는 그냥 인터페이스에 속한 메서드이며 실제 사용자가 메서드 구현을 하지 않으면 아무런 동작도 하지 않는 것은 물론이고
    GC나 finalizer 와 특별한 관계가 있는 것도 아니지만 이 인터페이스를 이용해 Dispose 패턴을 구현함으로써 객체 관리에 도움이 된다.
    정도의 내용이 더 적합하지 않나 하는 의문이 들었습니다.

    Dispose 패턴의 대한 설명은 정말 이보다 더 자세하고 이해하기 쉬울 수 없겠다 싶을 정도로
    훌륭하게 정리되어 있어 많은 도움이 되었습니다.

    혹시 제가 잘못 알고 있는 부분이 있다면 지적 부탁드리겠습니다.

    • 좋은 피드백을 남겨주셔서 고맙습니다. 말씀하신 내용을 검토하여 정확한 내용이 전달될 수 있도록 하는 것이 좋겠다는 것에 공감합니다. 이후에 글을 한 번 업데이트하도록 하겠습니다. 🙂

댓글 남기기