소멸자는 객체를 한 번 더 살린다
Esc

    소멸자는 객체를 한 번 더 살린다

    –C#GC

    생성자는 태어날 때 딱 한 번 실행됐습니다. 그럼 죽을 때 실행되는 것도 있을까요.

    있습니다. 소멸자라고 부릅니다.

    class 연결
    {
        ~연결()
        {
            Console.WriteLine("치웁니다");
        }
    }

    Dispose 를 깜빡한 사람을 위한 안전망처럼 보입니다. 그런데 이걸 붙이면 그 객체는 더 늦게 치워집니다.

    소멸자가 붙은 객체는 GC 가 한 번에 못 치웁니다. 줄을 세워 뒀다가 나중에 다시 와야 합니다.

    GC 가 두 번 와야 한다

    앞에서 GC 가 뿌리에서 손이 닿는 것을 찾고 나머지를 지운다고 했습니다. 소멸자가 붙어 있으면 그 자리에서 지우지 못합니다. 소멸자를 아직 안 돌렸으니까요.

    세대가 있는 런타임이라면 이 사이에 한 세대 위로 올라갑니다. 0세대에서 싸게 사라졌을 것이 1세대나 2세대로 승진합니다. 거기는 훨씬 드물게 도는 곳입니다.

    그리고 그 객체가 붙잡고 있는 필드들도 같이 살아남습니다. 작은 객체 하나에 소멸자를 붙였는데 그 안에 큰 배열이 있으면 그 배열도 한 사이클 더 남습니다.

    언제 불릴지 아무도 모른다

    소멸자는 이런 것을 보장하지 않습니다.

    • 언제 불리는지. GC 가 돌아야 하고, 그다음 별도 스레드가 처리해야 합니다
    • 어떤 순서로 불리는지. A 가 B 를 가리켜도 A 의 소멸자가 먼저라는 보장이 없습니다
    • 불리기는 하는지. 프로그램이 끝날 때 아직 대기줄에 남아 있으면 그냥 안 불립니다
    • 어느 스레드에서 도는지. 내 스레드가 아닙니다

    이것 때문에 소멸자 안에서 할 수 있는 일이 거의 없습니다. 다른 관리 객체를 만지면 그게 이미 치워졌을 수도 있습니다. 순서가 보장되지 않으니까요. 로그를 남기려고 로거를 부르면 그 로거가 이미 죽었을 수 있습니다.

    소멸자 안에서 예외가 나면 프로그램이 그대로 죽습니다. 잡아 줄 사람이 없습니다.

    그래서 결론은 이렇게 됩니다.

    치우는 일은 Dispose 로 합니다. 소멸자는 마지막 보험일 뿐이고, 그마저 안 드는 보험입니다.

    그럼 언제 붙이나

    거의 안 붙입니다. 조건이 하나뿐입니다.

    운영체제에서 직접 받은 것을 필드로 들고 있을 때입니다. IntPtr 로 받은 핸들 같은 것입니다. 이건 GC 가 모르고, 아무도 안 치우면 프로그램이 끝날 때까지 새 나갑니다. 그런 경우에만 마지막 보험으로 붙입니다.

    내가 만든 클래스가 StreamWriter 나 Socket 을 필드로 들고 있는 것은 여기 해당하지 않습니다. 그건 이미 누군가 관리하는 관리 객체입니다. 내가 Dispose 만 잘 넘겨주면 됩니다.

    실무에서는 이 조건마저 SafeHandle 을 쓰면 사라집니다. SafeHandle 이 안에서 그 처리를 이미 하고 있어서, 그걸 필드로 들면 소멸자를 안 써도 됩니다.

    둘을 같이 쓰는 모양

    그래도 써야 한다면 정해진 모양이 있습니다. Dispose 와 소멸자가 같은 일을 하되, 누가 불렀는지에 따라 하는 범위가 다릅니다.

    class 자원 : IDisposable
    {
        private IntPtr _핸들;          // OS 가 준 것. GC 가 모름
        private StreamWriter _파일;    // 관리 객체. GC 가 앎
        private bool _치웠나;
    
        public void Dispose()
        {
            치우기(true);
            GC.SuppressFinalize(this);   // 소멸자 부르지 말라고 표시
        }
    
        ~자원()
        {
            치우기(false);
        }
    
        private void 치우기(bool 사람이불렀나)
        {
            if (_치웠나) return;
    
            if (사람이불렀나)
            {
                _파일?.Dispose();     // 관리 객체는 여기서만
            }
    
            해제(_핸들);              // OS 가 준 것은 양쪽에서
            _핸들 = IntPtr.Zero;
            _치웠나 = true;
        }
    }

    두 줄만 이해하면 됩니다.

    사람이불렀나 로 갈리는 이유. 소멸자에서 불렸다면 _파일 은 이미 치워졌을 수 있습니다. 순서가 보장되지 않으니까요. 그래서 소멸자 경로에서는 관리 객체를 안 만집니다. OS 핸들만 돌려주고 끝냅니다.

    GC.SuppressFinalize 가 하는 일. “이 객체는 소멸자를 부를 필요 없다” 고 표시합니다. Dispose 를 제대로 불렀으면 이미 다 치웠으니 대기줄에 올릴 이유가 없습니다. 이 한 줄로 위에서 본 한 사이클 더 사는 문제가 사라집니다.

    즉 Dispose 를 부르면 소멸자를 붙인 손해가 없어지고, 안 부르면 그때 보험이 발동합니다.

    정리

    • 소멸자는 ~클래스() 로 씁니다. GC 가 치우기 전에 한 번 실행됩니다
    • 소멸자가 있으면 GC 가 한 번에 못 치웁니다. 대기줄에 올렸다가 두 번째 GC 에서 사라집니다
    • 그동안 객체와 그 안의 필드가 전부 살아남고, 세대가 있는 런타임이면 한 세대 승진합니다
    • 언제, 어떤 순서로, 불리기는 하는지 아무것도 보장되지 않습니다
    • 그래서 치우는 일은 Dispose 로 하고 소멸자는 보험으로만 둡니다
    • OS 가 직접 준 핸들을 들고 있을 때만 붙입니다. 대부분은 SafeHandle 로 끝납니다
    • Dispose 안에서 GC.SuppressFinalize(this) 를 부르면 손해가 사라집니다