---
title: "GC 가 치우지 못하는 것이 있다"
description: "힙은 GC 가 알아서 치웁니다. 파일 핸들과 소켓은 아닙니다. using 이 있는 이유입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "GC"]
canonical: https://blog.unialgames.com/posts/csharp-idisposable/
---
앞에서 GC 가 아무도 안 가리키는 것을 알아서 치운다고 했습니다.
그러면 우리가 치울 일이 없어야 할 텐데, 이런 코드가 있습니다.

```csharp
using (var 파일 = new StreamWriter("log.txt"))
{
    파일.WriteLine("안녕");
}
```

`using` 이라고 적어서 끝나는 자리를 표시했습니다. GC 가 알아서 한다면 왜 이걸 적을까요.

> **GC 는 힙의 메모리만 압니다. 파일 핸들이나 소켓처럼 운영체제가 빌려준 것은 모릅니다.**

## GC 가 아는 것과 모르는 것

`new StreamWriter(...)` 를 하면 두 가지가 생깁니다.

<x-heap>
<script type="application/json">
{
  "label": "객체 하나에 딸린 두 가지",
  "steps": [
    {
      "code": "var 파일 = new StreamWriter(\"log.txt\");",
      "why": "힙에 객체가 생깁니다. 이건 GC 가 압니다. 그런데 그 안에 운영체제가 빌려준 파일 핸들이 들어 있습니다.",
      "stack": [{ "name": "파일", "ref": 0, "hot": true }],
      "heap": [{ "id": 0, "title": "StreamWriter", "fields": ["버퍼: ...", "핸들: OS 가 빌려준 것"], "hot": true }]
    },
    {
      "code": "파일 = null;",
      "why": "아무도 안 가리킵니다. 객체는 버려졌지만 핸들은 아직 열린 채입니다.",
      "stack": [{ "name": "파일", "value": "null", "hot": true }],
      "heap": [{ "id": 0, "title": "StreamWriter", "fields": ["버퍼: 아직 안 쓴 내용", "핸들: 여전히 열림"], "dead": true }]
    },
    {
      "code": "// 언젠가 GC 가 돌면",
      "why": "메모리는 회수됩니다. 그런데 GC 가 언제 돌지는 아무도 모릅니다. 그때까지 파일은 잠긴 채입니다.",
      "stack": [{ "name": "파일", "value": "null" }],
      "heap": []
    }
  ],
  "note": "GC 가 늦는 것이 문제가 아니라, 언제 도는지 모르는 것이 문제입니다."
}
</script>
</x-heap>

메모리는 늦게 회수돼도 별일이 없습니다. 남는 메모리가 있으면 GC 는 굳이 안 돕니다.

파일 핸들은 다릅니다. 열린 동안 다른 프로그램이 그 파일을 못 엽니다.
운영체제가 빌려주는 핸들은 개수도 정해져 있습니다. 안 돌려주고 계속 빌리기만 하면 언젠가 바닥납니다.

그리고 `StreamWriter` 는 쓴 내용을 버퍼에 모아 뒀다가 한꺼번에 내보냅니다.
닫지 않으면 **버퍼에 남은 내용이 파일에 안 적힙니다.** 프로그램이 끝나 버리면 그대로 사라집니다.

이런 것을 관리되지 않는 자원이라고 부릅니다. GC 밖에 있다는 뜻입니다.

## 그래서 Dispose 가 있다

돌려주는 방법이 필요합니다. 그게 `IDisposable` 입니다. 메서드 하나짜리 인터페이스입니다.

```csharp
public interface IDisposable
{
    void Dispose();
}
```

`Dispose()` 를 부르면 그 안에서 핸들을 닫고 버퍼를 내보냅니다.
`GC` 를 기다리는 것이 아니라 **우리가 시점을 정합니다.**

문제는 부르는 걸 잊는다는 것입니다. 그리고 중간에 예외가 나면 못 부릅니다.

```csharp
var 파일 = new StreamWriter("log.txt");
파일.WriteLine(계산());      // 여기서 터지면
파일.Dispose();              // 이 줄에 못 옵니다
```

## using 은 try/finally 다

`using` 은 이 문제를 문법으로 막습니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    {
      "label": "내가 쓴 코드",
      "code": "using (var 파일 = new StreamWriter(\"log.txt\"))\n{\n    파일.WriteLine(계산());\n}"
    },
    {
      "label": "컴파일된 결과",
      "code": "var 파일 = new StreamWriter(\"log.txt\");\ntry\n{\n    파일.WriteLine(계산());\n}\nfinally\n{\n    if (파일 != null) 파일.Dispose();\n}",
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

앞에서 본 `finally` 그대로입니다. 예외가 나든 `return` 으로 나가든 `Dispose()` 는 반드시 불립니다.

C# 8 부터는 괄호 없이 쓸 수도 있습니다. 이러면 그 블록이 끝날 때 정리됩니다.

```csharp
void 쓰기()
{
    using var 파일 = new StreamWriter("log.txt");
    파일.WriteLine("안녕");
}   // 여기서 Dispose
```

## 내가 만드는 쪽

관리되지 않는 자원을 들고 있는 클래스를 만들면 `IDisposable` 을 붙입니다.

```csharp
class 연결 : IDisposable
{
    private Socket _소켓;
    private bool _치웠나;

    public void Dispose()
    {
        if (_치웠나) return;    // 두 번 불려도 괜찮게
        _소켓?.Close();
        _소켓 = null;
        _치웠나 = true;
    }
}
```

한 가지만 지키면 됩니다. **두 번 불려도 터지지 않게** 만듭니다.
`using` 안에서 직접 `Dispose()` 를 부르는 코드가 섞이면 두 번 불립니다. 흔한 일입니다.

내가 만든 클래스가 다른 `IDisposable` 을 필드로 들고 있으면 그것도 같이 치웁니다.
그러지 않으면 내 것만 정리되고 안에 든 것이 남습니다.

## 어디서 만나나

`IDisposable` 이 붙은 것은 대개 이런 것들입니다.

- 파일: `StreamReader`, `StreamWriter`, `FileStream`
- 네트워크: `HttpClient`, `Socket`, `TcpClient`
- 데이터베이스: `SqlConnection`, `SqlCommand`
- 그림: `Bitmap`, `Graphics`

Unity 에서는 조금 다른 얼굴로 나옵니다.

```csharp
using (var 결과 = new NativeArray<float>(100, Allocator.Temp))
{
    ...
}
```

`NativeArray` 는 GC 가 관리하지 않는 메모리를 직접 잡습니다.
안 치우면 에디터가 누수를 경고합니다.

`Texture2D` 나 `Mesh` 처럼 유니티 객체는 `Dispose` 가 아니라 `Destroy` 로 치웁니다.
이름은 다르지만 "GC 가 모르는 것을 우리가 시점을 정해 돌려준다" 는 이야기는 같습니다.

## 한 가지 예외: HttpClient

거의 모든 `IDisposable` 은 `using` 으로 감싸는 것이 맞는데 `HttpClient` 는 반대입니다.

```csharp
// 이렇게 쓰면 안 됩니다
using (var client = new HttpClient()) { ... }
```

`Dispose` 를 해도 아래의 소켓 연결이 한동안 대기 상태로 남습니다.
매번 만들고 버리면 그 대기가 쌓여서 결국 연결을 못 만듭니다.
`HttpClient` 는 하나 만들어 오래 쓰는 것이 맞습니다.

규칙을 외우기보다 **왜 치우는지**를 보면 이런 예외도 설명이 됩니다.
치우는 목적은 자원을 빨리 돌려주는 것인데, `HttpClient` 는 돌려주는 것이 오히려 손해라서 그렇습니다.

## 정리

- GC 는 **힙 메모리만** 압니다. 파일 핸들, 소켓, 네이티브 메모리는 모릅니다
- 그래서 언제 도는지 모르는 GC 를 기다리면 파일이 잠긴 채 남거나 버퍼가 안 비워집니다
- `IDisposable.Dispose()` 로 **우리가 시점을 정해** 돌려줍니다
- `using` 은 `try/finally` 로 풀립니다. 예외가 나도 반드시 정리됩니다
- 내가 만들 때는 두 번 불려도 터지지 않게 하고, 안에 든 `IDisposable` 도 같이 치웁니다

`Dispose` 를 부르는 것은 우리 몫입니다. `using` 이 도와주기는 하지만, 그것도 우리가 적어야 합니다.
적는 것을 깜빡하면 아무도 대신 닫아 주지 않습니다.

GC 가 치우기 직전에 대신 실행되는 것을 붙일 수 있습니다. 안전망처럼 보입니다.
그런데 붙이면 그 객체는 오히려 **더 늦게** 치워집니다.