[PART10.예외 처리 기본(5/9)] using 문과 IDisposable 기초 — 비관리 리소스를 안전하게 닫는 약속
왜 GC만으로는 부족한가 / using이 try-finally로 풀리는 IL / IDisposable 직접 구현과 표준 Dispose 패턴 / Unity 신입이 자주 빠지는 함정
목차
1. 문제 제기 — "왜 GC가 있는데도 직접 닫아야 하는가"
C#을 쓰면서 한 번쯤은 이런 질문을 떠올리게 됩니다.
"C#은 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 알아서 정리해 준다고 했는데, 왜 using 같은 걸로 또 닫아야 하지?"
세이브 데이터 저장 코드를 떠올려 봅니다. 신입 개발자가 흔히 작성하는 형태입니다.
// Unity 게임의 세이브 코드 — 한 줄씩 호출되는 흔한 패턴
public void Save(PlayerData data)
{
var path = Path.Combine(Application.persistentDataPath, "save.dat");
var stream = new FileStream(path, FileMode.Create);
var writer = new BinaryWriter(stream);
writer.Write(data.level);
writer.Write(data.gold);
// 끝. GC가 알아서 정리할 거라고 믿는다.
}
테스트할 때는 잘 동작합니다. 그런데 며칠 뒤 QA에서 이런 보고가 올라옵니다.
- "세이브 직후 다시 로드하니
IOException: 파일이 다른 프로세스에서 사용 중입니다가 떴습니다." - "안드로이드에서 30분쯤 플레이 후
Too many open files로 게임이 강제 종료됐습니다." - "배포 서버 로그에
connection pool timeout이 잔뜩 찍혔습니다."
원인은 단 하나입니다. FileStream이 들고 있던 OS 파일 핸들을 닫지 않았기 때문입니다. GC가 메모리는 회수하지만, 운영체제가 발급한 파일 핸들·소켓·DB 연결까지 즉시 닫아 주지는 않습니다. 게다가 GC가 언제 실행될지는 결정적이지 않아서, "언젠가는 정리되겠지" 라는 기대로는 안정적인 프로그램을 만들 수 없습니다.
이 문제를 해결하기 위해 .NET이 만든 계약이 IDisposable이고, 그 계약을 빠뜨리지 않게 강제하는 문법 장치가 using 문입니다. 이번 글에서는 두 가지가 어떻게 함께 동작하는지, 컴파일러가 IL(Intermediate Language, .NET이 실행 직전 단계로 변환하는 중간 언어) 수준에서 어떻게 풀어쓰는지, 그리고 Unity 신입이 자주 헷갈리는 함정까지 정리합니다.
2. 개념 정의 — IDisposable이라는 약속과 using이라는 보증
2.1 비유 — "도서관에서 빌린 책"
도서관에서 책을 빌리면, 다 읽었을 때 반납하는 것이 약속입니다. 책상에 그냥 두고 나오면 다른 사람이 빌릴 수 없고, 사서가 일일이 책상을 돌면서 회수해야 합니다.
- 빌린 책 = 운영체제가 빌려준 파일 핸들·DB 연결·소켓
- 반납하는 행위 =
Dispose()메서드 호출 - "다 보면 반납하겠습니다" 약속 =
IDisposable인터페이스 - 반납을 강제로 챙겨 주는 사서 =
using문
GC는 이 비유에서 청소부 역할입니다. 도서관(메모리)에 굴러다니는 빈 종이는 알아서 치우지만, 빌린 책 자체를 도서관 카운터에 가져다 놓는 일은 GC의 업무가 아닙니다.
2.2 시각화 — 관리 vs 비관리 리소스

핵심은 FileStream 객체 자체는 GC가 회수하지만, 그 객체가 들고 있는 파일 핸들은 별도의 자원이라는 점입니다. 객체가 회수될 때 핸들이 자동으로 닫히는 것이 아니라, Dispose()가 명시적으로 호출되어야 OS에 반환됩니다.
2.3 기본 예시 — IDisposable과 using의 첫 만남
using— using 문 (Using statement) 블록을 벗어나는 모든 경로(정상 종료·예외·return)에서Dispose()를 자동으로 호출해 주는 문법.IDisposable을 구현한 객체에만 사용할 수 있다.
예시:using (var s = new FileStream(...)) { ... }블록 종료 시점에s.Dispose()호출 보장
using System;
using System.IO;
public static class SaveSystem
{
// Unity 신입 개발자가 작성하는 안전한 세이브 함수
public static string ReadSave(string path)
{
// using 블록을 벗어나는 즉시 reader.Dispose()가 호출된다.
// 예외가 발생하든, return 으로 빠져나가든 보장된다.
using (var reader = new StreamReader(path))
{
return reader.ReadToEnd();
}
}
}
위 코드는 블록을 벗어날 때 Dispose() 호출이 보장됩니다. return으로 빠져나가는 경로에서도, 파일이 손상되어 IOException이 던져지는 경로에서도 마찬가지입니다. 이것이 using이 주는 핵심 가치입니다.
쉬운 설명: "이 변수를 다 쓰면 알아서 닫아 줘." 기술 정의: using (var x = ...) { ... }은 x가 IDisposable을 구현했음을 컴파일러가 검증하고, 블록 종료 시 x.Dispose() 호출을 추가하는 문법 설탕(syntactic sugar)입니다.
3. 내부 동작 — using이 try-finally로 풀리는 IL
3.1 컴파일러가 만드는 코드
using 블록은 마법이 아니라 컴파일러가 try-finally로 다시 써 주는 코드입니다. 우리가 쓴 코드와 컴파일러가 풀어쓴 코드를 IL로 비교해 봅니다.

3.2 IL 분석 — using 블록
public static string ReadWithUsing(string path)
{
using (var reader = new StreamReader(path))
{
return reader.ReadToEnd();
}
}
.method public static string ReadWithUsing(string path)
{
.locals init (
[0] class StreamReader,
[1] string
)
IL_0001: ldarg.0
IL_0002: newobj instance void StreamReader::.ctor(string) // 리소스 생성
IL_0007: stloc.0 // local 0 = reader
.try
{
IL_0009: ldloc.0
IL_000a: callvirt instance string TextReader::ReadToEnd()
IL_000f: stloc.1 // 결과 저장
IL_0010: leave.s IL_001d // try 정상 종료
}
finally
{
IL_0012: ldloc.0
IL_0013: brfalse.s IL_001c // reader == null 이면 skip
IL_0015: ldloc.0
IL_0016: callvirt instance void IDisposable::Dispose() // 핵심: 항상 호출
IL_001c: endfinally
}
IL_001d: ldloc.1
IL_001e: ret
}
세 가지가 핵심입니다.
.try블록과finally블록으로 컴파일됩니다.using은 키워드가 아니라 IL 레벨에서는 평범한 예외 처리 구조로 환원됩니다.brfalse.s로 null 체크가 들어갑니다.reader가null이어도 NullReferenceException이 나지 않게 컴파일러가 자동으로 챙겨 줍니다.callvirt IDisposable::Dispose()가 호출됩니다.StreamReader의Dispose가 아닌 인터페이스를 통한 가상 호출이라는 점이 중요합니다. 어떤 타입을 넣어도IDisposable로만 다루면 동일한 IL이 나옵니다.
3.3 IL 분석 — 우리가 직접 쓴 try-finally
위와 동등한 코드를 손으로 작성하면 IL은 거의 동일합니다.
public static string ReadWithTryFinally(string path)
{
StreamReader reader = new StreamReader(path);
try
{
return reader.ReadToEnd();
}
finally
{
if (reader != null)
((IDisposable)reader).Dispose();
}
}
이 두 메서드의 IL은 거의 1:1로 일치합니다. using은 손으로 쓴 try-finally의 기계적 번역에 지나지 않습니다. 즉 성능 페널티가 없으며, 단지 "쓸 때마다 손으로 작성하면 빠뜨리는 사람이 있어서 컴파일러에게 맡긴다"는 안전 장치입니다.
3.4 IL 분석 — using 선언(C# 8.0)
public static string ReadWithUsingDeclaration(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}
.method public static string ReadWithUsingDeclaration(string path)
{
IL_0002: newobj instance void StreamReader::.ctor(string)
IL_0007: stloc.0
.try
{
IL_0008: ldloc.0
IL_0009: callvirt instance string TextReader::ReadToEnd()
IL_000f: leave.s IL_001c
}
finally
{
IL_0011: ldloc.0
IL_0012: brfalse.s IL_001b
IL_0014: ldloc.0
IL_0015: callvirt instance void IDisposable::Dispose()
IL_001b: endfinally
}
}
블록형 using과 IL이 사실상 동일합니다. 차이는 단 하나, Dispose()가 호출되는 시점이 메서드 끝으로 옮겨진다는 점입니다. 들여쓰기가 한 단계 줄어드는 가독성 효과만 있을 뿐, 동작 원리는 같습니다.
4. 실전 적용 — Unity에서 자주 만나는 IDisposable 타입
4.1 IDisposable을 직접 구현하기
자기만의 클래스에 IDisposable을 구현해야 할 때가 있습니다. 예를 들어 게임에서 임시 로그 파일을 들고 있는 클래스가 있다고 가정합니다.
IDisposable— 정리 약속 인터페이스 한 개의void Dispose()메서드만 가진 인터페이스. "이 타입은 반드시 정리해야 할 자원을 갖고 있다"는 계약을 표현한다.
예시:class MyType : IDisposable { public void Dispose() { ... } }using 문에 넣으면 자동으로 호출됨
using System;
using System.IO;
public sealed class SimpleLogger : IDisposable
{
private StreamWriter _writer;
public SimpleLogger(string path)
{
_writer = new StreamWriter(path, append: true);
}
public void Write(string msg) => _writer.WriteLine(msg);
// IDisposable 구현 — 한 줄짜리 약속
public void Dispose()
{
_writer?.Dispose();
_writer = null;
}
}
// 사용
using (var log = new SimpleLogger("log.txt"))
{
log.Write("스테이지 진입");
} // log.Dispose() 자동 호출 → 내부 StreamWriter 도 닫힘
이 형태로 충분한 경우가 대부분입니다. 클래스가 다른 IDisposable 객체만 갖고 있고, OS 자원을 직접 들고 있지 않다면 이 단순 패턴으로 끝입니다.
4.2 Before/After — 나쁜 예 vs 좋은 예
Unity에서 자주 보는 잘못된 세이브 코드부터 살펴봅니다.
// ❌ Before — Dispose 누락
public void SaveBad(PlayerData data)
{
var path = Path.Combine(Application.persistentDataPath, "save.dat");
var stream = new FileStream(path, FileMode.Create);
var writer = new BinaryWriter(stream);
writer.Write(data.level);
writer.Write(data.gold);
// 핸들이 GC 시점까지 열려 있음 → 직후 Load 시 IOException 가능
}
이 코드는 SaveBad 호출 직후 Load를 시도하면 파일이 잠겨 있어 실패합니다. GC가 FileStream을 회수할 때까지 핸들이 OS에 남아 있기 때문입니다.
// ✅ After — using 으로 핸들 즉시 반환
public void SaveGood(PlayerData data)
{
var path = Path.Combine(Application.persistentDataPath, "save.dat");
using (var stream = new FileStream(path, FileMode.Create))
using (var writer = new BinaryWriter(stream))
{
writer.Write(data.level);
writer.Write(data.gold);
} // writer.Dispose() → stream.Dispose() 순으로 자동 호출
}
After에서 IL을 뜯어 보면 .try { ... } finally { Dispose() }가 두 번 중첩되어 있습니다. 안쪽 writer가 먼저 닫히고, 그다음 바깥쪽 stream이 닫힙니다. 선언 역순으로 정리되는 점이 일관됩니다.
4.3 Unity 실전 — UnityWebRequest 코루틴
UnityWebRequest도 IDisposable입니다. 코루틴(Coroutine, Unity가 한 메서드를 여러 프레임에 걸쳐 실행하는 메커니즘) 안에서 사용할 때 특히 주의해야 합니다.
// ✅ 코루틴 + using — 도중 중단되어도 안전
IEnumerator FetchScore(string url)
{
using (var req = UnityWebRequest.Get(url))
{
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success)
Debug.Log(req.downloadHandler.text);
} // 코루틴이 정상 종료되든, StopCoroutine 으로 중단되든 Dispose 보장
}
yield return 한 가운데에서 코루틴이 중단(StopCoroutine, 씬 전환 등)되어도 using 블록이 함께 정리됩니다. Unity 코루틴은 내부적으로 IEnumerator를 분해해서 try-finally를 보존하기 때문입니다.
4.4 IL 해설 요약
지금까지 본 모든 using 코드의 IL 핵심 명령어는 같습니다.
| IL 명령어 | 의미 |
|---|---|
.try { ... } |
사용자 코드 본문 |
leave.s |
try 블록 정상 탈출 (finally 실행 후 점프) |
finally { ... } |
정상·예외·return 모두에서 실행 |
brfalse.s |
null 체크 (reader가 null이면 Dispose skip) |
callvirt IDisposable::Dispose() |
인터페이스를 통한 가상 호출 |
IL 레벨에서 using은 "try-finally + 인터페이스 캐스팅 + null 체크" 3종 세트의 매크로라고 정리할 수 있습니다.
5. 함정과 주의사항
5.1 함정 1 — Dispose가 두 번 호출되는 경우
같은 객체에 대해 Dispose()가 두 번 호출되면 어떻게 될까요?
// ❌ 위험한 패턴 — 외부에서도 닫고, using 으로도 닫음
var stream = new FileStream(path, FileMode.Open);
ProcessAndDispose(stream); // 함수 안에서 stream.Dispose() 호출됨
using (stream) // 두 번째 Dispose → 이미 닫힌 핸들에 또 접근 → ObjectDisposedException 가능
{
// ...
}
올바른 규칙: "리소스를 만든 쪽이 닫는다" 입니다. 메서드에 Stream을 인자로 받아 처리할 때는 그 안에서 Dispose하지 않습니다. 호출자가 using으로 감싸도록 둡니다.
// ✅ 호출자가 소유권을 갖는다
using (var stream = new FileStream(path, FileMode.Open))
{
Process(stream); // Process 는 Dispose 하지 않음
}
SimpleLogger.Dispose에 _writer = null을 넣은 이유도 같습니다. 두 번째 호출 시 _writer?.Dispose()가 null 체크로 안전하게 통과되도록 만든 방어적 코드입니다.
5.2 함정 2 — Texture2D는 IDisposable이 아니다 (Unity 한정)
Unity 신입이 가장 자주 빠지는 함정입니다.
// ❌ 컴파일 에러 — Texture2D 는 IDisposable 을 구현하지 않는다
using (var tex = new Texture2D(256, 256))
{
// CS1674: 'Texture2D' is not disposable
}
Texture2D, Mesh, Material, AudioClip 등 UnityEngine.Object 계열은 IDisposable이 아닙니다. 이들의 실제 메모리는 .NET 힙이 아니라 Unity 엔진의 C++ 네이티브 영역에 있고, GC가 아닌 Unity 엔진이 관리합니다. C# 쪽 객체는 네이티브 자원을 가리키는 얇은 래퍼(wrapper)일 뿐입니다.
// ✅ Unity 자산은 Object.Destroy 로 해제
var tex = new Texture2D(256, 256);
// ... 사용
UnityEngine.Object.Destroy(tex); // 또는 에디터에서는 DestroyImmediate
판단 기준은 단순합니다.
- C#/.NET BCL 타입(Stream, Reader, Connection, HttpClient...) →
using+Dispose() - UnityEngine.Object 상속 타입(Texture2D, Mesh, Material...) →
Object.Destroy()
5.3 함정 3 — 표준 Dispose 패턴이 필요한 경우
비관리 자원(C++ 라이브러리에서 받은 포인터, P/Invoke로 얻은 OS 핸들 등)을 직접 들고 있는 클래스라면 단순 IDisposable로 부족합니다. 사용자가 Dispose() 호출을 잊었을 때를 위한 최후의 방어선으로 finalizer(종료자)가 필요합니다.
~ClassName()— finalizer (종료자) GC가 객체를 수거하기 직전에 자동으로 호출되는 메서드. C++ 소멸자와 비슷한 문법이지만 호출 시점이 비결정적이다. Dispose 호출을 까먹은 사용자를 위한 안전 그물로만 사용한다.
예시:~MyClass() { Dispose(false); }GC 가 호출 → 비관리 리소스만 정리
public sealed class RobustHandle : IDisposable
{
private IntPtr _nativeHandle; // 비관리 리소스 (가정)
private StreamWriter _writer; // 관리 리소스
private bool _disposed;
public RobustHandle(string path)
{
_writer = new StreamWriter(path);
_nativeHandle = AcquireNativeHandle(); // 가상의 P/Invoke 호출
}
// 1) 사용자가 직접 호출하는 경로
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // GC 에게 "이미 정리했다" 알림
}
// 2) GC 가 호출하는 안전 그물
~RobustHandle() => Dispose(false);
// 3) 실제 정리 로직
private void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_writer?.Dispose(); // 관리 리소스는 disposing=true 일 때만
}
ReleaseNativeHandle(_nativeHandle); // 비관리 리소스는 항상
_disposed = true;
}
private static IntPtr AcquireNativeHandle() => new IntPtr(1);
private static void ReleaseNativeHandle(IntPtr h) { /* CloseHandle 등 */ }
}
IL을 보면 컴파일러가 ~RobustHandle()을 Finalize() 메서드로 변환합니다.
.method family hidebysig virtual instance void Finalize() cil managed
{
.try
{
ldarg.0
ldc.i4.0 // disposing = false
callvirt instance void Program/RobustHandle::Dispose(bool)
leave.s IL_0014
}
finally
{
ldarg.0
call instance void Object::Finalize() // base.Finalize() 호출
endfinally
}
}
.method public final hidebysig virtual instance void Dispose() cil managed
{
ldarg.0
ldc.i4.1 // disposing = true
callvirt instance void Program/RobustHandle::Dispose(bool)
ldarg.0
call void System.GC::SuppressFinalize(object) // GC 종료자 큐에서 제거
ret
}
핵심 두 가지를 IL에서 확인할 수 있습니다.
Finalize()는 try-finally로 자동 감싸진다. 종료자 안에서 예외가 던져져도 base의 Finalize가 반드시 호출되도록 컴파일러가 보호합니다.GC.SuppressFinalize(this)는 GC 종료자 큐에서 객체를 제거합니다. 결과적으로 GC가 두 번 일하는 것을 막아 주고, 객체가 종료자 큐를 거치지 않고 한 세대에서 바로 회수되도록 해 성능에 도움이 됩니다.
Unity 신입을 위한 한 줄 가이드: 직접 P/Invoke를 쓰거나 네이티브 라이브러리를 다루는 게 아니라면, finalizer는 만들지 않습니다. 단순히 다른 IDisposable을 위임하는 정도면 4.1의 SimpleLogger 형태로 충분합니다. 잘못 만든 finalizer는 GC 압력만 늘립니다.
5.4 함정 4 — using과 비동기 코드의 충돌
using은 동기 Dispose()만 호출합니다. 비동기 정리가 필요한 타입(SqlConnection을 비동기로 닫고 싶다거나, 스트림 플러시가 비동기여야 하는 경우)에는 await using + IAsyncDisposable이 필요합니다. 이 주제는 이번 글의 범위를 넘어가므로 후속편에서 다룹니다.
// ❌ 동기 Dispose 안에서 비동기 정리를 시도하면 데드락 위험
public void Dispose()
{
_connection.CloseAsync().Wait(); // ❌ 동기적으로 기다리지 말 것
}
지금 단계에서는 "비동기 정리가 필요하면 IAsyncDisposable을 찾아본다" 정도만 기억하면 충분합니다.
6. C# 버전별 변화
using과 IDisposable은 C# 1.0부터 있었지만, 사용 편의를 위한 문법 개선이 꾸준히 이뤄졌습니다.
6.1 C# 8.0 — using 선언 (2019)
// Before (C# 1.0 ~ 7.x) — 블록형 using, 들여쓰기 한 단계 깊어짐
public string Read(string path)
{
using (var reader = new StreamReader(path))
{
return reader.ReadToEnd();
}
}
// After (C# 8.0+) — 변수 선언 앞에 using, 메서드 끝에서 Dispose
public string Read(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}
3.4절의 IL 분석에서 봤듯, 두 형태의 IL은 거의 동일합니다. 차이는 Dispose()가 호출되는 시점이 명시적 블록 끝인지 메서드 끝인지뿐입니다. 다중 리소스를 다룰 때 들여쓰기 지옥을 피할 수 있습니다.
// 다중 리소스 — 블록형은 중첩이 깊어진다
using (var stream = File.OpenRead(path))
using (var reader = new StreamReader(stream))
using (var json = new JsonTextReader(reader))
{
// 들여쓰기 4단계
}
// using 선언 — 평평하게 펼쳐진다
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
using var json = new JsonTextReader(reader);
// 들여쓰기 1단계, 메서드 끝에서 역순 Dispose
6.2 C# 8.0 — IAsyncDisposable과 await using (2019)
비동기 정리를 위해 IAsyncDisposable 인터페이스와 await using 문이 추가됐습니다. 함정 5.4에서 짧게 언급했고, 별도 주제로 다룹니다.
6.3 IDisposable 자체는 변하지 않았다
인터페이스 정의(void Dispose() 단 하나)는 C# 1.0부터 그대로입니다. 변한 것은 사용 문법뿐이고, 의미론은 25년째 같습니다. 인터페이스가 매우 단순하다는 것은, 그만큼 잘못 쓰기도 어렵다는 뜻입니다 — Dispose() 호출만 잊지 않으면 됩니다.
7. 정리
이번 글의 핵심을 체크리스트로 정리합니다.
- [ ] GC는 메모리만 관리한다. 파일·소켓·DB 연결 같은 OS 자원은 직접 닫아야 한다.
- [ ]
IDisposable은 "정리할 게 있다"는 약속,using은 그 약속을 빠뜨리지 않게 강제하는 문법이다. - [ ]
using은 IL 레벨에서try-finally + null 체크 + IDisposable.Dispose() callvirt로 풀린다. 성능 페널티는 없다. - [ ] 다른
IDisposable만 들고 있는 클래스라면 단순IDisposable구현으로 충분하다.Dispose()한 줄에 위임만 하면 된다. - [ ] 비관리 자원(P/Invoke·네이티브 핸들)을 직접 들고 있을 때만 표준 Dispose 패턴(
Dispose(bool)+ finalizer +SuppressFinalize)을 쓴다. - [ ] Unity의
Texture2D·Mesh·Material은IDisposable이 아니다.Object.Destroy()로 해제한다. - [ ] 리소스는 만든 쪽이 닫는다. 메서드에 인자로 받은 stream을 함부로 Dispose하지 않는다.
- [ ] C# 8.0의
using선언으로 다중 리소스 코드를 평평하게 쓸 수 있다. IL은 블록형과 거의 같다. - [ ] 비동기 정리가 필요하면
await using+IAsyncDisposable을 찾아본다(후속 주제).
using과 IDisposable을 제대로 이해하면, "왜 갑자기 파일이 잠겼지?"·"왜 connection pool이 고갈됐지?" 같은 운영 장애의 80%는 코드를 짜는 단계에서 미리 막을 수 있습니다. GC가 만능이 아니라는 사실을 받아들이는 것이 첫걸음이고, 컴파일러가 만들어 주는 try-finally를 신뢰하는 것이 두 번째입니다.
'C# 기초' 카테고리의 다른 글
| [PART10.예외 처리 기본(7/9)] await using + IAsyncDisposable — 비동기 정리 (2) | 2026.05.05 |
|---|---|
| [PART10.예외 처리 기본(6/9)] using 선언 (C# 8) — 들여쓰기 없이 자원을 안전하게 풀어주는 법 (0) | 2026.05.05 |
| [PART10.예외 처리 기본(4/9)] 자주 만나는 예외 — 이름만이라도 기억 (0) | 2026.05.05 |
| [PART10.예외 처리 기본(3/9)] throw — 예외를 어떻게, 왜 그렇게 던져야 하는가 (2) | 2026.05.05 |
| [PART10.예외 처리 기본(2/9)] 예외 계층과 catch 순서 — 왜 구체적인 것을 위에, 일반적인 것을 아래에 두는가 (0) | 2026.05.05 |
