C# Dispose(그리고 Finalizer)

NightMiya827·2023년 12월 17일
0

(블로그 이사하면서 옮겨진 글입니다)

C#에서 Dispose를 사용하는 이유는 관리되지 않는 리소스를 해제하고(release), 할당된 메모리를 해제하고(free), 콜렉션에서 아이템을 제거하고, 락을 해제하기 위하는 등의 행동을 하기 위해서라고 되어있습니다.[1]여기서 얘기하는 관리되지 않는 리소스의 대표적인 예로는 파일, 네트워크 연결 등의 OS연결을 래핑하고 있는 개체들이 있습니다. 반대로 관리되는 메모리는 프로그램의 스택,힙 영역에 존재하는 메모리입니다. 당연한 말이긴 하지만, 관리되는 메모리가 아니면 관리되지 않는 메모리니까 이렇게 구분하는 게 더 편할 듯 합니다.

C++에서는 이 역할을 보통 소멸자가 해주게됩니다. 그런데, C#에도 소멸자(Destructor)와 대응되는 종료자(Finalizer)라는 것이 존재합니다. 그런데 공식 문서를 보면 몇몇 경우에는 종료자를 사용하지 않고 대신 Dispose를 사용하도록 권장하고있고[2] 다른 글들을 찾아보면 리소스를 해제하는 등의 용도로 종료자보다는 Dispose가 선호되는 걸 찾아볼 수 있습니다.[3]

객체의 사용이 끝나고난 후에 호출되고, 객체를 없애기 전에 해줘야할 행동들을 수행한다는 점에서는 동일한데 두개의 동작이 어떤 차이가 있길래 두개가 각각 정의되어있는지, 어째서 Dispose를 사용하는 것을 권장하는걸까요? 간단한 코드와 함께 두 기능의 차이를 알아보겠습니다.

public class Wheel {
    public int radius;
    public Wheel() {
        radius = 100;
        Console.WriteLine($"\t{this} is created.");
    }
    ~Wheel() {
        radius = 0;
        Console.WriteLine($"\t{this} destructed");
    }
}
public class Car {
    public Wheel Wheel { get; set; }
    public Car() {
        Console.WriteLine($"\t{this} is created.");
    }
    ~Car() {
        Console.WriteLine($"\t{this}: {Wheel.radius} is destructed. ");
    }
}

public class Program {
    public static void Main() {
        Console.WriteLine("Program Start");
        var car = new Car();
        var wheel = new Wheel();
        car.Wheel = wheel;
        wheel = null;
        car = null;
        GC.Collect();
        GC.WaitForFullGCComplete();
        Console.WriteLine("Program End");
    }
}

이 코드를 실행하면 아래와같은 결과가 나옵니다.

Program Start
   Car is created.
   Wheel is created.
Program End
   Wheel destructed
   Car: 0 is destructed.

똑똑하게 해제자 호출을 참조한 순서대로 해주면 좋겠지만, 그렇게는 되지 않습니다. 그리고 또 한가지는 프로그램이 종료되고 나서야 종료자가 호출되었다는 점입니다. GC를 호출했는데도 왜 프로그램이 종료되고 나서 종료자 호출 메세지가 나오는걸까요? 공식 문서를 보면, 종료자가 별도로 구현되어있는 경우, GC 수행 → 종료자 대기열에 넣기 → 종료자 호출 → 다음 GC에서 정리 순서로 수행되기 때문에, 메모리를 해제하는데 오랜 시간이 걸리게 됩니다. 그리고 이 과정에서 오버헤드도 생기게 되지요. 그리고 이 예제에서는 GC.Collect를 직접 호출했지만, 보통은 이 함수를 직접 호출할 일은 많지 않습니다. 그러니 종료자에서 필요한 리소스 해제를 하고있다면 그 해제가 언제 이루어질지 알 수 없는 것이죠.

종료자 해제를 대기하기 위해서 GC.WaitForPendingFinalizers를 호출할 수 있습니다. 하지만 여전히 GC.CollectGC.WaitForPendingFinalizers를 강제로 호출해야하고 대기시간이 더 길어진다는 문제가 남습니다.

이런 문제들을 피하기 위해서, 리소스를 내가 원하는 때에 즉시 해제할 수 있게 Dispose가 필요합니다. 아래와같이 Dispose를 명시적으로 호출해서 필요한 리소스를 해제할 수 있습니다.

public class Wheel : IDisposable {
    public int radius;
    public Wheel() {
        radius = 100;
        Console.WriteLine($"\t{this} is created.");
    }
    public void Dispose() {
        radius = 0;
        Console.WriteLine($"\t{this} destructed");
    }
}
public class Car : IDisposable {
    public Wheel Wheel { get; set; }
    public Car() {
        Console.WriteLine($"\t{this} is created.");
    }
    public void Dispose() {
        Console.WriteLine($"\t{this}: {Wheel.radius} is destructed. ");
    }
}

public class Program {
    public static void Main() {
        Console.WriteLine("Program Start");
        var wheel = new Wheel();
        var car = new Car();
        car.Wheel = wheel;
        car.Dispose();
        wheel.Dispose();
        Console.WriteLine("Program End");
    }
}

결과는 아래와 같이 코드에서 예측할 수 있는 순서대로 나옵니다.

Program Start
   Wheel is created.
   Car is created.
   Car: 100 is destructed.
   Wheel destructed
Program End

하지만 이렇게 직접 호출하고 관리한다면 IDsposable이라는 인터페이스는 존재할 이유가 없고, Dispose라는 함수이름을 별도로 지정할 이유도 없습니다. Dispose는 그냥 함수일 뿐이니까요. 위 예시에서는 IDisposable을 상속하나마나 차이가 없지만, IDisposable을 상속하면 아래와같이 사용하는게 가능해집니다.

public class Wheel : IDisposable {
    public int radius;
    public Wheel() {
        radius = 100;
        Console.WriteLine($"\t{this} is created.");
    }
    public void Dispose() {
        radius = 0;
        Console.WriteLine($"\t{this} destructed");
    }
}
public class Car : IDisposable {
    public Wheel Wheel { get; set; }
    public Car() {
        Console.WriteLine($"\t{this} is created.");
    }
    public void Dispose() {
        Console.WriteLine($"\t{this}: {Wheel.radius} is destructed. ");
    }
}

public class Program {
    public static void Main() {
        Console.WriteLine("Program Start");

        using (var wheel = new Wheel()) {
            using (var car = new Car()) {
                car.Wheel = wheel;
            }
        }
        Console.WriteLine("Program End");
    }
}

이러면 using이 끝나는 시점에서 Dispose를 자동으로 호출해줍니다. 즉, 필요한 리소스 해제나 락을 풀어주는 등의 행위를 빠르게 수행해주기 위해서는 Dispose가 필요합니다. 또한 호출 순서도 제어가 가능하기 때문에 종료자호출에서는 순서가 꼬이면 null reference 예외가 생길 여지가 많지만 Dispose를 사용했을 때는 null reference 예외가 나면 그냥 내가 코드를 잘못짠겁니다. 이런 문제가 줄어듭니다.

결론

  1. 힙, 스택 등 프로그램 메모리에 속하지 않은 영역에 있는 리소스(관리되지 않는 리소스)는 GC가 자동으로 정리를 해줄 수 없다.
  2. 관리되지 않는 리소스를 해제해주기 위해서는 종료자(Finalizer)를 구현하거나 직접 리소스를 해제해주는 함수를 구현하고 호출해줘야한다.
  3. IDispoable 인터페이스를 상속하고 using구문과 함께 사용하면, using을 벗어나는 순간 바로 Dispose를 호출하여 리소스를 간단히 해제할 수 있다.
  4. 종료자는 호출 시점을 내가 제어할 수 없어 리소스 해제가 언제 일어날지 장담할 수 없다. 또한 종료자 호출에는 이런저런 오버헤드가 있다.
  5. 그러니 Dispose와 using을 잘 쓰자

+) Dispose의 용도는 이 글의 시작에서 언급한 것처럼 관리되지 않는 리소스 해제에만 국한되지는 않는다.

C#을 공부하기 시작한지 얼마 안되어서 Dispose를 직접 사용하면서 프로그램을 만들어 본 적도 없다보니 잘못된 내용이 없을지 걱정이네요. 자세한 내용은 Dispose 패턴 사용소멸자에 대한 공식 문서를 참고해주시면 좋을 것 같습니다.

0개의 댓글

관련 채용 정보