| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- Tween
- 게임개발
- 직장인공부
- 2D Camera
- Job 시스템
- 오공완
- 프레임워크
- job
- 환급챌린지
- 샘플
- Custom Package
- Dots
- TextMeshPro
- 최적화
- RSA
- adfit
- 직장인자기계발
- Unity Editor
- 패스트캠퍼스
- sha
- AES
- Framework
- unity
- C#
- base64
- DotsTween
- ui
- 가이드
- 암호화
- 패스트캠퍼스후기
- Today
- Total
EveryDay.DevUp
try-catch, finally와 using-dispose 본문
try-catch, finally와 using-dispose
예외가 터져도 리소스는 반드시 닫힌다 — 예외 처리와 리소스 해제의 내부 동작
Unity 게임에서 파일을 저장하다 예외가 나면? 서버와 연결하다 오류가 발생하면? 코드가 그냥 멈추면 파일은 잠긴 채로, 소켓은 열린 채로 남는다. try-catch-finally와 using-dispose는 "어떤 상황에서도 뒷정리를 보장하는" 두 가지 메커니즘이다. 이 글에서는 각각의 내부 동작과 IL 수준에서 컴파일러가 무엇을 생성하는지 살펴본다.
try-catch-finally
기초 개념
Unity에서 외부 API를 호출하거나 JSON 파일을 파싱할 때, 예상치 못한 예외가 터지면 그 시점 이후의 코드는 실행되지 않는다. 파일을 열었다면 닫아야 하고, UI 상태를 변경했다면 원래대로 되돌려야 한다. try-catch-finally는 그 뒷정리를 보장하는 제어 흐름 구조다.
비유: 게임의 일시정지 버튼을 생각해보자. 전투 중이든, 메뉴를 열었든, 예외 상황이든 ESC 키를 누르면 반드시 Pause 메뉴가 뜬다. finally 블록이 바로 그 역할이다 — 어떤 경로로 코드가 빠져나가든 반드시 실행된다.

// Unity에서 저장 파일을 다룰 때
static void SaveScore(int score, string path)
{
FileStream fs = null;
try
{
fs = new FileStream(path, FileMode.Create);
var data = BitConverter.GetBytes(score);
fs.Write(data, 0, data.Length);
Debug.Log("저장 완료");
}
catch (IOException ex)
{
Debug.LogError($"저장 실패: {ex.Message}");
}
finally
{
// 예외 발생 여부와 관계없이 반드시 실행됨
fs?.Dispose();
}
}
catch 없이 finally만 사용해도 된다. try-finally 조합은 "예외를 잡지 않아도 뒷정리는 반드시"가 필요할 때 쓴다.
동작 원리
CLR(Common Language Runtime, .NET 실행 환경)은 예외가 발생하면 스택 언와인딩(stack unwinding)을 수행한다. 호출 스택을 거슬러 올라가며 일치하는 catch 핸들러를 찾고, 그 과정에서 만나는 모든 finally 블록을 실행한다.
핵심은 return도 finally를 건너뛸 수 없다는 것이다. IL 수준에서 return은 try/catch 블록 안에서 leave.s 명령어로 변환되며, 이 명령어는 반드시 finally를 거쳐 나가게 설계되어 있다.
// try/catch 내부의 return이 finally를 건너뛰지 않음을 보여주는 예시
static int ReadScore(int[] scores, int index)
{
try
{
return scores[index]; // 정상 반환
}
catch (IndexOutOfRangeException)
{
return -1; // 예외 시 반환
}
finally
{
Console.WriteLine("finally: 항상 실행됨"); // 어느 경로든 실행됨
}
}
.method private hidebysig static
int32 ReadScore (int32[] scores, int32 index) cil managed
{
.locals init (
[0] int32 // 반환값을 임시 저장하는 로컬 변수
)
.try
{
.try
{
IL_0001: nop
IL_0002: ldarg.0 // scores 배열 로드
IL_0003: ldarg.1 // index 로드
IL_0004: ldelem.i4 // scores[index] 로드
IL_0005: stloc.0 // 반환값 임시 저장 (즉시 return이 아님!)
IL_0006: leave.s IL_001c // try 블록 탈출 → finally 반드시 거침
}
catch [System.Runtime]System.IndexOutOfRangeException
{
IL_0008: pop // 예외 객체 버림 (사용 안 함)
IL_0009: nop
IL_000a: ldc.i4.m1 // -1 로드
IL_000b: stloc.0 // 반환값 임시 저장
IL_000c: leave.s IL_001c // catch 블록 탈출 → finally 반드시 거침
}
}
finally
{
IL_000f: ldstr "finally: 항상 실행됨"
IL_0014: call void System.Console::WriteLine(string)
IL_001b: endfinally // finally 종료
}
IL_001c: ldloc.0 // 임시 저장했던 반환값 로드
IL_001d: ret // 진짜 return은 여기서
}
IL 분석 포인트:
1. stloc.0 + leave.s — return이 두 단계로 분리된다
try/catch 내부의 return scores[index]는 즉시 반환이 아니다. stloc.0으로 반환값을 로컬 변수에 저장하고, leave.s로 블록에서 탈출한다. leave.s는 반드시 finally를 실행시킨다. 실제 ret은 finally가 끝난 뒤 IL_001c에서 실행된다. Unity Update 루프에서 try 안에 return이 있어도 finally의 정리 코드가 빠지지 않는다는 보장이 여기서 나온다.
2. leave.s vs br.s — finally를 통과하는 분기
일반적인 분기 명령(br.s)과 달리 leave.s는 현재 보호 블록(try/catch)을 안전하게 벗어나는 전용 명령어다. CLR은 이 명령어를 만나면 해당 블록의 finally 핸들러를 반드시 실행한다.
3. 중첩 .try 구조 — catch와 finally의 분리
try-catch-finally가 IL에서는 .try { .try { } catch { } } finally { } 형태로 두 겹으로 컴파일된다. catch 블록과 finally 블록이 독립적으로 처리되는 구조다.
특징
특징 1: finally가 실행되지 않는 예외적 상황
"finally는 항상 실행된다"는 원칙에는 예외가 있다. Unity 개발에서 마주칠 수 있는 경우들이다.
// 실행되지 않는 finally — 알아두어야 할 상황들
static void ShowFinallyCaveats()
{
// 1. Environment.FailFast — 즉시 프로세스 강제 종료
try
{
Environment.FailFast("치명적 오류 — 복구 불가");
}
finally
{
Console.WriteLine("이 줄은 실행되지 않음"); // ← 실행 안 됨
}
// 2. StackOverflowException — 재귀 무한 호출
// 스택 공간이 없어 언와인딩 자체가 불가능
// void InfiniteRecursion() { InfiniteRecursion(); } 호출 시
// 3. 프로세스 강제 종료 (OS 킬, Task Manager)
// finally를 실행할 프로세스 자체가 사라짐
}
IL 레벨에서 특이사항 없음 — 런타임 강제 종료 시나리오이므로 IL 분석 생략.
특징 2: catch 없이 finally만 사용 가능
예외를 잡지 않고 정리만 보장하고 싶을 때는 catch 없이 try-finally를 쓴다. using 문의 내부 구현이 바로 이 패턴이다.
// catch 없는 try-finally — 예외는 상위로 전파, 정리만 보장
static void LoadMap(string path)
{
var file = File.OpenRead(path);
try
{
ParseMap(file);
// 예외 발생 시 catch 없음 → 상위로 전파
// 하지만 finally는 반드시 실행됨
}
finally
{
file.Dispose(); // 예외 전파 중에도 실행됨
}
}
IL 레벨에서 특이사항 없음 — try-finally 구조는 IL 분석 1(using)과 동일.
특징 3: 예외 필터 — when 절
C# 6에서 도입된 when 절은 특정 조건을 만족하는 예외만 catch한다. 중요한 특성은 스택 언와인딩 전에 필터를 평가한다는 것이다.
// 네트워크 오류를 코드로 분류해서 처리
static async Task FetchDataAsync(string url)
{
try
{
var result = await httpClient.GetAsync(url);
result.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// 404만 처리 — 스택 언와인딩 전에 조건 평가
Debug.LogWarning($"리소스 없음: {url}");
}
catch (HttpRequestException ex) when (ex.StatusCode >= System.Net.HttpStatusCode.InternalServerError)
{
// 5xx 서버 오류만 처리
Debug.LogError($"서버 오류: {ex.StatusCode}");
}
// 조건 불일치 시 — 해당 catch 건너뜀, 예외 계속 전파
}
when 필터는 조건을 먼저 평가하고 일치할 때만 스택 언와인딩을 시작한다. 조건 불일치 시 catch 블록을 건너뛰고 다음 catch나 상위 호출자로 예외가 전파된다. IL 레벨에서는 filter 블록으로 컴파일되며 catch 전에 평가된다.
장단점
✅ 장점 1: 예외가 발생해도 뒷정리 보장
// Unity에서 JSON 파싱 실패 시에도 UI 상태 복원
static void ParseAndApplyConfig(string json)
{
bool loadingShown = false;
try
{
ShowLoadingUI(); // UI 변경
loadingShown = true;
var config = JsonUtility.FromJson<GameConfig>(json);
ApplyConfig(config);
}
catch (Exception ex)
{
Debug.LogError($"설정 로드 실패: {ex.Message}");
ShowErrorMessage("설정을 불러올 수 없습니다.");
}
finally
{
if (loadingShown)
HideLoadingUI(); // 예외 여부와 무관하게 로딩 UI 숨김
}
}
IL 레벨에서 특이사항 없음 — try-catch-finally 기본 구조.
✅ 장점 2: 예외 타입별 세밀한 처리
// 예외 타입별 다른 복구 전략
static string LoadSaveFile(string path)
{
try
{
return File.ReadAllText(path);
}
catch (FileNotFoundException)
{
// 파일 없음 → 기본값 반환 (복구 가능)
return CreateDefaultSave();
}
catch (UnauthorizedAccessException)
{
// 권한 없음 → 다른 경로 시도
return LoadSaveFile(Path.GetTempPath() + "/backup.sav");
}
catch (IOException ex)
{
// 기타 IO 오류 → 로그 후 예외 재발생
Debug.LogError($"IO 오류: {ex.Message}");
throw; // 원본 스택 트레이스 보존
}
}
IL 레벨에서 특이사항 없음 — 다중 catch는 타입 매칭 체크를 순서대로 실행.
⚠️ 단점 1: 예외 발생 시 성능 비용이 크다
try 블록 자체의 진입 비용은 거의 없지만, 예외가 실제로 발생하면 수 마이크로초(μs)의 비용이 발생한다. CLR이 예외 객체(newobj)를 생성하고, 스택 트레이스를 캡처하고, catch 핸들러를 탐색하는 과정이 모두 비용이다.
// ❌ 예외를 제어 흐름으로 남용 — Unity Update에서 절대 금지
void Update()
{
try
{
var value = int.Parse(inputField.text); // 사용자 입력마다 예외 가능
ApplyValue(value);
}
catch (FormatException)
{
// 입력 실패를 예외로 처리 → 매 프레임 비용
}
}
// ✅ 예외 대신 TryParse 패턴 — 예외 없이 실패 처리
void Update()
{
if (int.TryParse(inputField.text, out int value))
{
ApplyValue(value);
}
// 실패 시 예외 없음 → 비용 없음
}
IL 레벨에서 특이사항 없음 —TryParse는 예외를 던지지 않고bool을 반환.
주로 실수하는 부분
❌ 실수 1: throw ex vs throw — 스택 트레이스 소실
가장 흔하고 디버깅을 어렵게 만드는 실수다. 예외를 다시 던질 때 throw ex를 쓰면 원본 스택 트레이스가 사라진다.
// ❌ 잘못된 패턴 — 스택 트레이스 리셋
static void BadRethrow()
{
try
{
throw new InvalidOperationException("원본");
}
catch (InvalidOperationException ex)
{
LogError(ex);
throw ex; // ← 여기서 새 예외를 던지는 것처럼 처리됨
}
}
// ✅ 올바른 패턴 — 원본 스택 트레이스 보존
static void GoodRethrow()
{
try
{
throw new InvalidOperationException("원본");
}
catch (InvalidOperationException ex)
{
LogError(ex);
throw; // ← 원본 예외를 그대로 전파
}
}
// ❌ throw ex — IL
catch [System.Runtime]System.InvalidOperationException
{
IL_000d: stloc.0 // ex 변수에 예외 저장
IL_000e: nop
IL_000f: ldloc.0 // ex 변수 로드
IL_0010: throw // ← 새 throw! 스택 트레이스가 여기서부터 시작됨
}
// ✅ throw — IL
catch [System.Runtime]System.InvalidOperationException
{
IL_000d: pop // 예외 객체를 스택에서 버림 (변수에 저장 안 함)
IL_000e: nop
IL_000f: rethrow // ← 원본 예외 그대로 재발생 (스택 트레이스 보존!)
}
IL 분석 포인트:
1. throw vs rethrow — 단 하나의 명령어 차이
throw ex는 ldloc.0(예외 객체 로드) + throw(새 예외 발생) 두 단계다. CLR은 이를 완전히 새로운 예외로 처리하며 스택 트레이스를 현재 위치부터 다시 기록한다. 반면 rethrow는 단일 명령어로 원본 예외 객체를 그대로 전파한다 — 스택 트레이스가 변경되지 않는다.
2. pop — 예외 변수 없이 catch
throw(단독)는 catch 블록에서 예외 변수 없이 쓸 수 있다. 이 경우 IL은 pop으로 스택의 예외 객체를 버리고, rethrow로 원본을 전파한다. 컴파일러 경고 CA2200이 throw ex를 사용할 때 발생하는 이유이기도 하다.
❌ 실수 2: catch-all로 모든 예외를 삼키기
// ❌ 나쁜 패턴 — 예외를 완전히 숨김
void LoadData()
{
try
{
var config = File.ReadAllText("config.json");
ApplyConfig(config);
}
catch { } // 무슨 예외인지, 발생했는지조차 알 수 없음
}
// ✅ 최소한 로깅은 해야 함
void LoadData()
{
try
{
var config = File.ReadAllText("config.json");
ApplyConfig(config);
}
catch (Exception ex)
{
Debug.LogError($"[LoadData] 예외 발생: {ex}"); // 전체 스택 트레이스 포함
// 복구 불가능하다면 다시 throw
}
}
IL 레벨에서 특이사항 없음 — 빈 catch 블록의 문제는 논리적 패턴.
❌ 실수 3: Unity Update에서 예외를 정상 흐름으로 사용
// ❌ Update에서 예외 남용 — 매 프레임 비용 발생 가능
void Update()
{
try
{
ProcessEnemies(); // 내부에서 IndexOutOfRangeException 가능
}
catch (IndexOutOfRangeException)
{
// 이 catch가 매 프레임 발생한다면 심각한 성능 문제
}
}
// ✅ 예외 발생 자체를 막는 방어적 코딩
void Update()
{
if (enemies.Count == 0) return; // 조건 먼저 확인
if (currentIndex >= enemies.Count) currentIndex = 0;
ProcessEnemies();
}
IL 레벨에서 특이사항 없음 — 성능 비용은 런타임 예외 발생 빈도에 따름.
C# 버전별 개선점
C# 6 — 예외 필터 (when 절)
// C# 6 이전 — catch 안에서 조건 체크 후 re-throw
try { /* ... */ }
catch (HttpRequestException ex)
{
if (ex.StatusCode != System.Net.HttpStatusCode.NotFound)
throw; // 조건 불일치 시 re-throw
HandleNotFound();
}
// C# 6 이후 — when 절로 조건을 catch 밖에서 평가
try { /* ... */ }
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
HandleNotFound(); // 스택 트레이스 보존 + 코드 명확
}
차이점: when 절의 조건은 스택 언와인딩 전에 평가된다. C# 6 이전 방식처럼 catch 안에서 re-throw를 쓰면 스택이 이미 언와인딩된 상태라 디버거에서 원본 예외 발생 지점 확인이 어렵다.
C# 7 — throw 표현식
// C# 7 이전 — throw를 구문(statement)으로만 사용
private string _name;
public string Name
{
get { return _name; }
set
{
if (value == null) throw new ArgumentNullException(nameof(value));
_name = value;
}
}
// C# 7 이후 — throw를 표현식(expression)으로 사용
private string _name;
public string Name
{
get => _name;
set => _name = value ?? throw new ArgumentNullException(nameof(value));
// ↑ 삼항 연산자 안에서 throw 가능
}
// null 병합 할당에서도 사용 가능
string GetConfig(string key) =>
_config.TryGetValue(key, out var val)
? val
: throw new KeyNotFoundException($"설정 키 없음: {key}");
IL 레벨에서 특이사항 없음 — throw 표현식은 기존 throw 명령어와 동일한 IL을 생성.
using과 IDisposable
기초 개념
GC(Garbage Collector, 힙 메모리를 자동으로 회수하는 런타임 구성요소)는 C# 객체의 메모리를 알아서 회수하지만, 비관리 리소스(unmanaged resources)는 다르다. 파일 핸들, 네트워크 소켓, DB 연결, 네이티브 메모리 — 이것들은 GC가 존재 자체를 모른다. 개발자가 직접 해제하지 않으면 프로세스가 종료될 때까지 점유된 채로 남는다.
비유: 도서관에서 책을 빌리면 반납해야 한다. GC는 책상 위의 쓰레기(메모리)는 치워주지만, 빌린 책(비관리 리소스)은 당신이 직접 반납해야 한다. IDisposable.Dispose()가 그 반납 창구다.

IDisposable 인터페이스는 단 하나의 메서드를 정의한다. 클래스가 이 인터페이스를 구현한다는 것은 "나는 직접 해제해야 할 리소스를 가지고 있다"는 계약이다.
// IDisposable 인터페이스 정의 (BCL)
public interface IDisposable
{
void Dispose(); // 리소스를 해제하는 메서드
}
// IDisposable을 구현하는 타입은 반드시 using으로 사용
using (var stream = new FileStream("score.dat", FileMode.Open))
{
// stream 사용
} // ← 블록을 벗어나는 순간 stream.Dispose() 자동 호출
동작 원리
using 문이 try-finally로 컴파일되는 방식
using 문은 문법적 설탕(syntactic sugar)이다. 컴파일러가 try-finally로 변환해준다.
// 개발자가 작성하는 코드
static void UseStream()
{
using (var stream = new MemoryStream())
{
stream.WriteByte(42);
}
}
.method private hidebysig static
void UseStream() cil managed
{
.locals init (
[0] class [System.Runtime]System.IO.MemoryStream // stream 로컬 변수
)
IL_0001: newobj instance void [System.Runtime]System.IO.MemoryStream::.ctor()
IL_0006: stloc.0 // stream = new MemoryStream()
.try // ← 컴파일러가 자동으로 삽입한 try 블록
{
IL_0008: ldloc.0
IL_0009: ldc.i4.s 42
IL_000b: callvirt instance void Stream::WriteByte(uint8)
IL_0012: leave.s IL_001f // try 블록 정상 탈출
}
finally // ← 컴파일러가 자동으로 삽입한 finally 블록
{
IL_0014: ldloc.0 // stream 로드
IL_0015: brfalse.s IL_001e // null이면 건너뜀 (null 체크 자동 포함!)
IL_0017: ldloc.0
IL_0018: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_001e: endfinally
}
IL_001f: ret
}
IL 분석 포인트:
1. brfalse.s — null 체크가 자동으로 포함된다
컴파일러는 using 블록 생성 시 null 체크(brfalse.s)를 자동으로 삽입한다. stream이 null이면 Dispose() 호출을 건너뛴다. 개발자가 직접 null 체크를 작성하지 않아도 안전하다.
2. callvirt — IDisposable.Dispose() 가상 호출
Dispose() 호출은 callvirt instance void IDisposable::Dispose()로 컴파일된다. 인터페이스를 통한 가상 디스패치로 실제 구현 타입의 Dispose가 호출된다.
3. newobj — MemoryStream은 힙에 생성
newobj로 MemoryStream이 힙에 생성된다. using으로 명시적 해제를 하지 않으면 GC가 수집할 때까지 힙에 남는다. Unity에서 GC 스파이크(garbage collection spike, GC가 동작하면서 발생하는 프레임 드랍)를 줄이려면 using으로 빠르게 해제하는 것이 중요하다.
Dispose(bool) 패턴 — 표준 구현 방법
using System;
using System.IO;
using System.Runtime.InteropServices;
// Microsoft 권장 IDisposable 구현 패턴
public class ResourceHolder : IDisposable
{
private Stream _managedStream; // 관리 리소스 (다른 IDisposable)
private IntPtr _nativeHandle; // 비관리 리소스 (P/Invoke 핸들)
private bool _disposed = false;
public ResourceHolder(Stream stream)
{
_managedStream = stream;
_nativeHandle = Marshal.AllocHGlobal(256); // 가상의 비관리 메모리
}
// ① 외부에서 호출 — using 또는 명시적 Dispose()
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // finalizer 억제
}
// ② 핵심 해제 로직 — disposing 플래그로 관리/비관리 구분
protected virtual void Dispose(bool disposing)
{
if (_disposed) return; // 중복 호출 방지
if (disposing)
{
// disposing == true: Dispose()에서 호출
// 관리 리소스(다른 IDisposable)는 여기서만 해제
_managedStream?.Dispose();
}
// 비관리 리소스는 항상 해제 (finalizer에서도 호출됨)
if (_nativeHandle != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativeHandle);
_nativeHandle = IntPtr.Zero;
}
_disposed = true;
}
// ③ finalizer — GC 수집 시 마지막 안전망
~ResourceHolder()
{
Dispose(disposing: false); // GC 호출 시 관리 리소스 접근 불가
}
}
// Dispose() — 외부 호출용
.method public final hidebysig newslot virtual
instance void Dispose() cil managed
{
IL_0001: ldarg.0
IL_0002: ldc.i4.1 // disposing = true
IL_0003: callvirt instance void ResourceHolder::Dispose(bool)
IL_0009: ldarg.0
IL_000a: call void [System.Runtime]System.GC::SuppressFinalize(object)
IL_0010: ret
}
// Dispose(bool) — 핵심 해제 로직
.method family hidebysig newslot virtual
instance void Dispose(bool disposing) cil managed
{
.locals init ([0] bool, [1] bool)
IL_0002: ldfld bool ResourceHolder::_disposed // _disposed 필드 로드
IL_0009: brfalse.s IL_000d // false면(아직 안 됨) 계속 진행
IL_000b: br.s IL_001b // true면(이미 됨) 즉시 return
IL_000d: ldarg.1 // disposing 인수 로드
IL_000e: stloc.1
IL_000f: ldloc.1
IL_0010: brfalse.s IL_0014 // false면(finalizer 호출) 건너뜀
IL_0012: nop // ← disposing==true 분기 내부
IL_0013: nop // (실제 코드에서는 _managedStream?.Dispose() 등)
IL_0014: ldarg.0
IL_0015: ldc.i4.1
IL_0016: stfld bool ResourceHolder::_disposed // _disposed = true
IL_001b: ret
}
// Finalize — GC 수집 시 호출
.method family hidebysig virtual
instance void Finalize() cil managed
{
.try
{
IL_0002: ldarg.0
IL_0003: ldc.i4.0 // disposing = false (비관리만 해제)
IL_0004: callvirt instance void ResourceHolder::Dispose(bool)
IL_000a: leave.s IL_0014
}
finally
{
IL_000c: ldarg.0
IL_000d: call instance void [System.Runtime]System.Object::Finalize()
IL_0013: endfinally // base.Finalize() 항상 호출
}
IL_0014: ret
}
IL 분석 포인트:
1. GC::SuppressFinalize — finalizer 큐에서 제거
Dispose()를 직접 호출하면 GC.SuppressFinalize(this)로 finalizer 큐에서 제거된다. finalizer가 있는 객체는 GC가 두 단계(finalization → 수집)로 처리하는데, SuppressFinalize는 첫 단계를 건너뛰게 한다. Unity에서 비관리 리소스를 가진 객체를 빨리 해제하면 GC 스파이크가 줄어드는 이유다.
2. brfalse.s IL_001b — 중복 Dispose 방지
_disposed 필드를 확인해서 이미 해제된 경우 즉시 return한다. using 안에서 실수로 Dispose()를 두 번 호출해도 안전하다.
3. Finalizer의 Dispose(disposing: false) — 관리 리소스 접근 불가
finalizer는 GC가 임의의 순서로 호출하기 때문에, 이 시점에 _managedStream 등의 관리 객체가 이미 수집됐을 수 있다. disposing == false일 때 관리 리소스 접근을 건너뛰는 이유다.
특징
특징 1: Dispose에서 예외가 발생하면 원본 예외가 소실된다
이것이 가장 위험한 함정이다. try 블록에서 예외가 발생하고, 그 직후 finally(=using)에서 Dispose가 예외를 던지면 원본 예외가 사라진다.
using System;
using System.IO;
using UnityEngine;
// Dispose에서 예외를 던지는 타입 (잘못된 구현 예시)
public class ThrowingDisposable : IDisposable
{
public void Dispose() => throw new IOException("Dispose 중 오류!");
}
// 위험한 시나리오 — 원본 예외 소실
static void DangerousDispose()
{
try
{
using var resource = new ThrowingDisposable(); // Dispose에서 예외 발생
throw new InvalidOperationException("원본 예외"); // 예외 1
// finally에서 resource.Dispose() 호출 → 예외 2(IOException) 발생
// 결과: 예외 1(InvalidOperationException)은 소실, 예외 2만 전파됨!
}
catch (Exception ex)
{
// ex는 Dispose의 IOException — 원본 InvalidOperationException은 사라짐
Debug.LogError($"받은 예외: {ex.GetType().Name}"); // "IOException"
}
}
// 해결책: Dispose에서는 예외를 삼켜야 한다
public class SafeResource : IDisposable
{
private Stream _stream;
public SafeResource(Stream stream) { _stream = stream; }
public void Dispose()
{
try
{
_stream?.Flush();
_stream?.Dispose();
}
catch (Exception ex)
{
// 로그는 남기되, 절대 throw 금지
Debug.LogWarning($"Dispose 중 오류: {ex.Message}");
}
}
}
IL 레벨에서 특이사항 없음 — 두 예외 충돌은 런타임 예외 전파 동작.
특징 2: struct IDisposable — using은 박싱 없음, 명시적 캐스팅은 박싱 발생
"struct를 IDisposable 인터페이스로 캐스팅하면 박싱이 발생한다"는 말은 맞다. 하지만 using 문은 박싱을 발생시키지 않는다. 컴파일러가 constrained. 접두사를 사용해 박싱을 우회하기 때문이다. 실제 IL 분석으로 확인한다.
using System;
using System.Runtime.InteropServices;
public struct StructResource : IDisposable
{
private IntPtr _ptr;
public StructResource(int size) { _ptr = Marshal.AllocHGlobal(size); }
public void Dispose()
{
if (_ptr != IntPtr.Zero) { Marshal.FreeHGlobal(_ptr); _ptr = IntPtr.Zero; }
}
}
// 케이스 1: using으로 struct 사용
static void UseStruct()
{
using (var r = new StructResource(64))
{
Console.WriteLine("struct 사용");
}
}
// 케이스 2: struct를 IDisposable로 명시적 캐스팅
static void CastStructToInterface()
{
var r = new StructResource(64);
IDisposable d = r; // ← 명시적 인터페이스 캐스팅
d.Dispose();
}
// ✅ 케이스 1: using (var r = new StructResource(64))
.locals init (
[0] valuetype StructResource // struct → 스택 할당 (valuetype)
)
IL_0001: ldloca.s 0 // 스택 주소 로드 (newobj 없음!)
IL_0005: call instance void StructResource::.ctor(int32) // call (힙 할당 없음)
.try
{
IL_000b: ldstr "struct 사용"
IL_0010: call void Console::WriteLine(string)
IL_0017: leave.s IL_0028
}
finally
{
IL_0019: ldloca.s 0 // 스택 주소 로드 (box 없음!)
IL_001b: constrained. StructResource // ← 핵심: constrained 접두사
IL_0021: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
// ↑ callvirt이지만 constrained.가 박싱을 막는다
IL_0027: endfinally
}
// 주목: brfalse.s(null 체크) 없음 — struct는 null이 될 수 없어서
// ❌ 케이스 2: IDisposable d = r (명시적 캐스팅)
.locals init (
[0] valuetype StructResource,
[1] class [System.Runtime]System.IDisposable // 참조 타입 변수
)
IL_000a: ldloc.0
IL_000b: box StructResource // ← 박싱 발생! 힙에 복사본 생성
IL_0010: stloc.1 // IDisposable 변수에 저장
IL_0012: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL 분석 포인트:
1. constrained. StructResource — using이 박싱을 피하는 방법
CLR의 constrained. 접두사는 "이 타입이 값 타입이면 박싱 없이 직접 메서드를 호출하라"는 지시다. 컴파일러는 using 문에서 struct를 감지하면 자동으로 이 접두사를 삽입한다. 결과적으로 callvirt 형태이지만 박싱은 발생하지 않는다.
2. box StructResource — 명시적 인터페이스 캐스팅은 박싱 발생
IDisposable d = r처럼 값 타입을 인터페이스 변수에 대입하면 box 명령어가 실행된다. 힙에 복사본이 생성되고 그 참조가 d에 저장된다. 이후 Dispose()를 호출하면 힙의 복사본에서 실행된다 — 원본 struct와 별개의 객체다.
3. valuetype 로컬 vs class 로컬 — 생성 방식의 차이
struct는 .locals init에서 valuetype StructResource로 선언되어 스택에 공간을 잡는다. 생성자는 ldloca.s(스택 주소)와 call .ctor로 호출된다 — newobj(힙 할당)가 없다. class는 newobj로 힙에 객체를 생성하고 참조를 반환한다.
4. brfalse.s (null 체크) 없음
struct는 null이 될 수 없으므로 컴파일러가 null 체크를 생략한다. class using에 있는 brfalse.s IL_0022 (null이면 Dispose 건너뜀) 코드가 struct에는 없다.
요약: using 문은 struct를 박싱하지 않는다 (constrained.)
IDisposable 변수에 대입하면 박싱 발생 (box)
생성 자체는 struct가 스택 할당 → 힙 할당 없음
장단점
✅ 장점 1: 결정론적 해제 — GC 스파이크 감소
// ❌ Dispose 없이 GC 의존 — Unity에서 GC 스파이크 유발
void LoadTextureWithoutDispose(string url)
{
var request = UnityWebRequestTexture.GetTexture(url); // IDisposable
StartCoroutine(SendRequest(request));
// request.Dispose() 없음 → GC가 수집할 때까지 리소스 점유
// GC 수집 시 finalizer → 두 단계 처리 → 스파이크
}
// ✅ using으로 즉시 해제 — GC 부담 없음
IEnumerator LoadTextureSafe(string url)
{
using (var request = UnityWebRequestTexture.GetTexture(url))
{
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
var texture = DownloadHandlerTexture.GetContent(request);
ApplyTexture(texture);
}
}
// request.Dispose() 자동 호출 — 즉시 해제, GC 대기 없음
}
IL 레벨에서 특이사항 없음 — using의 try-finally 구조는 IL 분석 1과 동일.
⚠️ 단점: using 선언 사용 시 조기 해제가 불가능
// ⚠️ using 선언 — 메서드 끝까지 리소스 유지
void ProcessFiles(string[] paths)
{
using var firstFile = new StreamReader(paths[0]); // 메서드 전체 수명
DoHeavyWork(); // firstFile이 필요 없는 긴 작업 중에도 점유
// firstFile은 메서드 끝에서야 해제됨
}
// ✅ 조기 해제가 필요하면 using 문(블록 방식) 사용
void ProcessFiles(string[] paths)
{
using (var firstFile = new StreamReader(paths[0]))
{
var content = firstFile.ReadToEnd();
PrepareData(content);
} // ← 여기서 즉시 해제
DoHeavyWork(); // 이제 firstFile 해제됨
}
IL 레벨에서 특이사항 없음 — 수명 범위의 차이는 컴파일된 try-finally 위치가 다름.
주로 실수하는 부분
❌ 실수 1: Dispose 후 사용 — ObjectDisposedException
// ❌ 잘못된 패턴 — using 후 참조 유지
StreamReader _reader;
void BadPattern()
{
using var reader = new StreamReader("data.txt");
_reader = reader; // 외부에 참조 저장
ProcessData(reader.ReadToEnd());
} // reader.Dispose() 호출됨
void LaterMethod()
{
var data = _reader.ReadToEnd(); // ObjectDisposedException!
}
// ✅ 데이터를 먼저 읽고 저장
string _data;
void GoodPattern()
{
using var reader = new StreamReader("data.txt");
_data = reader.ReadToEnd(); // 데이터를 먼저 읽어 저장
} // reader.Dispose() — 이제 _data에는 string이 남아있음
void LaterMethod()
{
ProcessData(_data); // 안전
}
IL 레벨에서 특이사항 없음 — ObjectDisposedException은 런타임 예외.
❌ 실수 2: HttpClient를 매번 생성/해제 — 소켓 고갈
Unity 게임에서 서버 API를 호출할 때 흔히 발생하는 실수다. HttpClient는 IDisposable을 구현하지만 매번 생성/해제하면 소켓 고갈(socket exhaustion)이 발생한다.
// ❌ 매번 생성 — 소켓 고갈 유발
public class ApiManager : MonoBehaviour
{
public async Task<string> GetLeaderboardAsync()
{
using var client = new HttpClient(); // 매번 새 소켓
return await client.GetStringAsync("https://api.game.com/leaderboard");
// Dispose 후에도 TIME_WAIT 상태로 소켓이 남음
}
}
// ✅ 싱글턴 재사용 — HttpClient는 스레드 안전하고 재사용을 권장
public class ApiManager : MonoBehaviour
{
// static 싱글턴 — 게임 전체에서 하나만 사용
private static readonly HttpClient _client = new HttpClient();
public async Task<string> GetLeaderboardAsync()
{
return await _client.GetStringAsync("https://api.game.com/leaderboard");
}
}
IL 레벨에서 특이사항 없음 — 소켓 고갈은 OS 수준의 문제.
❌ 실수 3: NativeArray를 Dispose 안 하면 메모리 누수
Unity C# Job System에서 NativeArray를 생성했는데 Dispose를 빠뜨리면 비관리 메모리가 누수된다. GC는 이것을 모른다.
// ❌ NativeArray Dispose 누락 — 메모리 누수
using Unity.Collections;
public class EnemySystem : MonoBehaviour
{
void SpawnEnemies(int count)
{
var positions = new NativeArray<UnityEngine.Vector3>(count, Allocator.TempJob);
// 작업...
ScheduleJobs(positions);
// positions.Dispose() 빠짐 → TempJob 할당자이지만 명시적 해제 필요
// Unity Editor에서 "A Native Collection has not been disposed" 경고 발생
}
}
// ✅ using으로 자동 해제
public class EnemySystem : MonoBehaviour
{
void SpawnEnemies(int count)
{
using var positions = new NativeArray<UnityEngine.Vector3>(count, Allocator.TempJob);
var handle = ScheduleJobs(positions);
handle.Complete(); // 잡 완료 후
} // positions.Dispose() 자동 호출
}
IL 레벨에서 특이사항 없음 — NativeArray.Dispose()는 내부적으로 비관리 메모리 해제.
❌ 실수 4: MonoBehaviour 필드에 using 사용 불가
using var은 지역 변수(local variable)에만 사용할 수 있다. MonoBehaviour의 필드로 선언한 IDisposable은 수동으로 관리해야 한다.
// ❌ 컴파일 에러 — using은 지역 변수에만
public class NetworkManager : MonoBehaviour
{
using var _client = new TcpClient(); // 컴파일 에러!
}
// ✅ 필드는 OnDestroy에서 수동 해제
public class NetworkManager : MonoBehaviour
{
private TcpClient _client;
private NetworkStream _stream;
void Start()
{
_client = new TcpClient();
_client.Connect("game-server.com", 7777);
_stream = _client.GetStream();
}
void OnDestroy()
{
// MonoBehaviour 소멸 시 반드시 수동 해제
_stream?.Dispose();
_client?.Dispose();
}
}
IL 레벨에서 특이사항 없음 — 수동 Dispose는 일반 메서드 호출.
C# 버전별 개선점
C# 8 — using 선언 (블록 없이 스코프 기반)
// C# 7 이하 — 중괄호 필수, 중첩 시 들여쓰기 깊어짐
static void ProcessOld(string inputPath, string outputPath)
{
using (var reader = new StreamReader(inputPath))
{
using (var writer = new StreamWriter(outputPath))
{
string line;
while ((line = reader.ReadLine()) != null)
writer.WriteLine(line.ToUpper());
} // writer.Dispose()
} // reader.Dispose()
}
// C# 8 이후 — 블록 없음, 메서드 끝에서 역순 해제
static void ProcessNew(string inputPath, string outputPath)
{
using var reader = new StreamReader(inputPath);
using var writer = new StreamWriter(outputPath);
string? line;
while ((line = reader.ReadLine()) != null)
writer.WriteLine(line.ToUpper());
} // writer.Dispose() → reader.Dispose() (역순!)
// using 선언 → 중첩 try-finally로 컴파일됨
.method private hidebysig static
void UseStreamDeclaration(string path) cil managed
{
.locals init (
[0] class [System.Runtime]System.IO.StreamReader, // reader
[1] class [System.Runtime]System.IO.StreamWriter, // writer
[2] string // line
)
IL_0001: newobj instance void StreamReader::.ctor(string)
IL_0007: stloc.0 // reader = new StreamReader(...)
.try // ← reader를 위한 외부 try
{
// writer 생성
IL_0013: newobj instance void StreamWriter::.ctor(string)
IL_0018: stloc.1 // writer = new StreamWriter(...)
.try // ← writer를 위한 내부 try
{
// ... 루프 작업 ...
IL_0034: leave.s IL_004c
}
finally // ← writer Dispose (먼저!)
{
IL_0036: ldloc.1
IL_0037: brfalse.s IL_0040
IL_0039: ldloc.1
IL_003a: callvirt instance void IDisposable::Dispose()
IL_0040: endfinally
}
}
finally // ← reader Dispose (나중!)
{
IL_0041: ldloc.0
IL_0042: brfalse.s IL_004b
IL_0044: ldloc.0
IL_0045: callvirt instance void IDisposable::Dispose()
IL_004b: endfinally
}
IL_004c: ret
}
IL 분석 포인트:
1. 중첩 try-finally — 선언 순서와 반대로 Dispose
using var reader, using var writer 순서로 선언했지만, Dispose는 writer → reader 역순이다. IL을 보면 writer가 내부 try-finally에, reader가 외부 try-finally에 감싸진다. 내부 finally가 먼저 실행되므로 자연스럽게 역순이 된다. 리소스 A가 리소스 B에 의존할 때 선언 순서만 맞추면 안전하게 해제된다.
2. 각각 독립적인 null 체크
writer와 reader 각각에 brfalse.s null 체크가 포함된다. 어느 하나가 null이어도 나머지가 안전하게 Dispose된다.
C# 8 — IAsyncDisposable + await using
비동기 I/O 처리 시 Dispose()에서 .GetAwaiter().GetResult()를 쓰면 데드락 위험이 있다. C# 8의 IAsyncDisposable이 이 문제를 해결한다.
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading.Tasks;
// C# 8 이전 — 비동기 Dispose의 문제
public class BadAsyncResource : IDisposable
{
private readonly NetworkStream _stream;
public BadAsyncResource(NetworkStream stream) { _stream = stream; }
public void Dispose()
{
_stream.FlushAsync().GetAwaiter().GetResult(); // 데드락 위험!
_stream.Dispose();
}
}
// C# 8 이후 — IAsyncDisposable로 안전한 비동기 해제
public class GoodAsyncResource : IAsyncDisposable
{
private readonly NetworkStream _stream;
public GoodAsyncResource(NetworkStream stream) { _stream = stream; }
public async ValueTask DisposeAsync()
{
await _stream.FlushAsync(); // 비동기로 flush
await _stream.DisposeAsync(); // 비동기로 해제
}
public async Task ProcessAsync()
{
var buffer = new byte[1024];
var read = await _stream.ReadAsync(buffer, 0, buffer.Length);
Console.WriteLine($"수신: {read}바이트");
}
}
// await using으로 사용
static async Task UseResourceAsync(NetworkStream stream)
{
await using var resource = new GoodAsyncResource(stream);
await resource.ProcessAsync();
} // DisposeAsync() 자동 await 호출
await using은 컴파일러가 try-finally로 변환하되 finally 안에서await resource.DisposeAsync()를 호출한다.ValueTask를 반환해서 동기 완료 시 힙 할당이 없다. IL 레벨에서는 async 상태 기계(state machine)가 생성된다.
핵심 요약 체크리스트
try-catch-finally
├─ finally는 return, 예외, 정상 완료 어느 경로에서도 실행됨
│ (FailFast, StackOverflow, 프로세스 강제 종료 제외)
├─ throw → rethrow IL → 원본 스택 트레이스 보존
│ throw ex → throw IL → 스택 트레이스 리셋 (사용 금지)
├─ 예외 발생 자체가 비싸다 — TryParse 등 예외 없는 패턴 우선
└─ when 필터는 스택 언와인딩 전에 평가됨 (C# 6+)
IDisposable + using
├─ using 문 = try { } finally { if(x!=null) x.Dispose(); } 자동 생성
├─ null 체크 포함 — 개발자가 신경 쓸 필요 없음
├─ Dispose에서 예외 던지기 금지 — 원본 예외 소실
├─ GC.SuppressFinalize(this) — finalizer 억제로 GC 스파이크 감소
└─ C# 8 using 선언: 역순 Dispose, 중첩 try-finally 자동 생성
🎮 Unity 실전
├─ NativeArray: Allocator.Persistent는 OnDestroy에서 반드시 Dispose
├─ UnityWebRequest: 코루틴 내부에서도 using으로 감싸기
├─ HttpClient: static 싱글턴으로 재사용 (매번 생성 금지)
└─ MonoBehaviour 필드: using 불가 → OnDestroy에서 수동 해제
