[PART10.예외 처리 기본(1/9)] try · catch · finally — 예외가 터져도 무너지지 않는 코드
위험한 구간을 try로 감싸고, 잡고 싶은 예외를 catch로 받고, 정리는 finally에 맡긴다 — 세 키워드의 역할 분담과 CLR 내부 동작
목차
1. 문제 제기 — 게임이 강제 종료되는 한 줄
플레이어 데이터를 디스크에서 불러오는 코드를 떠올려 보자. 파일이 손상되었거나, 사용자가 SD 카드를 빼버렸거나, 권한이 없거나 — 어떤 이유로든 File.ReadAllText는 실패할 수 있다. 그리고 실패하면 예외(Exception, 정상 흐름을 벗어나는 오류 신호)가 던져진다.
// Unity의 어느 매니저 컴포넌트
void LoadPlayerSave()
{
string json = File.ReadAllText(savePath); // 파일이 없으면 FileNotFoundException
playerData = JsonUtility.FromJson<PlayerData>(json);
Debug.Log("로드 성공");
}
이 코드의 문제는 두 가지다.
- 앱이 죽는다. 예외를 아무도 잡지 않으면 호출 스택을 거슬러 올라가다 결국 Unity 엔진까지 전파된다. 모바일이라면 그대로 강제 종료(Force Close).
- 정리가 안 된다. 만약
File.ReadAllText대신FileStream을 직접 열고 있었다면, 예외 시점에 파일 핸들이 열린 채로 남는다. 다음에 같은 파일에 쓰려 하면 또 다른 예외가 터진다.
게임은 "오류가 안 생기는 코드"를 짜는 것이 아니라 "오류가 생겨도 무너지지 않는 코드"를 짜는 일이다. C#은 이를 위해 try, catch, finally라는 세 키워드를 제공한다. 각 키워드가 정확히 어떤 책임을 지는지 — 그리고 CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)이 내부에서 어떻게 처리하는지를 이해해야, 어떤 상황에 어떤 키워드를 써야 할지 판단할 수 있다.
2. 개념 정의 — 보호 구역, 응급실, 청소부
세 키워드의 역할 비유
소방 훈련에 비유하면 셋의 역할이 명확해진다.
try블록 = 위험 구역. "여기서부터 여기까지는 불이 날 수 있는 곳"이라고 표시한 영역.catch블록 = 응급실. 불이 났을 때 어떤 종류의 불(전기, 기름, 일반)인지에 따라 다르게 대응한다.finally블록 = 청소부. 불이 났든 안 났든, 누군가 도망갔든, 마지막에 들어가 가스 밸브를 잠그고 문을 닫는다.
구조와 흐름

가장 단순한 형태
using System;
using System.IO;
public class SaveLoader
{
public static string LoadOrDefault(string path)
{
try
{
return File.ReadAllText(path); // 위험 구역
}
catch (FileNotFoundException)
{
return "{}"; // 응급 처치 — 빈 JSON 반환
}
finally
{
Console.WriteLine("로드 시도 종료"); // 청소부 — 항상 실행
}
}
}
path가 존재하면 → try 본문 실행 → finally 실행 → 파일 내용 반환. path가 없으면 → try 도중 예외 → catch 진입 → finally 실행 → "{}" 반환.
쉽게 말해 try는 "감시 대상"을 표시하고, catch는 "발생한 예외에 대한 처리"를, finally는 "어떤 경우에도 마지막에 해야 할 일"을 담는다. 정확한 정의는 다음과 같다:
try: 예외 처리 대상이 되는 코드 블록을 정의한다. JIT(Just-In-Time, 실행 직전 IL을 기계어로 변환) 컴파일러는 이 범위를 EH 테이블(Exception Handling Table, 예외 처리 영역과 핸들러 위치를 매핑한 표)에 기록한다.catch (TException):try블록에서 던져진 예외 중TException또는 그 파생 타입에 일치하는 것을 처리한다.finally:try블록을 어떤 방식으로든 빠져나갈 때(정상 종료, 예외 전파,return,break) 반드시 실행되는 정리 블록이다.
3. 내부 동작 — 두 단계 예외 처리(Two-Pass)
CLR이 예외를 처리하는 두 번의 패스
C#의 throw는 단순한 goto가 아니다. CLR은 2-패스(Two-Pass) 모델로 예외를 처리한다.

핵심은 finally가 실행되는 시점이 2패스라는 점이다. 1패스에서 일치하는 catch를 찾지 못하면 finally도 실행되지 않을 수 있다(미처리 예외 시 CLR 정책에 따라 다름). 일치하는 catch를 찾았다면, 2패스에서 예외 발생 지점부터 그 catch까지 사이의 모든 finally를 순서대로 실행한 뒤 catch 본문으로 진입한다.
IL 레벨에서 본 try/catch/finally
가장 단순한 형태의 IL을 보면 구조가 더 명확해진다. 다음 C# 코드를:
using System;
public class Program {
public static int Demo() {
try {
return 1;
}
catch (DivideByZeroException) {
return 2;
}
finally {
Console.WriteLine("finally");
}
}
}
컴파일러는 이렇게 변환한다.
.method public hidebysig static int32 Demo () cil managed
{
.maxstack 1
.locals init (
[0] int32 // return 값을 임시 저장
)
.try
{
.try // ← 외부 try가 finally를 감싸고
{ // 내부 try가 catch를 감싼다
IL_0000: ldc.i4.1 // 1을 스택에
IL_0001: stloc.0 // 지역 변수에 저장
IL_0002: leave.s IL_0014 // try 빠져나감 (finally 거쳐서)
}
catch [System.Runtime]System.DivideByZeroException
{
IL_0004: pop // 예외 객체 스택에서 제거
IL_0005: ldc.i4.2 // 2를 스택에
IL_0006: stloc.0
IL_0007: leave.s IL_0014 // try 빠져나감 (finally 거쳐서)
}
}
finally
{
IL_0009: ldstr "finally"
IL_000e: call void [System.Console]System.Console::WriteLine(string)
IL_0013: endfinally // finally 블록 종료
}
IL_0014: ldloc.0 // 지역 변수에서 반환값 로드
IL_0015: ret
}
세 가지 명령어가 핵심이다.
leave.s:try나catch블록을 빠져나갈 때 쓴다. 일반br(branch)이 아니다 —leave는 "도중에 만나는 모든finally를 실행하고 나서" 점프한다. 이 한 명령어가finally실행 보장의 비밀이다.endfinally:finally블록의 종료. 컨트롤이 원래 가려던leave목적지로 이어진다.- 블록 중첩: C# 한 단계의
try/catch/finally는 IL에서 외부.try(catch+finally 감싸는) + 내부.try(catch만 감싸는) 의 2단 구조로 펼쳐진다. CLR이 1패스에서 catch를 찾고 2패스에서 finally를 거치는 모델을 그대로 반영한 형태다.
return 문이 직접 메서드를 빠져나가지 않고 일단 지역 변수에 저장된 뒤 leave로 finally를 통과한 후에야 ret로 반환되는 점에 주목하자. C#의 try 안에서 쓰는 return은 즉시 반환이 아니다.
4. 실전 적용 — Unity에서 어디에 쓰고 어디에 쓰면 안 되는가
사례 1: 파일 I/O는 반드시 try/finally로 보호
Before — 핸들 누수
using System.IO;
using UnityEngine;
public class SaveSystemBad : MonoBehaviour
{
public void Save(string json, string path)
{
FileStream stream = File.OpenWrite(path);
StreamWriter writer = new StreamWriter(stream);
writer.Write(json); // ← Disk Full 등으로 IOException 가능
writer.Close();
stream.Close();
}
}
writer.Write 도중 예외가 던져지면 writer.Close와 stream.Close는 절대 호출되지 않는다. 운영체제 수준에서 파일 핸들이 잠긴 채로 남아 다음 저장 시도가 실패하거나, 모바일에서 파일 시스템에 쓰기 락이 걸린 채로 앱이 종료된다.
After — finally로 정리 보장
using System.IO;
using UnityEngine;
public class SaveSystemGood : MonoBehaviour
{
public void Save(string json, string path)
{
FileStream stream = null;
StreamWriter writer = null;
try
{
stream = File.OpenWrite(path);
writer = new StreamWriter(stream);
writer.Write(json);
}
catch (IOException ex)
{
Debug.LogError($"저장 실패: {ex.Message}");
}
finally
{
writer?.Close();
stream?.Close();
}
}
}
?.— 널 조건부 연산자 (Null-conditional operator) 좌측이null이면 전체 식을null로 평가하고, 그렇지 않으면 우측 멤버에 접근한다. 예외 없이 안전하게 호출할 수 있다.
예시:writer?.Close();writer가 null이면 Close()를 호출하지 않고 그냥 넘어감 —OpenWrite가 실패해 stream이 null인 경우에도 NRE 방지
writer가 null인 경우(즉 OpenWrite가 실패한 경우)에도 안전하다. 실무에서는 다음 주제(using 선언)로 더 간결하게 쓰지만, 그 내부 동작은 결국 try/finally로 컴파일된다 — 이 글에서는 원리 그 자체를 본다.
사례 2: catch 블록의 return도 finally는 실행된다
public static int ParseOrDefault(string s)
{
try
{
return int.Parse(s);
}
catch (FormatException)
{
return -1; // ← 여기서 return 해도 finally는 실행됨
}
finally
{
Debug.Log("파싱 종료");
}
}
s가 "abc"이면 출력은 다음과 같다:
파싱 종료
(반환값: -1)
IL 레벨에서 이미 봤듯, return은 leave.s로 컴파일되어 finally를 거친 뒤에야 메서드를 빠져나간다.
사례 3: catch 없이 finally만 — "예외는 던지되 정리는 한다"
오류 자체를 처리할 능력이 이 메서드에 없다면, catch 없이 try/finally만 쓰는 것이 정직하다.
public byte[] LoadAtlas(string path)
{
FileStream stream = File.OpenRead(path);
try
{
byte[] buffer = new byte[stream.Length];
stream.Read(buffer, 0, buffer.Length);
return buffer;
}
finally
{
stream.Close(); // 예외가 나든 안 나든 핸들 닫기
}
// 예외는 호출자에게 그대로 전파됨
}
.method public hidebysig static void OnlyFinally () cil managed
{
.try
{
IL_0000: ldstr "작업"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: leave.s IL_0017 // try를 정상적으로 빠져나감 → finally 거쳐서
}
finally
{
IL_000c: ldstr "정리"
IL_0011: call void [System.Console]System.Console::WriteLine(string)
IL_0016: endfinally
}
IL_0017: ret
}
catch 없이도 finally는 동작한다. IL에서도 .try { ... } finally { ... } 한 쌍만 만들어진다 — 외부 try로 감쌀 catch가 없기 때문이다.
판단 기준 — 언제 어디에 쓰는가
| 상황 | 권장 |
|---|---|
| 파일·네트워크·DB I/O | try/catch/finally (또는 using) |
| Unity에서 외부 에셋·플러그인 호출 | try/catch — 알 수 없는 예외 격리 |
Unity Update·FixedUpdate 핫패스 |
사용 금지에 가까움 — 예외 발생 자체를 차단 |
int.Parse 같은 변환 |
TryParse 사용 — 예외로 흐름 제어 금지 |
| 게임 로직(총알 부족, 쿨타임) | if로 처리 — 예측 가능한 분기는 예외가 아님 |
5. 함정과 주의사항
함정 1 — finally가 실행되지 않는 경우
finally는 "거의" 항상 실행되지만 절대적이지는 않다. 다음 상황에서는 실행되지 않는다.
StackOverflowException: 스택을 사용해 finally를 실행할 여력이 없으므로 CLR이 즉시 프로세스를 종료한다.Environment.FailFast(...)호출: 의도적인 즉시 종료 — finally 건너뜀.- 외부에서 강제 종료 (
kill -9, 모바일 OS의 메모리 회수 등).
신입 개발자가 자주 헷갈리는 부분:
// ❌ 위험한 패턴 — finally에서 다시 예외를 던짐
public void Process()
{
try { /* ... */ }
finally
{
throw new Exception("정리 중 실패"); // 원래 예외를 덮어씀!
}
}
// 위 finally의 throw도 정상적으로 IL에 들어가지만, 결과적으로
// 원래 try 블록에서 던져진 예외 정보가 사라지고 finally의 새 예외만 전파된다.
// ✅ 올바른 패턴 — finally에서는 예외를 던지지 않음
public void Process()
{
try { /* ... */ }
finally
{
try { _resource?.Close(); }
catch { /* finally 안에서 발생한 정리 실패는 삼키거나 로그만 */ }
}
}
함정 2 — Unity 핫패스에서 try/catch 남발
// ❌ 매 프레임 호출되는 Update에서 예외로 흐름 제어
void Update()
{
try
{
Vector3 dir = (target.position - transform.position).normalized;
transform.position += dir * speed * Time.deltaTime;
}
catch (NullReferenceException)
{
// target이 파괴됐을 때 여기로 옴
}
}
이 코드의 문제는 예외가 정상 흐름의 일부가 됐다는 점이다. target이 파괴되었다는 사실은 충분히 예측 가능한 게임 상태인데, 이를 NullReferenceException으로 처리하면:
- 매번 예외 객체가 힙(Heap, 가비지 컬렉터(GC, Garbage Collector — 메모리를 자동으로 회수하는 런타임 구성요소)가 관리하는 동적 메모리 영역)에 할당된다.
- 콜 스택을 캡처하느라 수백~수천 클럭 사이클이 소요된다.
- Unity의 Boehm-Demers-Weiser GC(Mono 백엔드의 보수적 GC)는 이 가비지를 모아 한 번에 수집하면서 프레임 스파이크를 만든다.
// ✅ if 가드로 예방
void Update()
{
if (target == null) return; // 예외가 아닌 정상 분기
Vector3 dir = (target.position - transform.position).normalized;
transform.position += dir * speed * Time.deltaTime;
}
==— Unity Object의 == 오버로드 Unity의UnityEngine.Object파생 객체(MonoBehaviour, GameObject 등)는==가 오버로드되어 있어, 실제로는 살아있지만 C# 참조만 남은 "destroyed" 상태도null로 평가된다.
예시:if (target == null) return;Destroy된 GameObject도 이 검사로 걸러진다 —target?.position같은?.연산자는 이 오버로드를 거치지 않으므로 Unity 객체에는==가 더 안전하다.
함정 3 — 너무 넓은 catch
// ❌ Pokémon catch — "다 잡아!"
try
{
LoadAndProcessAsset(path);
}
catch (Exception) // 모든 예외를 삼킴 — OutOfMemory도, ThreadAbort도
{
// 무시
}
Exception은 모든 예외의 부모 타입이라, 이 패턴은 진짜 심각한 시스템 예외(OutOfMemoryException, AccessViolationException 등)까지 조용히 삼킨다. 디버깅이 지옥이 된다.
// ✅ 잡을 예외만 명시적으로
try
{
LoadAndProcessAsset(path);
}
catch (FileNotFoundException ex)
{
Debug.LogWarning($"에셋 없음: {ex.FileName}");
}
catch (UnauthorizedAccessException ex)
{
Debug.LogError($"권한 없음: {ex.Message}");
}
// 그 외 예외는 위로 전파시켜 진짜 버그를 노출시킨다
6. C# 버전별 변화
C# 6.0 — 예외 필터(when 절)
C# 6.0 이전에는 catch 블록 안에서 if로 분기한 뒤 조건이 안 맞으면 다시 throw해야 했다.
// Before (C# 5 이하 스타일)
try { /* ... */ }
catch (HttpRequestException ex)
{
if (ex.Message.Contains("404"))
{
Debug.LogWarning("리소스 없음");
}
else
{
throw; // 다시 던짐 — 스택이 한 번 풀렸다가 다시 감기는 비용
}
}
C# 6.0의 when 절은 이 패턴을 정직하게 표현한다.
// After (C# 6.0+)
try
{
int.Parse("abc");
}
catch (FormatException ex) when (ex.Message.Contains("Input"))
{
Console.WriteLine("필터 일치");
}
when— 예외 필터 (Exception filter)catch절 뒤에when (조건식)을 붙이면, 해당 예외 타입이 일치하더라도 조건식이true일 때만 catch 본문이 실행된다. 조건이false이면 마치 catch가 일치하지 않은 것처럼 다음 핸들러를 계속 찾는다.
예시:catch (HttpRequestException ex) when (ex.StatusCode == 404)404 상태일 때만 이 catch가 활성화 — 다른 상태 코드는 다음 catch나 상위 핸들러로 넘어감
IL 레벨에서 when은 별도의 filter 블록으로 컴파일된다.
.method public hidebysig static void WithFilter () cil managed
{
.try
{
IL_0000: ldstr "abc"
IL_0005: call int32 [System.Runtime]System.Int32::Parse(string)
IL_000a: pop
IL_000b: leave.s IL_003a
}
filter // ← when 절은 filter 블록이 된다
{
IL_000d: isinst [System.Runtime]System.FormatException
IL_0012: dup
IL_0013: brtrue.s IL_0019
IL_0015: pop
IL_0016: ldc.i4.0 // 타입 안 맞음 → 0(false)
IL_0017: br.s IL_002b
IL_0019: callvirt instance string [System.Runtime]System.Exception::get_Message()
IL_001e: ldstr "Input"
IL_0023: callvirt instance bool [System.Runtime]System.String::Contains(string)
IL_0028: ldc.i4.0
IL_0029: cgt.un // Contains 결과를 0/1로 정규화
IL_002b: endfilter // 0이면 이 catch 건너뛰기, 1이면 진입
}
{
IL_002d: pop
IL_002e: ldstr "필터 일치"
IL_0033: call void [System.Console]System.Console::WriteLine(string)
IL_0038: leave.s IL_003a
}
IL_003a: ret
}
핵심은 endfilter — 이 명령어는 1단계(검색) 패스에서 평가된다. 스택을 풀기 전(2단계 진입 전)에 조건을 검사하므로, 조건이 false일 때 스택 정보가 그대로 보존된 채 다음 핸들러로 넘어간다. 디버거에서 첫 번째 throw 지점을 그대로 볼 수 있다는 뜻이다 — 단순 throw; 패턴과의 결정적 차이다.
C# 7.0 — throw 표현식
C# 7.0 이전에는 throw가 문장(statement)이라, 삼항 연산자나 람다 표현식 본문에서 직접 쓸 수 없었다.
// Before (C# 6 이하)
public string Name
{
get
{
if (_name == null) throw new InvalidOperationException();
return _name;
}
}
// After (C# 7.0+)
public string Name => _name ?? throw new InvalidOperationException();
??— 널 병합 연산자 (Null-coalescing operator) 왼쪽이null이 아니면 왼쪽 값을,null이면 오른쪽 값을 반환한다.
예시:string result = name ?? "Unknown";name이 null이면 "Unknown" —throw 표현식과 결합하면 "null이면 즉시 예외"라는 의도가 한 줄로 드러난다.
IL 레벨에서는 둘 다 동일한 throw opcode로 컴파일된다 — 표현식 형태는 순전히 문법적 편의(syntactic sugar)다. 그래서 이 변화에는 IL 차원의 새 기능은 없다.
7. 정리
이것만은 기억하자
try는 위험 구역,catch는 응급실,finally는 청소부다. 셋의 역할을 섞지 말 것.finally는 거의 항상 실행된다.try안의return도 finally를 거친다 — IL의leave명령이 그렇게 만든다.- CLR은 2-패스 모델로 예외를 처리한다. 1패스에서 catch를 찾고, 2패스에서 finally를 모두 실행한 뒤 catch로 진입한다.
when절(C# 6+)은 1패스에서 평가되어 스택을 풀지 않는다.throw;재던지기보다 항상 우월하다.- Unity 핫패스에서는 예외를 흐름 제어로 쓰지 말 것.
if가드와TryParse패턴으로 예외 발생 자체를 차단한다. catch (Exception)는 최후의 수단. 잡을 수 있는 예외만 명시적으로 잡고, 나머지는 위로 전파시켜 버그를 노출시킨다.- finally 안에서 새 예외를 던지지 말 것. 원본 예외 정보가 사라진다.
다음 글에서는 이 위에 쌓이는 두 가지 주제를 다룬다 — 예외 타입 계층(Exception → SystemException → ...)과 여러 catch 블록이 있을 때의 매칭 순서.
'C# 기초' 카테고리의 다른 글
| [PART10.예외 처리 기본(3/9)] throw — 예외를 어떻게, 왜 그렇게 던져야 하는가 (2) | 2026.05.05 |
|---|---|
| [PART10.예외 처리 기본(2/9)] 예외 계층과 catch 순서 — 왜 구체적인 것을 위에, 일반적인 것을 아래에 두는가 (0) | 2026.05.05 |
| [PART9.컬렉션 기본 사용법(8/8)] 배열 vs List<T> — 언제 무엇을 쓰는가 (0) | 2026.05.04 |
| [PART9.컬렉션 기본 사용법(7/8)] 컬렉션 식 재방문 — `[1, 2, 3]` 하나로 List·배열·Span을 모두 만든다 (0) | 2026.05.04 |
| [PART9.컬렉션 기본 사용법(6/8)] 컬렉션 초기화자 — `{ 1, 2, 3 }`은 컴파일러가 어떻게 풀어쓰는가 (0) | 2026.05.04 |
