반응형

[PART10.예외 처리 기본(9/9)] 예외를 제어 흐름으로 쓰지 않는다 — try/catchif보다 수백 배 비싼 이유

예외는 "경보 시스템"이지 "내비게이션 시스템"이 아니다 / 스택 트레이스 캡처라는 숨은 비용 / Parse vs TryParse의 IL 차이 / TryXxx · 튜플 · null 반환 · Result 패턴 / Unity IL2CPP에서 throw가 더 비싼 이유


1. 문제 제기 — try/catch로 흐름을 가르면 일어나는 일

Unity 모바일 게임의 친구 검색 기능을 만든다고 해봅시다. 사용자가 입력한 ID 문자열을 정수로 바꿔서 서버에 보내야 합니다. 신입 개발자가 처음 떠올릴 만한 코드는 보통 이렇습니다.

C#
public void OnSearchButton(string idInput)
{
    int id;
    try
    {
        id = int.Parse(idInput);
    }
    catch (FormatException)
    {
        ShowToast("숫자만 입력해주세요");
        return;
    }
    SendFriendRequest(id);
}

문법은 맞고, 동작도 합니다. 그런데 이 코드를 작성한 개발자가 모르는 사실이 두 가지 있습니다.

  1. int.Parse는 실패할 때마다 FormatException을 던진다. "숫자만 입력해주세요" 토스트가 뜨는 모든 순간, CLR(Common Language Runtime, .NET이 코드를 실행하는 런타임)은 거대한 비용을 치른다.
  2. 이 비용은 단순한 if문 비용의 수백~수천 배에 달한다. 사용자가 빠르게 잘못된 입력을 반복하거나, 매 프레임 Update()에서 비슷한 패턴이 돌면 60fps가 30fps로 떨어진다.

그래서 .NET 설계 가이드라인에는 다음과 같은 단호한 문장이 있습니다.

"DO NOT use exceptions for normal flow of control." 정상적인 흐름 제어에 예외를 사용하지 마라.

이 글은 그 문장의 "왜"를 IL(Intermediate Language, C# 컴파일러가 만드는 중간 언어) 수준에서 보여주고, 무엇으로 대체해야 하는지를 다룹니다. 그리고 Unity 모바일 환경에서는 왜 이 원칙이 더 엄격해야 하는지도 함께 설명합니다.

이 글은 PART 10 — 예외 처리 기본의 마지막 주제이며, 앞서 다룬 try/catch/finally, 예외 계층, throw, using 같은 도구를 "언제 쓰지 말아야 하는가"의 관점에서 마무리합니다.


2. 개념 정의 — "예외적인 상황"의 진짜 뜻

2.1 비유 — 화재경보기와 메뉴 버튼

회사 사무실을 떠올려봅시다.

  • 메뉴 버튼: 평소에 자주 누르는 것. 엘리베이터, 회의실 예약, 자판기 결제. 빠르고 가볍고 예측 가능하다.
  • 화재경보기: 정말 비상 상황에만 누르는 것. 누르는 순간 사이렌이 울리고, 모든 직원이 대피하고, 소방차가 출동한다. 비싸지만 진짜 위급할 땐 반드시 동작해야 한다.

if문과 TryXxx 메서드는 메뉴 버튼입니다. throw로 던지는 예외는 화재경보기입니다. 화재경보기로 "오늘 점심 뭐 먹지?"를 결정하지 않는 것처럼, 예외로 정상적인 분기 처리를 하면 안 됩니다.

2.2 시각화 — 정상 흐름 vs 예외 흐름

정상 분기와 예외 분기의 비용 차이

왼쪽은 정상적인 분기 처리, 오른쪽은 예외로 처리하는 흐름입니다. 같은 "변환 실패 → 기본값 반환"을 표현하지만, 비용 구조는 완전히 다릅니다.

2.3 가장 작은 예제 — Parse vs TryParse

out — 출력 매개변수 (Output parameter) 메서드가 호출자에게 추가 값을 "돌려보내는" 매개변수다. 메서드 내부에서 반드시 값을 할당해야 하며, 호출 시점에는 변수가 초기화되지 않아도 된다. bool TryParse(string s, out int result)처럼 "성공 여부 + 결과값"을 한 번에 돌려주는 패턴에서 자주 쓴다.
예시: int.TryParse("42", out int n); 성공 시 n에 42가 들어가고 메서드는 true 반환, 실패 시 n에 0이 들어가고 false 반환

같은 의미의 코드를 두 가지 방식으로 작성합니다. 한쪽은 예외로, 한쪽은 TryParse로 처리합니다.

C#
// BEFORE — 예외를 제어 흐름으로 (안티패턴)
public static int ParseWithException(string s)
{
    try
    {
        return int.Parse(s);
    }
    catch (FormatException)
    {
        return 0;
    }
}

// AFTER — TryXxx 패턴 (정상 흐름)
public static int ParseWithTryPattern(string s)
{
    if (int.TryParse(s, out int result))
    {
        return result;
    }
    return 0;
}

코드만 보면 큰 차이가 없어 보입니다. 하지만 컴파일러가 만드는 IL을 보면 무슨 일이 벌어지는지 확연히 드러납니다.

IL
// BEFORE — ParseWithException
.method public hidebysig static int32 ParseWithException(string s)
{
    .try
    {
        IL_0000: ldarg.0                 // s를 스택에
        IL_0001: call int32 [System.Runtime]System.Int32::Parse(string)  // 실패 시 throw
        IL_0006: stloc.0
        IL_0007: leave.s IL_000e
    }
    catch [System.Runtime]System.FormatException   // 예외 핸들러 등록
    {
        IL_0009: pop                     // 잡은 예외 객체 버림
        IL_000a: ldc.i4.0
        IL_000b: stloc.0
        IL_000c: leave.s IL_000e
    }
    IL_000e: ldloc.0
    IL_000f: ret
}

// AFTER — ParseWithTryPattern
.method public hidebysig static int32 ParseWithTryPattern(string s)
{
    IL_0000: ldarg.0                     // s를 스택에
    IL_0001: ldloca.s 0                  // out 변수 주소를 스택에
    IL_0003: call bool [System.Runtime]System.Int32::TryParse(string, int32&)
    IL_0008: brfalse.s IL_000c           // false면 0 반환 분기로
    IL_000a: ldloc.0
    IL_000b: ret
    IL_000c: ldc.i4.0
    IL_000d: ret
}

핵심 IL 차이:

  • BEFORE에는 .try { ... } catch { ... } 블록이 있다. 메서드 메타데이터에 예외 핸들러 테이블이 등록된다는 뜻이다. JIT(Just-In-Time, 실행 시점 기계어 변환) 컴파일러는 이 메서드를 컴파일할 때 예외 unwinding 정보까지 함께 만든다.
  • AFTER는 단순히 brfalse.s 한 줄이다. if 분기 그 자체다. 예외 핸들러 테이블이 비어 있다.

평소 입력이 모두 유효하다면(즉 Parse가 성공한다면) 두 코드의 비용은 비슷합니다. 하지만 실패 경로에서 비용이 폭발적으로 차이납니다. 이유는 다음 섹션에서 살펴봅니다.


3. 내부 동작 — throw 한 줄이 일으키는 4단계 비용

int.Parse("abc")가 실행되는 순간, CLR 내부에서 어떤 일이 일어나는지를 단계별로 봅니다.

3.1 시각화 — throw가 일으키는 4단계

throw 한 줄에 숨은 4단계 비용

3.2 단계별 설명

① 예외 객체 힙 할당. throw new FormatException(...)은 결국 new 연산이다. 관리 힙(managed heap)에 객체가 할당되고, 이 객체는 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 회수해야 한다. 객체 자체는 작지만(수십~수백 바이트) 1초에 수천 번 발생하면 Gen0 할당이 폭증한다.

② 스택 트레이스 캡처. 이게 가장 비싼 부분이다. CLR은 예외가 던져진 시점의 스레드 콜 스택을 native level에서 walking한다. 각 스택 프레임마다 메서드 메타데이터를 조회해 "어느 클래스의 어느 메서드의 몇 번째 줄인지"를 기록한다. 호출 스택이 깊을수록(예: Unity의 이벤트 핸들러 → UI 시스템 → 게임 로직 → 서버 모듈) 비례적으로 비용이 늘어난다. 결과는 결국 문자열로 직렬화되어 Exception.StackTrace 속성을 통해 노출된다.

③ catch 블록 매칭. 현재 메서드의 .try 블록 끝에 등록된 핸들러 중 던져진 예외 타입과 호환되는 것이 있는지 본다. 없으면 호출자(caller) 프레임으로 올라가서 다시 본다. 끝까지 못 찾으면 프로그램이 죽는다(unhandled exception).

④ 스택 되감기. 매칭된 catch를 찾으면, 그 지점까지 스택 프레임을 하나씩 팝(pop)하면서 각 프레임의 finally 블록을 실행하고 지역 변수를 정리한다. C#의 using(앞서 5번 주제)이 IL try/finally로 변환되는 이유가 여기다 — 예외가 던져져도 자원 해제는 보장되어야 하기 때문이다.

3.3 IL이 보여주는 핵심 차이

Parse 호출은 IL 한 줄(call)이지만, 실패 시 위 4단계가 모두 일어난다. TryParse 호출도 IL 한 줄(call)이지만, 실패 시 단순히 false를 반환한다. 같은 메서드 호출 모양이지만 실패 경로의 비용이 천지차이입니다.

벤치마크 수치 참고: BenchmarkDotNet으로 측정한 전형적인 결과는 다음과 같다(.NET 8/x64 기준).
  • int.TryParse("abc", out _) → 약 30 ns / 0 byte 할당
  • try { int.Parse("abc"); } catch { } → 약 3,000~10,000 ns / 200~500 byte 할당
  • 호출 스택이 깊어지면 후자는 더 늘어난다.

4. 실전 적용 — 어떤 패턴으로 대체하는가

Parse만이 아닙니다. "정상적으로 일어날 수 있는 실패"를 예외로 처리하는 패턴은 코드 곳곳에 숨어 있습니다. 대표적인 네 가지를 BCL(Base Class Library, .NET이 기본 제공하는 표준 라이브러리)이 제공하는 대안과 함께 봅니다.

4.1 TryXxx 패턴 — 가장 먼저 고려할 대안

Dictionary<TKey, TValue> 인덱서는 키가 없을 때 KeyNotFoundException을 던집니다. 친구 점수판에서 "점수가 없으면 0으로 처리"하려고 다음처럼 쓰면 안 됩니다.

C#
// BEFORE — 안티패턴: KeyNotFoundException으로 흐름 분기
public static int GetScoreOrZero(Dictionary<string, int> scores, string name)
{
    try
    {
        return scores[name];
    }
    catch (KeyNotFoundException)
    {
        return 0;
    }
}

// AFTER — TryGetValue: 실패가 정상 시나리오에 포함됨
public static int GetScoreOrZeroTry(Dictionary<string, int> scores, string name)
{
    if (scores.TryGetValue(name, out int score))
    {
        return score;
    }
    return 0;
}

IL을 보면 BEFORE.try/catch 블록을 가지고, AFTER는 단순한 분기로 끝납니다.

IL
// BEFORE — GetScoreOrZero
.method public hidebysig static int32 GetScoreOrZero(...)
{
    .try
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: callvirt instance !1 Dictionary`2::get_Item(!0)   // 실패 시 KeyNotFoundException
        IL_0007: stloc.0
        IL_0008: leave.s IL_000f
    }
    catch [System.Runtime]System.Collections.Generic.KeyNotFoundException
    {
        IL_000a: pop
        IL_000b: ldc.i4.0
        IL_000c: stloc.0
        IL_000d: leave.s IL_000f
    }
    IL_000f: ldloc.0
    IL_0010: ret
}

// AFTER — GetScoreOrZeroTry
.method public hidebysig static int32 GetScoreOrZeroTry(...)
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: ldloca.s 0
    IL_0004: callvirt instance bool Dictionary`2::TryGetValue(!0, !1&)
    IL_0009: brfalse.s IL_000d           // 단순 분기
    IL_000b: ldloc.0
    IL_000c: ret
    IL_000d: ldc.i4.0
    IL_000e: ret
}

같은 의미를 표현하지만, 실패 시점에 예외 객체 할당과 스택 트레이스 캡처가 일어나지 않습니다. 모바일 환경에서는 이 한 줄의 차이가 GC Spike(가비지 컬렉션이 길게 일어나서 프레임이 끊기는 현상) 발생 빈도를 크게 줄여줍니다.

4.2 LINQ — First vs FirstOrDefault

같은 함정이 LINQ에도 있습니다. First()는 조건을 만족하는 항목이 없으면 InvalidOperationException을 던집니다. FirstOrDefault()null(또는 값 타입의 default)을 반환합니다.

? (참조 타입 뒤) — Nullable Reference Type, NRT (C# 8.0+) 참조 타입에 ?를 붙이면 "이 변수는 null일 수 있음"을 컴파일러에 명시한다. 컴파일러는 호출자에게 null 체크를 강제한다. User는 null 불가, User?는 null 가능이라는 의미다.
예시: User? FindUser(int id) => list.FirstOrDefault(u => u.Id == id); 호출자는 if (user is null) return; 같은 null 가드를 거쳐야 컴파일 경고가 사라진다.
C#
// BEFORE — First()로 던지고 catch
public static User GetFirstAdmin(List<User> users)
{
    try
    {
        return users.First(u => u.Name == "admin");
    }
    catch (InvalidOperationException)
    {
        return new User(-1, "");
    }
}

// AFTER — FirstOrDefault() + NRT
public static User? FindFirstAdmin(List<User> users)
{
    return users.FirstOrDefault(u => u.Name == "admin");
}
IL
// BEFORE — GetFirstAdmin (핵심 부분만)
.try
{
    IL_0020: call !!0 [System.Linq]System.Linq.Enumerable::First<class Prog2.User>(...)  // 실패 시 throw
    IL_0025: stloc.0
    IL_0026: leave.s IL_0037
}
catch [System.Runtime]System.InvalidOperationException
{
    IL_0028: pop
    IL_0029: ldc.i4.m1
    IL_002a: ldstr ""
    IL_002f: newobj instance void Prog2.User::.ctor(int32, string)
    IL_0034: stloc.0
    IL_0035: leave.s IL_0037
}

// AFTER — FindFirstAdmin (핵심 부분만)
IL_0020: call !!0 [System.Linq]System.Linq.Enumerable::FirstOrDefault<class Prog2.User>(...)
IL_0025: ret                             // try/catch 없음, 그냥 반환

해설: BEFORE의 메서드 메타데이터에는 InvalidOperationException 핸들러가 등록되어 있고, 실패 시 4단계 비용이 그대로 발생한다. AFTER는 일반 메서드 호출 한 줄이며, 실패 시 null이 반환된다. NRT를 켜면 호출자는 null을 반드시 체크해야 하므로 안전성도 유지된다.

FirstOrDefault처럼 Single/SingleOrDefault, Last/LastOrDefault도 같은 쌍입니다. "OrDefault" 접미사가 있으면 정상 흐름용, 없으면 예외 흐름용으로 기억하면 됩니다.

4.3 튜플 반환 — out이 부담스러울 때

out 매개변수는 호출자 코드에서 변수를 따로 선언해야 하고, async 메서드에서는 사용할 수 없습니다. C# 7부터 도입된 ValueTuple을 사용하면 다음처럼 깔끔합니다.

C#
public (bool Success, Order Value) FindOrder(int id)
{
    var order = _orders.GetValueOrDefault(id);
    return (order is not null, order ?? new Order());
}

// 호출 측
var (ok, order) = FindOrder(42);
if (ok) Console.WriteLine(order.Total);

ValueTuple구조체라서 힙에 할당되지 않습니다. 즉 반환값을 만드는 비용이 거의 없습니다.

4.4 Result/Option 패턴 — 실패 이유까지 시그니처에 담을 때

"성공/실패"만이 아니라 "왜 실패했는지"도 호출자에게 알려야 할 때가 있습니다. 결제 처리, 외부 API 호출, 비즈니스 로직 검증 등이 그런 경우입니다.

C#
// 직접 정의한 Result 패턴 (LanguageExt, OneOf 라이브러리도 동일 개념)
public abstract record Result<T>;
public sealed record Ok<T>(T Value) : Result<T>;
public sealed record Err<T>(string Reason) : Result<T>;

public static Result<int> ChargeGem(int currentGem, int cost)
{
    if (cost < 0)        return new Err<int>("비용은 음수일 수 없습니다");
    if (currentGem < cost) return new Err<int>("잼이 부족합니다");
    return new Ok<int>(currentGem - cost);
}

// 호출 측
var result = ChargeGem(player.Gem, 100);
if (result is Ok<int> ok)         player.Gem = ok.Value;
else if (result is Err<int> err)  ShowToast(err.Reason);
record — 레코드 (값 동등성을 가진 참조 타입, C# 9+) 클래스와 비슷하지만 컴파일러가 자동으로 값 동등성(필드가 모두 같으면 같다고 판정), with 식, 분해(Deconstruct)를 만들어준다. Result 패턴처럼 "값을 담아서 전달만 할 객체"에 잘 어울린다.
예시: public sealed record Ok<T>(T Value) : Result<T>; new Ok<int>(42)로 생성, result is Ok<int> ok 패턴 매칭으로 분해 가능

핵심은 메서드 시그니처만 봐도 "이 메서드는 실패할 수 있구나"가 드러난다는 것입니다. 호출자는 컴파일러의 도움을 받아 실패 케이스를 빠뜨리지 않게 됩니다.

4.5 Unity 실전 — 매 프레임 핫패스에서

Unity의 Update()는 60fps 기준 1초에 60번, 30분 플레이면 약 108,000번 실행됩니다. 이 안에서 예외 흐름을 쓰면 어떻게 되는지 봅시다.

C#
// BEFORE — Update() 안에서 KeyNotFoundException으로 흐름 제어
public class EnemyController : MonoBehaviour
{
    public Dictionary<int, EnemyStat> stats;

    void Update()
    {
        try
        {
            float speed = stats[currentEnemyId].Speed;   // 가끔 키가 없을 수 있음
            transform.position += Vector3.right * speed * Time.deltaTime;
        }
        catch (KeyNotFoundException)
        {
            // 그냥 넘김
        }
    }
}

// AFTER — TryGetValue로 정상 흐름 처리
public class EnemyController : MonoBehaviour
{
    public Dictionary<int, EnemyStat> stats;

    void Update()
    {
        if (stats.TryGetValue(currentEnemyId, out var stat))
        {
            transform.position += Vector3.right * stat.Speed * Time.deltaTime;
        }
    }
}

BEFORE에서 currentEnemyId가 가끔 없는 키라면, 매 프레임 예외 객체 할당과 스택 트레이스 캡처가 일어납니다. 모바일에서는 즉시 GC Spike와 프레임 드랍으로 이어집니다. AFTERif문 한 번이며, 실패 시에도 GC를 건드리지 않습니다.


5. 함정과 주의사항

5.1 함정 1 — "검증을 throw로" 하기

신입 개발자가 흔히 하는 실수입니다. "음수면 안 되니까 throw하자"라고 생각하는 거죠.

C#
// ❌ 잘못된 패턴 — 호출 시점에 검증할 수 있는 값을 throw로 판단
public static int CalcLevelBad(int exp)
{
    if (exp < 0) throw new ArgumentOutOfRangeException(nameof(exp));
    return exp / 100;
}

// ✅ 올바른 패턴 — 호출자가 미리 판단할 수 있게 TryXxx 제공
public static bool TryCalcLevel(int exp, out int level)
{
    if (exp < 0)
    {
        level = 0;
        return false;
    }
    level = exp / 100;
    return true;
}
IL
// ❌ CalcLevelBad
IL_0000: ldarg.0
IL_0001: ldc.i4.0
IL_0002: bge.s IL_000f
IL_0004: ldstr "exp"
IL_0009: newobj instance void [System.Runtime]System.ArgumentOutOfRangeException::.ctor(string)
IL_000e: throw                          // 여기서 4단계 비용 발생
IL_000f: ldarg.0
IL_0010: ldc.i4.s 100
IL_0012: div
IL_0013: ret

// ✅ TryCalcLevel
IL_0000: ldarg.0
IL_0001: ldc.i4.0
IL_0002: bge.s IL_0009
IL_0004: ldarg.1                        // out 변수 주소
IL_0005: ldc.i4.0
IL_0006: stind.i4
IL_0007: ldc.i4.0                       // false 반환
IL_0008: ret
IL_0009: ldarg.1
IL_000a: ldarg.0
IL_000b: ldc.i4.s 100
IL_000d: div
IL_000e: stind.i4
IL_000f: ldc.i4.1                       // true 반환
IL_0010: ret

중요한 구분: ArgumentOutOfRangeException이 무조건 나쁜 게 아닙니다. "호출자의 명백한 버그"(예: 사용자 ID로 음수가 들어옴)일 때는 throw가 맞습니다. 하지만 "비즈니스 로직상 자주 일어나는 실패"(예: 잼이 부족함, 입력값이 잘못됨)는 Try* 또는 Result<T>로 표현해야 합니다. API 문서나 시그니처가 호출자에게 어떤 메시지를 던지느냐가 기준입니다.

5.2 함정 2 — "DB에서 못 찾음"을 NotFoundException으로

서버/DB 레이어에서 흔한 패턴입니다.

C#
// ❌ 못 찾음을 항상 예외로
public User GetUser(int id)
{
    var user = _db.Users.FirstOrDefault(u => u.Id == id);
    if (user is null) throw new UserNotFoundException(id);
    return user;
}

// ✅ 정상 시나리오면 null 반환, 호출자가 결정
public User? FindUser(int id)
{
    return _db.Users.FirstOrDefault(u => u.Id == id);
}

// 호출자: "반드시 있어야" 하는 컨텍스트면 거기서 throw
var me = _userRepo.FindUser(myId)
    ?? throw new InvalidOperationException("로그인 사용자가 사라졌습니다");
?? — null 병합 연산자 (Null-coalescing operator) 왼쪽이 null이 아니면 그 값, null이면 오른쪽 값을 반환한다. ??=는 왼쪽이 null일 때만 오른쪽 값을 대입하는 변형이다.
예시: string name = userName ?? "Guest"; userName이 null이면 "Guest"가 들어간다.

핵심은 메서드 이름입니다. Get*은 "반드시 있어야 함"이라는 강한 계약을 의미하므로 throw가 적절합니다. Find*/Try*는 "없을 수 있음"을 의미하므로 null/false를 반환하는 게 맞습니다. 이 명명 규약은 BCL 전반에 걸쳐 일관됩니다(List<T>.Find, Dictionary<K,V>.TryGetValue, List<T>[index] 등).

5.3 함정 3 — "어차피 잡히니까" 하는 마음가짐

C#
// ❌ "광범위 catch"로 흐름 제어 + 디버깅 어려움
public void LoadAndPlay(string path)
{
    try
    {
        var clip = Resources.Load<AudioClip>(path);
        audioSource.clip = clip;
        audioSource.Play();
    }
    catch (Exception)   // 무엇이 잘못됐는지 모름
    {
        // 그냥 무시
    }
}

// ✅ 실패 가능성을 명시적으로 다루고, 진짜 예외만 잡음
public bool TryLoadAndPlay(string path)
{
    var clip = Resources.Load<AudioClip>(path);
    if (clip is null)
    {
        Debug.LogWarning($"오디오 클립 없음: {path}");
        return false;
    }
    audioSource.clip = clip;
    audioSource.Play();
    return true;
}

Exception을 통째로 catch하는 것은 단지 비용 문제가 아닙니다. 버그를 숨기는 행위입니다. 진짜 IO 실패와 코드 버그가 같은 catch에서 처리되면, 운영 중 어떤 일이 일어났는지 추적할 수 없습니다.

5.4 함정 4 — Unity IL2CPP에서 throw가 더 비싼 이유

IL2CPP — Intermediate Language To C++ Unity가 모바일/콘솔/WebGL 빌드를 만들 때 사용하는 백엔드. C# 코드를 IL로 컴파일한 뒤, 그 IL을 다시 C++ 코드로 변환해서 네이티브 컴파일한다. iOS는 강제 IL2CPP, Android는 선택 가능하다.

IL2CPP 환경에서 C# 예외는 결국 C++ 예외 메커니즘을 통해 처리됩니다. 이 변환 과정에서 다음과 같은 추가 비용이 생깁니다.

비용 항목 Mono(에디터/일부 데스크톱) IL2CPP(모바일/콘솔)
throw 실행 시간 빠른 편 느림 (C++ unwinding 비용 추가)
빌드 바이너리 크기 작음 큼 (모든 메서드의 unwind 정보 포함)
GC 부담 같음 같음
핫패스 영향 더 큼

원인: C++ 예외 처리는 "예외가 발생하지 않을 때는 0 비용, 발생할 때는 매우 큰 비용"이라는 zero-cost exception model을 따릅니다. 컴파일러는 모든 함수의 unwind 정보를 미리 만들어 바이너리에 박아두고, 실제 throw 시점에는 그 테이블을 따라가며 스택을 풀어냅니다. 이 모델은 "예외가 거의 발생하지 않는다"는 가정에 최적화되어 있습니다. 따라서 모바일 핫패스에서 throw를 흐름 제어로 쓰는 것은 zero-cost 모델의 가정을 정면으로 위반하며, 데스크톱보다 더 큰 성능 손실로 돌아옵니다.

주의 — 추정 영역: IL2CPP의 throw 비용에 대한 정확한 수치는 Unity 버전, IL2CPP 컴파일 옵션, 호출 스택 깊이에 따라 크게 달라집니다. 실제 프로젝트에서 의심된다면 Unity Profiler의 GC Allocations와 CPU 마커로 직접 측정해야 합니다.

6. C# 버전별 변화

이 원칙 자체는 .NET 1.0(2002년)부터 변하지 않았습니다. 변한 것은 "예외 없이 실패를 표현할 수 있는 수단"의 풍부함입니다.

6.1 .NET Framework 1.0~2.0 — out 매개변수만 있던 시절

C#
// 2002년 — out만으로 TryXxx 패턴이 등장
bool success = int.TryParse("42", out int n);
if (success) { /* ... */ }

이 시점부터 BCL은 일관되게 Try* 메서드를 추가하기 시작했습니다(Dictionary.TryGetValue, DateTime.TryParse 등). "예외 대신 bool 반환"이 표준 관용구가 되었습니다.

6.2 C# 7 (2017) — ValueTuple로 다중 반환을 깔끔하게

C#
// out의 번거로움을 ValueTuple이 대체
public (bool Success, int Value) ParseSafe(string s)
    => int.TryParse(s, out var n) ? (true, n) : (false, 0);

// 호출 측
var (ok, value) = ParseSafe("42");

ValueTuple은 구조체이므로 힙 할당이 없습니다. 반환값을 만드는 비용 자체가 사라졌습니다.

6.3 C# 8 (2019) — Nullable Reference Types로 null 반환을 안전하게

C#
#nullable enable

// User? 시그니처가 호출자에게 "null일 수 있음"을 전달
public User? FindUser(int id)
    => _users.FirstOrDefault(u => u.Id == id);

// 호출 측 — null 체크를 컴파일러가 강제
var user = FindUser(42);
if (user is null) return;
Console.WriteLine(user.Name);  // 여기서는 user가 non-null로 흐름 분석됨

C# 8 이전의 null 반환은 호출자가 깜빡할 위험이 있었습니다. NRT(Nullable Reference Types) 도입 이후 컴파일러가 null 가드를 강제하므로, "null 반환 + NRT"가 가장 가벼운 실패 표현 방법이 되었습니다.

6.4 C# 9~13 — 패턴 매칭과 record로 Result 패턴이 자연스러워짐

C#
// C# 9 — record로 Result 정의
public abstract record Result<T>;
public sealed record Ok<T>(T Value) : Result<T>;
public sealed record Err<T>(string Reason) : Result<T>;

// C# 9 — switch 표현식으로 처리
var message = result switch
{
    Ok<int> ok      => $"성공: {ok.Value}",
    Err<int> err    => $"실패: {err.Reason}",
    _               => "알 수 없음"
};

record와 패턴 매칭 덕분에 별도 라이브러리 없이도 Result/Option 패턴을 깔끔하게 표현할 수 있게 되었습니다. 함수형 프로그래밍의 영향이 강해진 흐름입니다.

6.5 정리 — 표현 수단의 진화

시기 도구 코드 예
.NET 1.0 out 매개변수 bool TryParse(string s, out int n)
C# 7 ValueTuple (bool, int) ParseSafe(string s)
C# 8 NRT + ? User? FindUser(int id)
C# 9+ record + 패턴 매칭 Result<T> 판별 유니언

원칙(예외는 예외적인 상황에만)은 같지만, 표현 수단이 늘어나면서 "예외를 흐름 제어로 쓸 핑계"는 점점 사라지고 있습니다.


7. 정리

이 글에서 다룬 내용을 한 줄씩 압축합니다.

  • [ ] 예외는 화재경보기다. 메뉴 버튼이 아니라.
  • [ ] throw의 4단계 비용을 기억한다. ① 객체 할당 → ② 스택 트레이스 캡처(가장 비쌈) → ③ catch 매칭 → ④ 스택 되감기.
  • [ ] Parse vs TryParse의 IL을 머릿속에 그린다. .try/catch 블록이 있느냐, brfalse.s 한 줄이냐의 차이다.
  • [ ] "정상 시나리오"인지 "비정상 시나리오"인지 먼저 판단한다. 정상이면 TryXxx/튜플/Result/null+NRT, 비정상이면 throw.
  • [ ] 명명 규약을 지킨다. Get* = 반드시 있어야 함, Find*/Try* = 없을 수 있음.
  • [ ] 광범위 catch는 흐름 제어가 아니라 버그 은폐다. catch (Exception)은 진짜 마지막 안전망에서만.
  • [ ] Unity 핫패스(Update/FixedUpdate/코루틴 yield 직후)에서는 try/catch 자체를 의심한다. GC Spike의 주범이다.
  • [ ] IL2CPP 환경에서 throw 비용은 Mono보다 크다. 모바일에서는 흐름 제어용 throw가 더 위험하다.
  • [ ] 예외가 맞는 상황은 따로 있다. ArgumentNullException(인수 검증), InvalidOperationException(상태 위반), IOException(외부 시스템 장애).
  • [ ] C# 9 이후로는 record + 패턴 매칭 + NRT로 Result 패턴이 깔끔하게 표현된다. "예외 대신 무엇을 쓸지" 선택지는 충분히 많다.

PART 10에서 다룬 try/catch/finally, 예외 계층, throw, using은 모두 강력한 도구입니다. 하지만 이 도구들의 첫 번째 사용 규칙은 "필요한 곳에만 쓰는 것"입니다. 정상 흐름까지 예외로 처리하기 시작하는 순간, 코드는 느려지고 디버깅은 어려워집니다.

다음 PART(예외 처리 심화)에서는 사용자 정의 예외, 예외 필터, ExceptionDispatchInfo로 스택 정보 보존하기 등 진짜 예외가 필요한 상황에서의 고급 기법을 다룹니다.
반응형

+ Recent posts