---
title: "소멸자는 객체를 한 번 더 살린다"
description: "안전망처럼 보이는데 언제 불릴지도 불리기는 할지도 정할 수 없습니다. 붙이면 오히려 늦게 치워집니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "GC"]
canonical: https://blog.unialgames.com/posts/csharp-finalizer/
---
생성자는 태어날 때 딱 한 번 실행됐습니다. 그럼 죽을 때 실행되는 것도 있을까요.

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

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

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

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

## GC 가 두 번 와야 한다

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

<x-heap>
<script type="application/json">
{
  "label": "소멸자가 붙으면 무슨 일이",
  "steps": [
    {
      "code": "var c = new 연결();  // ~연결() 이 있음",
      "why": "힙에 만들어질 때, 소멸자가 있는 객체라는 것이 따로 기록됩니다.",
      "stack": [{ "name": "c", "ref": 0, "hot": true }],
      "heap": [{ "id": 0, "title": "연결", "fields": ["소멸자 있음"], "hot": true }]
    },
    {
      "code": "c = null;",
      "why": "아무도 안 가리킵니다. 소멸자가 없었다면 다음 GC 에서 사라집니다.",
      "stack": [{ "name": "c", "value": "null", "hot": true }],
      "heap": [{ "id": 0, "title": "연결", "fields": ["소멸자 있음"], "dead": true }]
    },
    {
      "code": "// 첫 번째 GC",
      "why": "지우는 대신 대기줄에 넣습니다. 대기줄이 가리키니 이 객체는 다시 살아납니다.",
      "stack": [{ "name": "c", "value": "null" }],
      "heap": [{ "id": 0, "title": "연결", "fields": ["대기줄에 올라감", "다시 살아있음"], "hot": true }]
    },
    {
      "code": "// 별도 스레드가 ~연결() 실행",
      "why": "언제 실행될지는 정해져 있지 않습니다. 끝나면 대기줄에서 빠집니다.",
      "stack": [{ "name": "c", "value": "null" }],
      "heap": [{ "id": 0, "title": "연결", "fields": ["소멸자 실행 끝"], "dead": true }]
    },
    {
      "code": "// 두 번째 GC",
      "why": "이제서야 진짜로 사라집니다. GC 가 두 번 온 뒤입니다.",
      "stack": [{ "name": "c", "value": "null" }],
      "heap": []
    }
  ],
  "note": "소멸자를 붙이면 객체가 한 사이클 더 삽니다. 안에 든 것들도 같이 남습니다."
}
</script>
</x-heap>

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

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

## 언제 불릴지 아무도 모른다

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

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

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

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

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

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

## 그럼 언제 붙이나

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

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

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

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

## 둘을 같이 쓰는 모양

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

```csharp
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)` 를 부르면 손해가 사라집니다