반응형

[PART10.예외 처리 기본(1/9)] try · catch · finally — 예외가 터져도 무너지지 않는 코드

위험한 구간을 try로 감싸고, 잡고 싶은 예외를 catch로 받고, 정리는 finally에 맡긴다 — 세 키워드의 역할 분담과 CLR 내부 동작


1. 문제 제기 — 게임이 강제 종료되는 한 줄

플레이어 데이터를 디스크에서 불러오는 코드를 떠올려 보자. 파일이 손상되었거나, 사용자가 SD 카드를 빼버렸거나, 권한이 없거나 — 어떤 이유로든 File.ReadAllText는 실패할 수 있다. 그리고 실패하면 예외(Exception, 정상 흐름을 벗어나는 오류 신호)가 던져진다.

C#
// Unity의 어느 매니저 컴포넌트
void LoadPlayerSave()
{
    string json = File.ReadAllText(savePath);  // 파일이 없으면 FileNotFoundException
    playerData = JsonUtility.FromJson<PlayerData>(json);
    Debug.Log("로드 성공");
}

이 코드의 문제는 두 가지다.

  1. 앱이 죽는다. 예외를 아무도 잡지 않으면 호출 스택을 거슬러 올라가다 결국 Unity 엔진까지 전파된다. 모바일이라면 그대로 강제 종료(Force Close).
  2. 정리가 안 된다. 만약 File.ReadAllText 대신 FileStream을 직접 열고 있었다면, 예외 시점에 파일 핸들이 열린 채로 남는다. 다음에 같은 파일에 쓰려 하면 또 다른 예외가 터진다.

게임은 "오류가 안 생기는 코드"를 짜는 것이 아니라 "오류가 생겨도 무너지지 않는 코드"를 짜는 일이다. C#은 이를 위해 try, catch, finally라는 세 키워드를 제공한다. 각 키워드가 정확히 어떤 책임을 지는지 — 그리고 CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)이 내부에서 어떻게 처리하는지를 이해해야, 어떤 상황에 어떤 키워드를 써야 할지 판단할 수 있다.


2. 개념 정의 — 보호 구역, 응급실, 청소부

세 키워드의 역할 비유

소방 훈련에 비유하면 셋의 역할이 명확해진다.

  • try 블록 = 위험 구역. "여기서부터 여기까지는 불이 날 수 있는 곳"이라고 표시한 영역.
  • catch 블록 = 응급실. 불이 났을 때 어떤 종류의 불(전기, 기름, 일반)인지에 따라 다르게 대응한다.
  • finally 블록 = 청소부. 불이 났든 안 났든, 누군가 도망갔든, 마지막에 들어가 가스 밸브를 잠그고 문을 닫는다.

구조와 흐름

try / catch / finally 실행 흐름

가장 단순한 형태

C#
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) 모델로 예외를 처리한다.

CLR 2-패스 예외 처리 모델

핵심은 finally가 실행되는 시점이 2패스라는 점이다. 1패스에서 일치하는 catch를 찾지 못하면 finally도 실행되지 않을 수 있다(미처리 예외 시 CLR 정책에 따라 다름). 일치하는 catch를 찾았다면, 2패스에서 예외 발생 지점부터 그 catch까지 사이의 모든 finally를 순서대로 실행한 뒤 catch 본문으로 진입한다.

IL 레벨에서 본 try/catch/finally

가장 단순한 형태의 IL을 보면 구조가 더 명확해진다. 다음 C# 코드를:

C#
using System;
public class Program {
    public static int Demo() {
        try {
            return 1;
        }
        catch (DivideByZeroException) {
            return 2;
        }
        finally {
            Console.WriteLine("finally");
        }
    }
}

컴파일러는 이렇게 변환한다.

IL
.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: trycatch 블록을 빠져나갈 때 쓴다. 일반 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 — 핸들 누수

C#
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.Closestream.Close는 절대 호출되지 않는다. 운영체제 수준에서 파일 핸들이 잠긴 채로 남아 다음 저장 시도가 실패하거나, 모바일에서 파일 시스템에 쓰기 락이 걸린 채로 앱이 종료된다.

After — finally로 정리 보장

C#
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는 실행된다

C#
public static int ParseOrDefault(string s)
{
    try
    {
        return int.Parse(s);
    }
    catch (FormatException)
    {
        return -1;            // ← 여기서 return 해도 finally는 실행됨
    }
    finally
    {
        Debug.Log("파싱 종료");
    }
}

s"abc"이면 출력은 다음과 같다:

파싱 종료
(반환값: -1)

IL 레벨에서 이미 봤듯, returnleave.s로 컴파일되어 finally를 거친 뒤에야 메서드를 빠져나간다.

사례 3: catch 없이 finally만 — "예외는 던지되 정리는 한다"

오류 자체를 처리할 능력이 이 메서드에 없다면, catch 없이 try/finally만 쓰는 것이 정직하다.

C#
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();   // 예외가 나든 안 나든 핸들 닫기
    }
    // 예외는 호출자에게 그대로 전파됨
}
IL
.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의 메모리 회수 등).

신입 개발자가 자주 헷갈리는 부분:

C#
// ❌ 위험한 패턴 — finally에서 다시 예외를 던짐
public void Process()
{
    try { /* ... */ }
    finally
    {
        throw new Exception("정리 중 실패");  // 원래 예외를 덮어씀!
    }
}
IL
// 위 finally의 throw도 정상적으로 IL에 들어가지만, 결과적으로
// 원래 try 블록에서 던져진 예외 정보가 사라지고 finally의 새 예외만 전파된다.
C#
// ✅ 올바른 패턴 — finally에서는 예외를 던지지 않음
public void Process()
{
    try { /* ... */ }
    finally
    {
        try { _resource?.Close(); }
        catch { /* finally 안에서 발생한 정리 실패는 삼키거나 로그만 */ }
    }
}

함정 2 — Unity 핫패스에서 try/catch 남발

C#
// ❌ 매 프레임 호출되는 Update에서 예외로 흐름 제어
void Update()
{
    try
    {
        Vector3 dir = (target.position - transform.position).normalized;
        transform.position += dir * speed * Time.deltaTime;
    }
    catch (NullReferenceException)
    {
        // target이 파괴됐을 때 여기로 옴
    }
}

이 코드의 문제는 예외가 정상 흐름의 일부가 됐다는 점이다. target이 파괴되었다는 사실은 충분히 예측 가능한 게임 상태인데, 이를 NullReferenceException으로 처리하면:

  1. 매번 예외 객체가 힙(Heap, 가비지 컬렉터(GC, Garbage Collector — 메모리를 자동으로 회수하는 런타임 구성요소)가 관리하는 동적 메모리 영역)에 할당된다.
  2. 콜 스택을 캡처하느라 수백~수천 클럭 사이클이 소요된다.
  3. Unity의 Boehm-Demers-Weiser GC(Mono 백엔드의 보수적 GC)는 이 가비지를 모아 한 번에 수집하면서 프레임 스파이크를 만든다.
C#
// ✅ 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

C#
// ❌ Pokémon catch — "다 잡아!"
try
{
    LoadAndProcessAsset(path);
}
catch (Exception)         // 모든 예외를 삼킴 — OutOfMemory도, ThreadAbort도
{
    // 무시
}

Exception은 모든 예외의 부모 타입이라, 이 패턴은 진짜 심각한 시스템 예외(OutOfMemoryException, AccessViolationException 등)까지 조용히 삼킨다. 디버깅이 지옥이 된다.

C#
// ✅ 잡을 예외만 명시적으로
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해야 했다.

C#
// Before (C# 5 이하 스타일)
try { /* ... */ }
catch (HttpRequestException ex)
{
    if (ex.Message.Contains("404"))
    {
        Debug.LogWarning("리소스 없음");
    }
    else
    {
        throw;            // 다시 던짐 — 스택이 한 번 풀렸다가 다시 감기는 비용
    }
}

C# 6.0의 when 절은 이 패턴을 정직하게 표현한다.

C#
// 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 블록으로 컴파일된다.

IL
.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)이라, 삼항 연산자나 람다 표현식 본문에서 직접 쓸 수 없었다.

C#
// Before (C# 6 이하)
public string Name
{
    get
    {
        if (_name == null) throw new InvalidOperationException();
        return _name;
    }
}
C#
// 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 안에서 새 예외를 던지지 말 것. 원본 예외 정보가 사라진다.

다음 글에서는 이 위에 쌓이는 두 가지 주제를 다룬다 — 예외 타입 계층(ExceptionSystemException → ...)과 여러 catch 블록이 있을 때의 매칭 순서.

반응형

+ Recent posts