[PART10.예외 처리 기본(2/9)] 예외 계층과 catch 순서 — 왜 구체적인 것을 위에, 일반적인 것을 아래에 두는가
System.Exception 상속 트리 / catch는 위에서 아래로 첫 일치 / catch (Exception) 남용이 위험한 진짜 이유
목차
1. 문제 제기 — "그냥 다 catch (Exception)으로 잡으면 안 되나요?"
Unity 모바일 게임에서 서버로부터 캐릭터 정보 JSON을 내려받는 코드를 작성했다고 가정해 보겠습니다. 신입 개발자가 흔히 작성하는 형태는 다음과 같습니다.
// Unity: 캐릭터 정보를 디스크에서 읽어 파싱
public CharacterData Load(string path)
{
try
{
string json = System.IO.File.ReadAllText(path);
return UnityEngine.JsonUtility.FromJson<CharacterData>(json);
}
catch (Exception ex) // 모든 예외를 한 번에 처리
{
UnityEngine.Debug.LogError(ex);
return new CharacterData(); // 기본값 반환
}
}
겉보기에는 안전해 보입니다. "에러가 나도 게임이 죽지 않도록" 막아 둔 코드입니다. 그러나 이 코드는 다음 상황을 모두 같은 방식으로 처리합니다.
- 파일이 없음 (
FileNotFoundException) — 다음 프레임에 다시 시도하면 복구 가능 - 권한 부족 (
UnauthorizedAccessException) — 사용자에게 안내 필요 - JSON 형식이 깨짐 (
ArgumentException) — 데이터 파일 자체를 재다운로드해야 함 path파라미터가null(ArgumentNullException) — 호출자의 버그- 메모리 부족 (
OutOfMemoryException) — 게임을 안전하게 종료해야 하는 치명적 상황
전혀 다른 다섯 가지 사건을 하나의 catch 블록이 똑같이 "에러 로그만 찍고 빈 데이터 반환"으로 처리합니다. 그 결과 호출자의 null 전달 버그가 영구히 묻히고, 데이터 파일이 깨졌는데도 게임은 빈 캐릭터로 계속 실행되어 더 큰 장애를 만듭니다.
이 글이 답하는 질문 세 가지:
- C#의 예외는 어떤 계층으로 정리되어 있고, 그 트리가 왜 중요한가
- 여러
catch블록은 어떤 순서로 매칭되는가 — 그리고 순서를 거꾸로 쓰면 왜 컴파일 자체가 막히는가 catch (Exception)은 언제 써도 되고 언제 쓰면 안 되는가
2. 개념 정의 — 예외 계층과 catch 매칭 규칙
비유: 응급실의 분류 트리아지
응급실에 환자가 오면 의사는 "사람 = 다 같은 환자"라고 보지 않습니다. 골절 → 정형외과, 심정지 → 심장내과, 경미한 찰과상 → 외래로 분류합니다. 가장 구체적인 진단명에 맞는 전문의가 먼저 보고, 어디에도 안 맞는 환자만 일반 진료로 넘어갑니다.
C#의 try/catch 가 정확히 이 방식입니다. try 블록에서 던져진 예외는 위에서부터 아래로 catch 절을 훑으며, 첫 번째로 일치하는 전문의(catch 타입) 에게 배정됩니다. 일반 진료(catch (Exception))는 마지막에 위치한 안전망일 뿐, 모든 환자를 거기로 보내면 응급실이 무너집니다.
예외 계층 구조 — 상속 트리

상속 (Inheritance) 자식 클래스가 부모 클래스의 멤버를 물려받는 관계입니다. C#의is연산자로 검사하면 자식은 부모로 인식됩니다 — 즉FileNotFoundException인스턴스는IOException인스턴스이기도 합니다.catch (IOException)절은 이 관계 덕분에 자식 예외도 모두 잡을 수 있습니다.
핵심만 정리하면 다음과 같습니다.
- 모든 예외의 뿌리는
System.Exception - 그 아래
SystemException(CLR이 던지는 계열)과ApplicationException(과거 사용자 정의 예외용)이 있지만, 현재는 둘을 구분할 실익이 없습니다. 마이크로소프트 공식 가이드는 사용자 정의 예외를 만들 때Exception을 직접 상속하라고 권장합니다. - 잎 노드가 진짜 의미 있는 단위입니다.
FileNotFoundException·ArgumentNullException처럼 구체적일수록 어떻게 복구할지 결정할 수 있습니다. NullReferenceException·IndexOutOfRangeException은 잡는 대상이 아니라 예방하는 대상입니다.if (x != null)같은 사전 검사로 막아야 할 버그입니다.
CLR (Common Language Runtime) .NET 언어로 작성된 코드를 실행하는 런타임입니다. 메모리 관리(GC), JIT 컴파일, 예외 처리 메커니즘 등 모든 기반 동작을 담당합니다. C# 코드에서 직접 보이지 않지만, 예외 객체 생성·스택 추적·catch 절 매칭은 모두 CLR이 수행합니다.
catch 매칭 동작 — 위에서 아래로, 첫 일치만
try 블록에서 예외가 발생하면 CLR은 같은 메서드 안의 catch 절을 위에서 아래 방향으로 훑습니다. 발생한 예외 객체의 타입이 catch 타입과 같거나 그 자식이면 매칭이고, 매칭된 첫 번째 절만 실행한 뒤 나머지는 모두 건너뜁니다.
// Unity: 디스크에서 설정 파일을 읽는 상황
using System;
using System.IO;
class Program
{
static void Main()
{
try
{
File.ReadAllText("missing.txt"); // FileNotFoundException 발생
}
catch (FileNotFoundException ex) // 가장 구체적인 잎
{
Console.WriteLine($"파일 없음: {ex.FileName}");
}
catch (IOException ex) // FileNotFound의 부모
{
Console.WriteLine($"IO 오류: {ex.Message}");
}
catch (Exception ex) // 최상위 안전망
{
Console.WriteLine($"기타: {ex.Message}");
}
}
}
위 코드를 컴파일하면 다음 IL이 나옵니다.
.method private hidebysig static
void Main () cil managed
{
.maxstack 2
.entrypoint
.locals init (
[0] class [System.Runtime]System.IO.FileNotFoundException,
[1] class [System.Runtime]System.IO.IOException,
[2] class [System.Runtime]System.Exception
)
.try
{
IL_0000: ldstr "missing.txt"
IL_0005: call string [System.Runtime]System.IO.File::ReadAllText(string)
IL_000a: pop
IL_000b: leave.s IL_0055 // 정상 종료 시 try 빠져나감
} // end .try
catch [System.Runtime]System.IO.FileNotFoundException // ← 첫 번째 핸들러
{
IL_000d: stloc.0
IL_000e: ldstr "파일 없음: "
IL_0013: ldloc.0
IL_0014: callvirt instance string ...::get_FileName()
IL_0019: call string [System.Runtime]System.String::Concat(string, string)
IL_001e: call void [System.Console]System.Console::WriteLine(string)
IL_0023: leave.s IL_0055
}
catch [System.Runtime]System.IO.IOException // ← 두 번째
{
IL_0025: stloc.1
// ... (생략) ...
IL_003b: leave.s IL_0055
}
catch [System.Runtime]System.Exception // ← 세 번째 (안전망)
{
IL_003d: stloc.2
// ... (생략) ...
IL_0053: leave.s IL_0055
}
IL_0055: ret
}
IL 해설 — 이 글의 핵심 증거입니다.
.try { ... }블록 뒤에catch [...]System.IO.FileNotFoundException,catch [...]System.IO.IOException,catch [...]System.Exception세 개의 핸들러 절이 소스 순서 그대로 메타데이터에 기록됩니다.- IL 명령
leave.s는 "현재 보호 영역(.try또는 핸들러)을 빠져나가 지정된 위치로 점프하라"는 명령입니다. 일반br(branch)과 달리 보호 영역 경계를 넘을 때만 사용됩니다. - CLR은 예외가 발생하면 이 메타데이터에 기록된 핸들러 목록을 위에서부터 검사합니다. C# 컴파일러는 소스 순서를 그대로 유지해서 기록하므로, 소스 코드에서 위에 쓴 catch가 먼저 매칭됩니다.
IL (Intermediate Language) C# 컴파일러가 만들어 내는 중간 언어입니다. CPU가 직접 실행하는 기계어가 아니라 CLR이 실행 시점에 한 번 더 번역(JIT)하는 가상 명령어 집합입니다. C# 코드의 진짜 의미는 IL을 봐야 정확히 알 수 있습니다.
3. 내부 동작 — 두 번 훑기(Two-pass) 모델과 EH 테이블
CLR이 catch를 찾는 두 단계

핵심 사실 두 가지입니다.
- EH(Exception Handling) 테이블: 각 메서드의 메타데이터에는
try영역과 그 영역에 묶인 핸들러 목록이 별도 테이블로 기록됩니다. IL 코드 본문에는 들어가지 않고, 어셈블리 메타데이터에 부가 정보로 따라붙습니다. - 두 번 훑기(Two-pass) 모델: 1차에서는 콜 스택을 거슬러 올라가며 매칭 핸들러만 찾고(스택은 풀지 않음), 발견하면 그제야 2차에서
finally를 실행하며 스택을 되감습니다.
이 구조 때문에 위에 쓴 catch가 먼저 매칭됩니다. EH 테이블은 소스 순서를 그대로 보존하고, CLR은 그 순서대로 isinst(타입 호환성 검사) 명령을 적용합니다.
EH 테이블 (Exception Handling Table) CLR이try/catch/finally구조를 추적하기 위해 메서드마다 따로 보관하는 메타데이터입니다. "IL 오프셋 X부터 Y까지가 try 영역이고, 매칭되는 핸들러는 어디서부터 시작한다"가 기록됩니다. C# 소스에서 catch를 위에 적었는지 아래에 적었는지가 이 테이블의 등장 순서를 결정합니다.
순서를 거꾸로 쓰면 컴파일이 막힌다 — CS0160
만약 부모 catch를 위에 두고 자식 catch를 그 아래에 두면 어떻게 될까요? 자식 절은 영원히 도달할 수 없는 코드가 됩니다. C# 컴파일러는 이를 논리 오류로 간주하고 빌드 자체를 중단합니다.
// ❌ 컴파일 에러: CS0160
using System;
using System.IO;
class BadOrder
{
public static void Run()
{
try
{
File.ReadAllText("x.txt");
}
catch (IOException ex) { Console.WriteLine(ex); } // 부모 먼저
catch (FileNotFoundException ex) { Console.WriteLine(ex); } // 자식 — 도달 불가
}
}
dotnet build가 출력하는 메시지(실제 캡처):
error CS0160: 이전의 catch 절에서 이 형식이나 상위 형식('IOException')의
예외를 모두 catch합니다.
FileNotFoundException은 IOException의 자식이므로, IOException catch가 위에 있으면 발생한 FileNotFoundException은 무조건 그 위 절에 잡혀버립니다. 두 번째 절이 실행될 시나리오가 존재할 수 없기 때문에 컴파일러가 막아 줍니다.
거꾸로 말하면: 형제 관계인 예외(예: IOException vs FormatException)는 순서를 바꿔도 됩니다. 한쪽이 다른 쪽을 포함하지 않기 때문에 컴파일러가 도달 가능성을 보장할 수 있습니다.
4. 실전 적용 — Before/After와 Unity 사례
사례 1 — 디스크 I/O 처리: 잎부터 잡고 안전망은 마지막
Before (지나치게 포괄적)
// Unity 모바일: 저장된 게임 데이터 로드
using System;
using System.IO;
public CharacterData Load(string path)
{
try
{
string json = File.ReadAllText(path);
return JsonUtility.FromJson<CharacterData>(json);
}
catch (Exception ex)
{
Debug.LogError(ex); // 모든 예외를 똑같이 처리
return new CharacterData(); // 호출자 버그(null 인자)도 묻힘
}
}
After (구체적 → 일반적 순서)
public CharacterData Load(string path)
{
try
{
string json = File.ReadAllText(path);
return JsonUtility.FromJson<CharacterData>(json);
}
catch (FileNotFoundException) // ① 잎 — 첫 실행 등 정상 시나리오
{
return CharacterData.CreateDefault();
}
catch (IOException ex) // ② 부모 — 권한·잠금 등 I/O 일반
{
Debug.LogWarning($"읽기 일시 실패: {ex.Message}");
return CharacterData.CreateDefault();
}
catch (ArgumentException ex) // ③ JSON 파싱 실패
{
Debug.LogError($"세이브 파일 손상: {ex.Message}");
SaveBackupAndReset(path); // 깨진 파일 백업 후 초기화
return CharacterData.CreateDefault();
}
// ArgumentNullException(path가 null)은 의도적으로 잡지 않습니다.
// 호출자의 버그이므로 위로 전파해 빠르게 발견하도록 합니다.
}
After의 IL은 STEP 2의 IL과 동일한 패턴(.try 뒤에 catch [...]FileNotFoundException → catch [...]IOException → catch [...]ArgumentException 순서로 핸들러가 나열됨)을 따릅니다. 핵심 차이는 IL 메타데이터에 등장하는 핸들러 타입입니다.
- Before의 EH 테이블:
[handler: System.Exception]한 개 - After의 EH 테이블:
[handler: FileNotFoundException],[handler: IOException],[handler: ArgumentException]세 개
CLR이 매칭하는 후보가 명확해지므로 회복 전략을 분기할 수 있게 됩니다. "읽을 게 없는 정상 상태"와 "데이터 파일 자체가 깨진 상황"을 구분해서 다르게 대응합니다.
사례 2 — when 필터로 같은 타입을 조건 분기
C# 6.0부터 catch (T) when (조건) 문법이 도입됐습니다. 같은 예외 타입이지만 특정 조건일 때만 처리하고 싶을 때 씁니다.
when— 예외 필터 (Exception filter)catch절 뒤에 붙여서 "예외 타입이 맞고 조건이 true일 때만" 핸들러를 실행하도록 만드는 키워드입니다. 조건이 false면 마치 그 catch가 없었던 것처럼 다음 절로 넘어갑니다.
예시:catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)404일 때만 이 핸들러로 진입하고, 그 외 HTTP 오류는 다음 catch에 넘어감
// Unity: HTTP API 호출 결과 처리
using System;
using System.Net;
using System.Net.Http;
class ProgramWhen
{
public static void Demo(HttpResponseMessage response)
{
try
{
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.WriteLine("404 입니다."); // 404만 친절히 안내
}
catch (HttpRequestException ex)
{
Console.WriteLine($"기타 HTTP 오류: {ex.Message}");
}
}
}
핵심 IL을 보면 when 절이 일반 catch와 완전히 다른 IL 구조로 컴파일됩니다.
.try
{
IL_0000: ldarg.0
IL_0001: callvirt instance ... HttpResponseMessage::EnsureSuccessStatusCode()
IL_0006: pop
IL_0007: leave.s IL_0053
}
filter // ← when 절은 'filter' 핸들러
{
IL_0009: isinst [System.Net.Http]...HttpRequestException // 타입 체크
IL_000e: dup
IL_000f: brtrue.s IL_0015
IL_0011: pop
IL_0012: ldc.i4.0 // 타입 다르면 false
IL_0013: br.s IL_002c
IL_0015: callvirt instance ...::get_StatusCode()
IL_001a: stloc.0
IL_001b: ldloca.s 0
IL_001d: call instance ... Nullable`1::GetValueOrDefault()
IL_0022: ldc.i4 404
IL_0027: ceq // StatusCode == 404 ?
IL_0029: ldc.i4.0
IL_002a: cgt.un
IL_002c: endfilter // ← 1 이면 이 핸들러 채택
}
{
IL_002e: pop
IL_002f: ldstr "404 입니다."
IL_0034: call void [System.Console]System.Console::WriteLine(string)
IL_0039: leave.s IL_0053
}
catch [System.Net.Http]...HttpRequestException // ← 일반 catch (필터 미통과 시)
{
// 기타 HTTP 오류 처리
}
IL 해설:
- 일반 catch는
catch [타입]한 줄이지만,when절은filter { ... } endfilter라는 별도 IL 블록을 만듭니다. 이 블록 안에서isinst로 타입을 검사하고when조건식을 직접 평가합니다. endfilter는 스택 위에 남은 값(0 또는 1)을 결과로 반환합니다. 1이면 이 핸들러를 채택, 0이면 무시하고 다음 절로 넘어갑니다.- 가장 중요한 차이:
filter는 "1차 패스"에서 평가됩니다. 즉 스택을 풀기 전에 조건을 검사합니다. 일반 catch가 잡고if로 걸러서throw;로 다시 던지는 방식보다 스택 트레이스가 손상되지 않고 디버거가 throw 지점을 그대로 보여 줍니다.
사례 3 — 최상위 안전망: 게임 메인 루프
catch (Exception)은 절대 금지가 아닙니다. 애플리케이션의 최상위 진입점에서는 오히려 권장됩니다.
// Unity: GameManager의 최상위 진입점
public class GameManager : MonoBehaviour
{
void Start()
{
try
{
BootGame(); // 모든 초기화 로직 진입점
}
catch (Exception ex)
{
// 목적: 복구가 아니라 "기록 + 안전한 종료"
Debug.LogError($"치명적 부팅 실패: {ex}");
CrashReporter.Report(ex); // 원격 로그 전송
ShowFatalDialogAndQuit("게임을 시작할 수 없습니다.");
}
}
}
이 위치에서의 catch (Exception) 목적은 "복구"가 아니라 "기록 후 안전한 종료" 입니다. 사용자에게 친절한 안내를 띄우고, 원격 로그에 기록하고, 프로세스를 깔끔하게 종료합니다. 게임 루프 내부 깊숙한 곳에서 catch (Exception)을 쓰는 것과는 의미가 완전히 다릅니다.
5. 함정과 주의사항
함정 1 — throw ex; vs throw; (스택 트레이스 손상)
신입 개발자가 거의 반드시 한 번은 저지르는 실수입니다.
❌ 잘못된 패턴
public static void RethrowBad()
{
try { File.ReadAllText("missing.txt"); }
catch (Exception ex)
{
throw ex; // ⚠️ 스택 트레이스가 여기서 다시 시작됨
}
}
✅ 올바른 패턴
public static void RethrowGood()
{
try { File.ReadAllText("missing.txt"); }
catch (Exception)
{
throw; // 원본 스택 트레이스 보존
}
}
두 메서드의 IL 차이가 결정적입니다.
// RethrowGood — 'throw;' (변수 없음)
.try
{
IL_0000: ldstr "missing.txt"
IL_0005: call string ...File::ReadAllText(string)
IL_000a: pop
IL_000b: leave.s IL_0010
}
catch [System.Runtime]System.Exception
{
IL_000d: pop
IL_000e: rethrow // ← 원본 예외 그대로 다시 던짐
}
IL_0010: ret
// RethrowBad — 'throw ex;' (변수 사용)
.try
{
IL_0000: ldstr "missing.txt"
IL_0005: call string ...File::ReadAllText(string)
IL_000a: pop
IL_000b: leave.s IL_000e
}
catch [System.Runtime]System.Exception
{
IL_000d: throw // ← 새로 throw — 스택 트레이스 재시작
}
IL_000e: ret
IL 해설:
rethrow는 현재 catch 블록이 받은 예외 객체를 그대로 다시 던지라는 IL 전용 명령입니다. C#의throw;(인자 없이) 가 이걸로 컴파일됩니다. 스택 트레이스에 원본 throw 위치가 그대로 보존됩니다.throw는 스택 위에 있는 객체를 새로 던지라는 명령입니다. C#의throw ex;가 이걸로 컴파일됩니다. CLR 입장에서는catch블록이 새로운 throw 지점이 되므로 스택 트레이스가 거기서부터 다시 시작됩니다 — 진짜 원인이 묻혀 디버깅 지옥.- Roslyn 분석기 CA2200("catch한 예외를 다시 throw하면 스택 정보가 변경됩니다") 경고가 자동으로 뜨므로 즉시 수정해야 합니다.
함정 2 — 절대 잡지 말아야 할 예외들
catch (Exception)은 다음 예외들까지 끌어들이려 합니다 — 모두 잡으면 안 되거나 잡아도 의미 없는 케이스입니다.
| 예외 | 왜 잡으면 안 되는가 |
|---|---|
OutOfMemoryException |
메모리 부족 — catch 블록의 문자열 연결조차 실패할 수 있음 |
StackOverflowException |
.NET 2.0+ 부터 일반 catch로 잡히지 않음. 잡으려 해도 프로세스가 즉시 종료됨 |
AccessViolationException |
손상된 메모리 접근. CSE(Corrupted State Exception)로 분류되어 기본 catch에 잡히지 않음 |
ThreadAbortException (.NET Framework) |
catch해도 블록 끝에서 CLR이 자동 재던짐 |
NullReferenceException |
잡을 게 아니라 사전 null 체크로 막아야 하는 버그 |
IndexOutOfRangeException |
잡을 게 아니라 인덱스 범위 검사로 막아야 하는 버그 |
CSE (Corrupted State Exception) 프로세스 메모리 자체가 손상됐을 때 던져지는 예외 종류입니다.AccessViolationException이 대표적입니다. .NET 4.0+ 와 .NET Core/.NET 5+ 에서는 일반catch (Exception)으로 잡히지 않도록 설계되었습니다. 잡으려는 시도 자체가 위험하기 때문입니다.
함정 3 — Unity 핫패스에서의 try/catch 비용
Unity의 Update()·FixedUpdate()·LateUpdate()는 매 프레임 호출됩니다. 60fps 기준 1초에 60번, 핫패스 그 자체입니다.
❌ 잘못된 패턴 (예외를 제어 흐름으로 사용)
public class EnemyController : MonoBehaviour
{
private string[] inputs;
private int totalScore;
void Update()
{
// 텍스트 입력 큐를 매 프레임 파싱
foreach (var s in inputs)
{
try { totalScore += int.Parse(s); } // 형식 오류 시 throw
catch (FormatException) { /* 무시 */ } // 매번 잡고 버림
}
}
}
문제: int.Parse("abc")는 FormatException을 throw합니다. 한 번 throw 될 때마다 CLR은 스택 트레이스를 캡처하고 1차 패스로 핸들러를 탐색합니다. 모바일 기기에서 한 프레임에 10번만 발생해도 수십~수백 마이크로초가 사라져 프레임 드롭이 보입니다.
✅ 올바른 패턴 (Try-Pattern으로 throw 자체를 차단)
public class EnemyController : MonoBehaviour
{
private string[] inputs;
private int totalScore;
void Update()
{
foreach (var s in inputs)
{
if (int.TryParse(s, out int n)) // 실패 시 false 반환 — throw 없음
{
totalScore += n;
}
}
}
}
두 코드의 IL 핵심 차이:
Before (Parse + try/catch) |
After (TryParse) |
|---|---|
.try { ... call Int32::Parse } catch [System.Runtime]System.FormatException { ... } 메서드마다 EH 테이블 항목 생성 |
call Int32::TryParse(string, int32&) 한 번 + brfalse로 분기 |
| 실패 시 CLR이 1차 패스 시작 → 스택 트레이스 캡처 → 핸들러 매칭 | 실패 시 단순히 false 반환 — 분기 한 번 |
규칙: 핫패스에서는 throw가 발생할 수 있는 API 대신 Try 패턴(TryParse·TryGetValue·Dictionary.TryAdd)을 우선 사용합니다.
핫패스 (Hot Path) 프로그램에서 자주 반복 실행되어 전체 성능에 큰 영향을 주는 코드 구간입니다. Unity에서는Update()·FixedUpdate()·LateUpdate()·코루틴의yield return null루프 등이 대표적입니다. 한 번 호출에 100마이크로초만 더 들어도 60fps × 100μs = 6ms 손실이 발생해 프레임 예산(16.6ms)의 35%를 잡아먹습니다.
함정 4 — ApplicationException 상속
옛날 자료에서는 사용자 정의 예외를 만들 때 ApplicationException을 상속하라고 했지만, 마이크로소프트는 현재 이를 비권장합니다.
// ❌ 옛 권장 — 더 이상 의미 없음
public class InvalidPlayerStateException : ApplicationException { }
// ✅ 현재 권장 — Exception을 직접 상속
public class InvalidPlayerStateException : Exception
{
public InvalidPlayerStateException(string message) : base(message) { }
public InvalidPlayerStateException(string message, Exception inner)
: base(message, inner) { }
}
이유: SystemException과 ApplicationException을 구분하려던 원래 의도가 TargetInvocationException(CLR 발생인데 ApplicationException 상속) 같은 위반 사례 때문에 무너졌고, 두 클래스를 구분해서 분기 처리하는 코드는 사실상 존재하지 않습니다. 차라리 도메인 의미가 명확한 이름을 가진 자체 예외 계층을 만드는 편이 낫습니다.
6. C# 버전별 변화
| 버전 | 변화 | 영향 |
|---|---|---|
| C# 1.0 | 기본 try/catch/finally 도입 — 자바와 거의 동일 |
위에서 아래로 매칭, CS0160 규칙 확정 |
| C# 6.0 (2015) | when 예외 필터 도입 (catch (T) when (조건)) |
1차 패스에서 조건 평가 — 스택 트레이스 보존 |
| C# 6.0 | catch/finally 안에서 await 가능 |
비동기 예외 처리 자연스러워짐 |
| C# 8.0+ | using 선언과 await using 도입 (예외와는 직접 관련 없으나 finally 자동 생성) |
catch 절 외부 정리 코드 단순화 |
| C# 11+ | 일반 catch 절에는 큰 변화 없음 | — |
Before (C# 5 이하 — when 없음, catch에서 분기)
// C# 5: 같은 타입에서 분기하려면 일반 catch + if + throw
try
{
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
if (ex.Message.Contains("404"))
{
Console.WriteLine("404 입니다.");
}
else
{
throw; // 스택은 보존되지만 이미 풀린 상태
}
}
After (C# 6+ — when 필터 사용)
// C# 6+: 1차 패스에서 조건 평가, 스택 풀기 전에 분기
try
{
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.WriteLine("404 입니다.");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"기타 HTTP 오류: {ex.Message}");
}
IL 차이는 사례 2에서 본 그대로입니다. C# 5 이하는 일반 catch 하나에 if/throw가 들어가지만, C# 6+는 IL 수준에서 별도 filter/endfilter 블록으로 컴파일되어 1차 패스 평가의 이점을 그대로 받습니다. 디버거가 "처음 throw된 위치"를 정확히 가리킬 수 있어 문제 추적이 훨씬 쉬워집니다.
7. 정리 — 이것만 기억하세요
핵심 체크리스트
| 항목 | 답 |
|---|---|
| 모든 예외의 뿌리 | System.Exception |
| catch 매칭 순서 | 위 → 아래 (소스 코드 순서 = IL EH 테이블 순서) |
| catch 작성 순서 규칙 | 구체적인 것 먼저, 일반적인 것 나중 |
| 부모를 위에 두면 | CS0160 컴파일 에러 — 자식 절은 도달 불가 |
catch (Exception) 쓸 곳 |
애플리케이션 최상위 (기록·종료 목적만) |
NullReferenceException 처리법 |
잡지 말고 사전 null 체크로 예방 |
| 같은 타입 조건 분기 | C# 6+ when 필터 — 1차 패스에서 평가, 스택 보존 |
| 재던지기 | throw; (rethrow) — throw ex; 절대 금지 |
| Unity 핫패스 | Parse 대신 TryParse — throw 자체를 차단 |
| 사용자 정의 예외 | Exception 직접 상속 (ApplicationException 비권장) |
한 줄 요약
catch는 위에서 아래로 첫 일치를 골라잡습니다. 그래서 잎(구체) → 뿌리(일반) 순서로 적고, 뿌리(Exception)는 최상위 진입점에서 안전한 종료 용도로만 씁니다.
직접 확인해 보기
이 글을 닫기 전에 IDE에서 다음 두 가지를 직접 해 보세요.
catch (IOException)위에catch (FileNotFoundException)을 두고 빌드 — CS0160이 정말 뜨는지throw ex;로 작성한 코드와throw;로 작성한 코드를 각각 실행해 스택 트레이스 비교 — 차이가 한눈에 보입니다
다음 주제 「throw — 예외 던지기」에서는 자기 코드에서 예외를 언제 어떻게 던질지, 그리고 사용자 정의 예외를 어떻게 설계할지를 다룹니다.
'C# 기초' 카테고리의 다른 글
| [PART10.예외 처리 기본(4/9)] 자주 만나는 예외 — 이름만이라도 기억 (0) | 2026.05.05 |
|---|---|
| [PART10.예외 처리 기본(3/9)] throw — 예외를 어떻게, 왜 그렇게 던져야 하는가 (2) | 2026.05.05 |
| [PART10.예외 처리 기본(1/9)] try · catch · finally — 예외가 터져도 무너지지 않는 코드 (2) | 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 |
