반응형

[PART10.예외 처리 기본(2/9)] 예외 계층과 catch 순서 — 왜 구체적인 것을 위에, 일반적인 것을 아래에 두는가

System.Exception 상속 트리 / catch는 위에서 아래로 첫 일치 / catch (Exception) 남용이 위험한 진짜 이유


1. 문제 제기 — "그냥 다 catch (Exception)으로 잡으면 안 되나요?"

Unity 모바일 게임에서 서버로부터 캐릭터 정보 JSON을 내려받는 코드를 작성했다고 가정해 보겠습니다. 신입 개발자가 흔히 작성하는 형태는 다음과 같습니다.

C#
// 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 전달 버그가 영구히 묻히고, 데이터 파일이 깨졌는데도 게임은 빈 캐릭터로 계속 실행되어 더 큰 장애를 만듭니다.

이 글이 답하는 질문 세 가지:

  1. C#의 예외는 어떤 계층으로 정리되어 있고, 그 트리가 왜 중요한가
  2. 여러 catch 블록은 어떤 순서로 매칭되는가 — 그리고 순서를 거꾸로 쓰면 왜 컴파일 자체가 막히는가
  3. catch (Exception)은 언제 써도 되고 언제 쓰면 안 되는가

2. 개념 정의 — 예외 계층과 catch 매칭 규칙

비유: 응급실의 분류 트리아지

응급실에 환자가 오면 의사는 "사람 = 다 같은 환자"라고 보지 않습니다. 골절 → 정형외과, 심정지 → 심장내과, 경미한 찰과상 → 외래로 분류합니다. 가장 구체적인 진단명에 맞는 전문의가 먼저 보고, 어디에도 안 맞는 환자만 일반 진료로 넘어갑니다.

C#의 try/catch 가 정확히 이 방식입니다. try 블록에서 던져진 예외는 위에서부터 아래로 catch 절을 훑으며, 첫 번째로 일치하는 전문의(catch 타입) 에게 배정됩니다. 일반 진료(catch (Exception))는 마지막에 위치한 안전망일 뿐, 모든 환자를 거기로 보내면 응급실이 무너집니다.

예외 계층 구조 — 상속 트리

System.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 타입과 같거나 그 자식이면 매칭이고, 매칭된 첫 번째 절만 실행한 뒤 나머지는 모두 건너뜁니다.

C#
// 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이 나옵니다.

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를 찾는 두 단계

콜 스택

핵심 사실 두 가지입니다.

  1. EH(Exception Handling) 테이블: 각 메서드의 메타데이터에는 try 영역과 그 영역에 묶인 핸들러 목록이 별도 테이블로 기록됩니다. IL 코드 본문에는 들어가지 않고, 어셈블리 메타데이터에 부가 정보로 따라붙습니다.
  2. 두 번 훑기(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# 컴파일러는 이를 논리 오류로 간주하고 빌드 자체를 중단합니다.

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합니다.

FileNotFoundExceptionIOException의 자식이므로, IOException catch가 위에 있으면 발생한 FileNotFoundException은 무조건 그 위 절에 잡혀버립니다. 두 번째 절이 실행될 시나리오가 존재할 수 없기 때문에 컴파일러가 막아 줍니다.

거꾸로 말하면: 형제 관계인 예외(예: IOException vs FormatException)는 순서를 바꿔도 됩니다. 한쪽이 다른 쪽을 포함하지 않기 때문에 컴파일러가 도달 가능성을 보장할 수 있습니다.


4. 실전 적용 — Before/After와 Unity 사례

사례 1 — 디스크 I/O 처리: 잎부터 잡고 안전망은 마지막

Before (지나치게 포괄적)

C#
// 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 (구체적 → 일반적 순서)

C#
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 [...]FileNotFoundExceptioncatch [...]IOExceptioncatch [...]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에 넘어감
C#
// 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 구조로 컴파일됩니다.

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)은 절대 금지가 아닙니다. 애플리케이션의 최상위 진입점에서는 오히려 권장됩니다.

C#
// 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; (스택 트레이스 손상)

신입 개발자가 거의 반드시 한 번은 저지르는 실수입니다.

❌ 잘못된 패턴

C#
public static void RethrowBad()
{
    try { File.ReadAllText("missing.txt"); }
    catch (Exception ex)
    {
        throw ex;                  // ⚠️ 스택 트레이스가 여기서 다시 시작됨
    }
}

✅ 올바른 패턴

C#
public static void RethrowGood()
{
    try { File.ReadAllText("missing.txt"); }
    catch (Exception)
    {
        throw;                     // 원본 스택 트레이스 보존
    }
}

두 메서드의 IL 차이가 결정적입니다.

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
IL
// 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번, 핫패스 그 자체입니다.

❌ 잘못된 패턴 (예외를 제어 흐름으로 사용)

C#
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 자체를 차단)

C#
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을 상속하라고 했지만, 마이크로소프트는 현재 이를 비권장합니다.

C#
// ❌ 옛 권장 — 더 이상 의미 없음
public class InvalidPlayerStateException : ApplicationException { }

// ✅ 현재 권장 — Exception을 직접 상속
public class InvalidPlayerStateException : Exception
{
    public InvalidPlayerStateException(string message) : base(message) { }
    public InvalidPlayerStateException(string message, Exception inner)
        : base(message, inner) { }
}

이유: SystemExceptionApplicationException을 구분하려던 원래 의도가 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#
// 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#
// 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에서 다음 두 가지를 직접 해 보세요.

  1. catch (IOException) 위에 catch (FileNotFoundException)을 두고 빌드 — CS0160이 정말 뜨는지
  2. throw ex;로 작성한 코드와 throw;로 작성한 코드를 각각 실행해 스택 트레이스 비교 — 차이가 한눈에 보입니다

다음 주제 「throw — 예외 던지기」에서는 자기 코드에서 예외를 언제 어떻게 던질지, 그리고 사용자 정의 예외를 어떻게 설계할지를 다룹니다.

반응형

+ Recent posts