반응형

[PART10.예외 처리 기본(4/9)] 자주 만나는 예외 — 이름만이라도 기억

입문자가 매일 마주치는 예외 6종을 한눈에 분류 / 트리거 코드와 IL 증거 / TryParse · ThrowIfNull · NRT 같은 예방 도구


1. 문제 제기 — 빨간 줄에 익숙해지지 마세요

Unity 에디터에서 게임을 실행했을 때 콘솔 창에 빨간 글씨로 NullReferenceException이 떴습니다. 누군가는 "어, 또 떴네" 하고 줄 번호로 가서 찍어보고, 또 누군가는 "빨간 줄이 평소보다 좀 더 길게 떴네" 하면서 그냥 무시합니다. 빨간 글씨에 익숙해지는 순간, 디버깅 능력이 그 자리에서 멈춥니다.

C#의 예외 메시지는 "어디가 어떻게 아픈지 적어준 진단서" 입니다. 진단서에 적힌 병명(예외 이름)을 모르면 처방을 내릴 수 없습니다. 그런데 신입 개발자가 마주치는 예외는 사실 6종 으로 거의 추려집니다. 이 6종의 이름과 의미만 머릿속에 박아두면, 콘솔 창의 빨간 줄을 보는 순간 "어, 이건 인자 검증 문제구나", "이건 컬렉션 상태 문제구나" 하는 식으로 곧장 원인 후보가 좁혀집니다.

이번 글에서는 입문자(특히 Unity 신입)가 자주 만나는 예외 6종을 다룹니다.

  1. NullReferenceException — null 참조 멤버 접근
  2. ArgumentException 계열 (ArgumentNullException, ArgumentOutOfRangeException) — 메서드 인자 검증
  3. InvalidOperationException — 객체 상태 부적합
  4. FormatException — 문자열 형식 오류
  5. IndexOutOfRangeException — 배열·문자열 인덱스 범위 초과

각각 언제 발생하는지(트리거 코드), 상속 계층상 어떤 의미인지, 어떻게 예방하는지(NRT, ?., ThrowIfNull, TryParse, Length 검증, Span<T>) 를 IL 증거와 함께 정리합니다. 마지막에는 callvirtcall이 null 체크에서 어떻게 다르게 동작하는지, Unity 모바일 핫패스에서 예외 비용이 왜 치명적인지까지 짚고 끝냅니다.

비고 — 본 글은 PART 10의 4번째 글입니다. 1~3번 글에서 다룬 try/catch/finally, 예외 계층 분기, throw 사용 규칙은 이미 알고 있다고 전제합니다.

2. 개념 정의 — 6종 예외의 가족 관계

2.1 비유 — 119 신고 코드와 같다

응급 상황을 119에 신고할 때 "1번 코드(화재)", "2번 코드(구급)" 하는 식으로 분류 코드가 있다고 상상해 봅시다. 분류 코드 덕분에 출동 차량과 인력이 현장에 도착하기 전에 필요한 장비를 챙길 수 있습니다. 예외도 마찬가지입니다. NullReferenceException이라는 분류 코드 하나만 봐도 "어떤 변수가 null인지 찾아라" 라는 처방이 자동으로 따라옵니다.

CLR(Common Language Runtime, .NET 코드를 실행하는 런타임)이 던지는 모든 예외는 System.Exception을 뿌리로 하는 트리 안에 있습니다. 이 트리에서 같은 가지에 있는 예외끼리는 원인의 성격이 비슷합니다. 그래서 가족 관계만 알아도 절반은 진단됩니다.

2.2 시각화 — 6종 예외 상속 트리

System.Exception

2.3 트리에서 읽어내는 단서

위 트리에서 두 가지를 읽어냅니다.

  1. 모두 SystemException 직속 자식이다. SystemException은 "프로그래머 실수로 발생한 시스템 레벨 오류"를 의미합니다. 즉 6종 모두 사용자 입력 문제가 아니라 코드를 잘못 짠 것 이라는 강한 신호입니다.
  2. ArgumentNullExceptionArgumentOutOfRangeExceptionArgumentException의 자식이다. 이 둘은 "인자 검증 가족"입니다. catch (ArgumentException) 한 줄로 두 자식을 모두 잡을 수 있습니다.
참고IndexOutOfRangeException은 이름이 비슷한 ArgumentOutOfRangeException가족 관계가 아닙니다. 전자는 CLR이 배열 경계 검사 중 던지는 시스템 예외, 후자는 메서드 인자 검증용 예외로 출처가 다릅니다.

2.4 6종 한 줄 요약

예외 한 줄 진단
NullReferenceException "텅 빈 리모컨 버튼을 눌렀어요." 변수가 null인데 멤버 접근.
ArgumentNullException "메서드에 필수 인자를 빼먹었어요." 인자가 null.
ArgumentOutOfRangeException "메서드에 허용 범위 밖의 값을 줬어요." 음수, 너무 큰 수 등.
InvalidOperationException "지금 이 객체는 그 작업을 받을 상태가 아니에요." 빈 컬렉션의 First, 닫힌 스트림.
FormatException "이 문자열은 숫자로 못 바꿔요." int.Parse("abc").
IndexOutOfRangeException "없는 칸을 가리켰어요." 배열 인덱스가 0..Length-1을 벗어남.

이 6줄을 외워두면 콘솔의 빨간 줄을 보는 순간 진단의 절반이 끝납니다.


3. 내부 동작 — callvirt 한 명령어로 보는 NullReferenceException

3.1 비유 — 호출 직전의 보안 검사대

NullReferenceException은 6종 중 가장 자주 발생하는 만큼, CLR이 어떻게 이걸 잡아내는지 를 한 번은 볼 가치가 있습니다. 비유하자면 모든 인스턴스 메서드 호출은 공항 보안 검사대 를 통과합니다. 검사대에서 "이 사람(this 참조)이 살아 있는 사람인가요(not null)?"를 묻고, null이면 그 자리에서 NullReferenceException을 던집니다. 메서드 안으로는 들어가지도 못합니다.

이 검사대를 IL 레벨에서 실제로 보여주는 것이 callvirt 명령어입니다.

3.2 시각화 — call vs callvirt 의 null 체크 차이

call (정적 호출)

3.3 코드와 IL — 같은 메서드 호출인데 명령어가 다르다

Unity에서 매니저 객체의 정적 메서드를 부르는 것과, 플레이어 인스턴스의 프로퍼티에 접근하는 것은 IL 레벨에서 명령어가 갈립니다.

C#
public class Player {
    public int HP { get; set; } = 100;
}

public class CallVsCallvirt {
    // 정적 메서드 — call
    public static int StaticAdd(int a, int b) => a + b;

    // 인스턴스 멤버 접근 — callvirt
    public static int InstanceAccess(Player p) => p.HP;
}

위 두 메서드의 IL을 보면 호출 명령어가 정확히 갈립니다.

IL
.method public hidebysig static int32 StaticAdd(int32 a, int32 b)
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: add                  // 단순 정수 덧셈, 호출 자체가 없음
    IL_0003: ret
}

.method public hidebysig static int32 InstanceAccess(class Player p)
{
    IL_0000: ldarg.0              // 스택에 p (Player 참조) 로드
    IL_0001: callvirt instance int32 Player::get_HP()
    //        ↑ callvirt: p가 null인지 먼저 검사 → null이면 NRE
    IL_0006: ret
}

핵심은 callvirt호출 직전에 스택 맨 위 참조(여기서는 p)가 null인지를 반드시 검사 한다는 것입니다. C# 컴파일러는 Player.HP가 가상 멤버가 아니어도 일부러 callvirt를 사용합니다. 이유는 단 하나, null 체크를 보장하기 위해서 입니다. 만약 call로 호출했다면 null인 채로 메서드에 진입한 뒤 메서드 내부에서 this의 필드에 접근할 때 비로소 예외가 터지므로 디버깅이 더 어려워집니다.

?. — null 조건부 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이면 오른쪽 멤버 접근을 건너뛰고 전체 표현식을 null로 만든다. NRE를 피하면서 짧게 쓸 수 있다.
예시: int? hp = player?.HP; player가 null이면 hp는 null, 아니면 player.HP

3.4 ?. 가 만들어내는 IL — null 체크가 사용자 코드에 박힌다

?.를 쓰면 컴파일러가 null 체크 분기를 IL에 직접 박아 넣습니다. 호출 자체가 일어나지 않으므로 callvirt의 보안 검사대를 통과할 필요조차 없습니다.

C#
public static int NullConditional(Player p) => p?.HP ?? 0;
public static int DirectAccess(Player p) => p.HP;
IL
// p?.HP ?? 0
.method public hidebysig static int32 NullConditional(class Player p)
{
    IL_0000: ldarg.0
    IL_0001: brtrue.s IL_0005     // p가 null 아니면 IL_0005로 점프
    IL_0003: ldc.i4.0             // p가 null이면 0 반환
    IL_0004: ret
    IL_0005: ldarg.0
    IL_0006: call instance int32 Player::get_HP()  // ← call! callvirt 아님
    IL_000b: ret
}

// p.HP — null 무방비
.method public hidebysig static int32 DirectAccess(class Player p)
{
    IL_0000: ldarg.0
    IL_0001: callvirt instance int32 Player::get_HP()  // ← null이면 즉시 NRE
    IL_0006: ret
}

흥미로운 점은 ?.를 쓴 IL의 두 번째 호출이 callvirt가 아니라 call 이라는 것입니다. 이미 사용자 코드에서 null 체크(brtrue.s)를 했기 때문에 컴파일러가 "여기서 callvirt의 null 체크는 중복이다"라고 판단해 더 가벼운 call을 씁니다. 이게 ?.가 단순한 문법 설탕이 아니라 실제로 약간 더 빠른 코드를 만들어내는 이유 입니다.


4. 실전 적용 — Before/After로 보는 6종 방어법

이 섹션은 Unity 신입이 매일 마주치는 패턴을 6종 예외별로 Before/After로 정리합니다.

4.1 NullReferenceException — ?., NRT, ThrowIfNull

Unity에서 가장 자주 보는 패턴입니다. GetComponent<T>()가 null을 반환했는데 그대로 멤버를 부르는 경우입니다.

Before — null 체크 없이 직접 접근

C#
void Start() {
    var rb = GetComponent<Rigidbody>(); // 컴포넌트가 없으면 null
    rb.useGravity = false;              // NullReferenceException
}

After — ?.로 안전하게

C#
void Start() {
    var rb = GetComponent<Rigidbody>();
    if (rb != null) {
        rb.useGravity = false;
    }
    // 또는 한 줄로:
    // GetComponent<Rigidbody>()?.SetActive(false); 같은 호출 형태에서 강력
}
NRT — Nullable Reference Types (널 가능 참조 타입) C# 8.0부터 도입된 컴파일러 기능. 프로젝트 파일에 <Nullable>enable</Nullable>을 켜면 참조 타입의 null 가능성을 타입 시스템이 추적한다. string은 null 불가능, string?은 null 가능으로 구분되며, null일 수 있는 변수를 검사 없이 사용하면 컴파일 경고가 뜬다.

더 근본적인 해결 — NRT 활성화

.csproj<Nullable>enable</Nullable>을 추가하면 컴파일 시점에 null 가능성 위반이 경고로 떠서 런타임 NRE를 사전에 차단합니다. Unity 2022 이상에서는 어셈블리 정의 파일(.asmdef)에서도 NRT를 켤 수 있습니다.

ThrowIfNull — 가드 절 헬퍼 (.NET 6+) ArgumentNullException.ThrowIfNull(arg) 한 줄로 인자 null 검사를 끝낸다. 내부적으로 if (arg is null) throw new ArgumentNullException(nameof(arg)); 와 동일한 코드를 생성하지만, [CallerArgumentExpression] 덕분에 인자 이름까지 자동으로 잡아준다.

4.2 ArgumentNullException / ArgumentOutOfRangeException — 가드 절 한 줄

메서드 시작 부분에서 빠르게 실패(Fail Fast) 하는 가드 절을 두면, 잘못된 값이 메서드 깊숙이 흘러 들어가서 엉뚱한 곳에서 터지는 일을 막을 수 있습니다.

Before — 가드 없음

C#
public static void NoGuard(string name, int level) {
    Console.WriteLine(name.Length + level); // name이 null이면 여기서 NRE
}

After — ThrowIfNull + ThrowIfNegative 가드

C#
public static void Guarded(string name, int level) {
    ArgumentNullException.ThrowIfNull(name);
    ArgumentOutOfRangeException.ThrowIfNegative(level);
    Console.WriteLine(name.Length + level);
}

IL을 비교하면 가드 절이 메서드 진입 직후에 한 줄짜리 호출로 박혀 있는 것을 볼 수 있습니다.

IL
// Before: NoGuard
.method public hidebysig static void NoGuard(string name, int32 level)
{
    IL_0000: ldarg.0
    IL_0001: callvirt instance int32 [System.Runtime]System.String::get_Length()
    //        ↑ name이 null이면 여기서 NRE — "어디서 null이 들어왔지?" 추적해야 함
    IL_0006: ldarg.1
    IL_0007: add
    IL_0008: call void [System.Console]System.Console::WriteLine(int32)
    IL_000d: ret
}

// After: Guarded
.method public hidebysig static void Guarded(string name, int32 level)
{
    IL_0000: ldarg.0
    IL_0001: ldstr "name"
    IL_0006: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)
    //        ↑ 메서드 진입 직후 가드 → 명확한 ArgumentNullException("name")
    IL_000b: ldarg.1
    IL_000c: ldstr "level"
    IL_0011: call void [System.Runtime]System.ArgumentOutOfRangeException::ThrowIfNegative<int32>(!!0, string)
    IL_0016: ldarg.0
    IL_0017: callvirt instance int32 [System.Runtime]System.String::get_Length()
    IL_001c: ldarg.1
    IL_001d: add
    IL_001e: call void [System.Console]System.Console::WriteLine(int32)
    IL_0023: ret
}

차이는 예외 메시지의 명확성 입니다. Before는 NullReferenceException: Object reference not set to an instance of an object로 끝나서 어떤 인자가 null이었는지 추적해야 합니다. After는 ArgumentNullException: Value cannot be null. (Parameter 'name')로 어떤 인자가 문제인지 즉시 알려줍니다. 이게 Fail Fast 의 본질입니다.

4.3 FormatException — Parse 대신 TryParse

Unity에서 사용자 입력(InputField)이나 외부 JSON 파일에서 가져온 문자열을 숫자로 변환할 때 가장 흔한 실수입니다.

Before — Parsetry/catch로 감싸기

C#
public static int ParseBad(string s) {
    try { return int.Parse(s); }
    catch (FormatException) { return 0; }
}

After — TryParse 패턴

C#
public static int ParseGood(string s) {
    return int.TryParse(s, out int n) ? n : 0;
}

IL을 보면 차이가 극명합니다.

IL
// Before: ParseBad — try 블록과 catch 핸들러가 IL에 박힘
.method public hidebysig static int32 ParseBad(string s)
{
    .try
    {
        IL_0000: ldarg.0
        IL_0001: call int32 [System.Runtime]System.Int32::Parse(string)
        //        ↑ 실패 시 FormatException 객체 할당 + 스택 추적 + 던지기 + catch
        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
    }
    ...
}

// After: ParseGood — 분기 한 번으로 끝
.method public hidebysig static int32 ParseGood(string s)
{
    IL_0000: ldarg.0
    IL_0001: ldloca.s 0
    IL_0003: call bool [System.Runtime]System.Int32::TryParse(string, int32&)
    //        ↑ 실패 시 false 반환, 예외 객체 할당 없음
    IL_0008: brtrue.s IL_000c
    IL_000a: ldc.i4.0
    IL_000b: ret
    IL_000c: ldloc.0
    IL_000d: ret
}

After 쪽에는 .try 블록도 catch 핸들러도 없습니다. 단순한 분기(brtrue.s) 하나로 끝납니다. 입력이 자주 잘못될 가능성이 있는 핫패스(예: 매 프레임 갱신되는 UI)에서는 이 차이가 그대로 GC 압박과 프레임 드랍으로 이어집니다(자세한 비용 분석은 5.4 참조).

4.4 IndexOutOfRangeException — 길이 검증 또는 Span<T>

배열·문자열·List<T>에 인덱스로 접근하는 코드는 모두 인덱스가 0..Length-1 안에 있는지 가 책임입니다.

Before — 무방비 접근

C#
public static int FirstUnsafe(int[] arr) => arr[0]; // 빈 배열이면 IndexOutOfRangeException

After — Length 검증

C#
public static int FirstSafe(int[] arr) {
    if (arr == null || arr.Length == 0) return 0;
    return arr[0];
}
IL
// Before
.method public hidebysig static int32 FirstUnsafe(int32[] arr)
{
    IL_0000: ldarg.0
    IL_0001: ldc.i4.0
    IL_0002: ldelem.i4         // ← CLR이 여기서 자체 경계 검사 → 실패 시 IndexOutOfRange
    IL_0003: ret
}

// After
.method public hidebysig static int32 FirstSafe(int32[] arr)
{
    IL_0000: ldarg.0
    IL_0001: brfalse.s IL_0007 // null이면 fallback
    IL_0003: ldarg.0
    IL_0004: ldlen             // 배열 길이 로드
    IL_0005: brtrue.s IL_0009  // 길이 != 0 이면 진입
    IL_0007: ldc.i4.0
    IL_0008: ret
    IL_0009: ldarg.0
    IL_000a: ldc.i4.0
    IL_000b: ldelem.i4
    IL_000c: ret
}

ldelem.i4(배열 인덱스로 int32 로드) 명령어 자체에는 이미 CLR이 경계 검사를 포함합니다. 그래서 C#은 자바와 마찬가지로 메모리 오염은 절대 일어나지 않습니다. 다만 검사에 실패하면 예외가 발생하고, 5.4의 비용을 지불합니다. 사용자 코드에서 미리 길이를 확인하는 편이 항상 더 빠릅니다.

Span<T> — C# 7.2 도입된 메모리 슬라이스 타입 배열·stackalloc 메모리·문자열의 연속된 영역을 복사 없이 가리키는 참조 타입. ref struct이므로 스택에만 살고, GC 할당이 없다. Slice(start, length)로 부분 영역을 만들 수 있고, 모든 인덱스 접근에 경계 검사가 포함되어 안전하다.

Span<T>와 C# 8.0 범위 연산자(..)를 쓰면 부분 배열을 안전하고 빠르게 다룰 수 있습니다.

C#
public static int Sum(int[] arr) {
    Span<int> slice = arr.AsSpan(0, Math.Min(10, arr.Length));
    int total = 0;
    foreach (var n in slice) total += n;
    return total;
}

arr.AsSpan(start, length)는 length가 배열 범위를 벗어나면 호출 시점에 즉시 ArgumentOutOfRangeException 을 던집니다. 즉 슬라이스를 만든 뒤에는 슬라이스 안에서 인덱스 사고가 날 일이 없습니다.

4.5 InvalidOperationException — Any() 또는 FirstOrDefault()

LINQ의 First()는 빈 컬렉션이면 InvalidOperationException("Sequence contains no elements")을 던집니다. 적의 목록에서 가장 가까운 적을 찾는 코드에서 적이 0명이면 그대로 터집니다.

Before — 빈 컬렉션 무방비

C#
var nearest = enemies.OrderBy(e => Vector3.Distance(player.position, e.position)).First();
//                                                                                ↑ 적 0명이면 InvalidOperation

After — FirstOrDefault

C#
var nearest = enemies.OrderBy(e => Vector3.Distance(player.position, e.position)).FirstOrDefault();
if (nearest != null) {
    nearest.TakeDamage(10);
}

FirstOrDefault()는 빈 컬렉션이면 참조 타입의 경우 null, 값 타입의 경우 default(T)를 반환합니다. 예외 없이 흐름 제어가 가능합니다.

또 다른 흔한 케이스는 foreach 도중 컬렉션 수정 입니다.

C#
// Before — InvalidOperationException("Collection was modified")
foreach (var bullet in bullets) {
    if (bullet.IsDead) bullets.Remove(bullet); // 순회 중 컬렉션 변경
}

// After — 인덱스 역순 또는 별도 리스트
for (int i = bullets.Count - 1; i >= 0; i--) {
    if (bullets[i].IsDead) bullets.RemoveAt(i);
}

5. 함정과 주의사항 — 신입이 자주 밟는 지뢰

5.1 ❌ Unity 객체 비교에 ?. 를 쓰면 안 된다

Unity에서는 ?.를 무조건 좋다고 쓰면 안 됩니다. UnityEngine.Object== 연산자를 오버로드 해서 "C# 참조는 살아 있지만 네이티브 오브젝트가 파괴된 상태"를 null처럼 보이게 합니다. ?.는 이 오버로드를 우회하므로 실제로는 파괴된 객체에 접근 할 수 있습니다.

❌ 잘못된 예 — Unity에서는 위험

C#
// Destroy(go) 후에도 go는 C# 참조로는 null이 아니다
go?.SetActive(false);  // 파괴된 게임 오브젝트의 메서드 호출 → Unity가 경고

✅ 올바른 예 — Unity 객체는 명시적 null 체크

C#
if (go != null) {        // ← Unity의 == 오버로드 호출
    go.SetActive(false);
}

이 함정은 IL로 보면 명확합니다. ?.는 IL 레벨의 brtrue/brfalse로 컴파일되는데, 이 명령어는 참조가 null인지만 체크하지 Unity의 사용자 정의 ==는 호출하지 않습니다.

5.2 ❌ 예외를 흐름 제어 수단으로 쓰지 말 것

대표적인 안티패턴입니다. "사용자 입력이 숫자인지 확인하기 위해" int.Parsetry/catch로 감싸는 코드입니다.

C#
// ❌ 흐름 제어를 예외로 — 매 호출마다 예외 객체 할당 + 스택 추적
bool IsNumeric(string s) {
    try { int.Parse(s); return true; }
    catch { return false; }
}

// ✅ TryParse 패턴
bool IsNumeric(string s) => int.TryParse(s, out _);

벤치마크 차이는 압도적입니다. 입력이 자주 잘못되는 환경(외부 파일, 사용자 입력)에서는 TryParseParse + try/catch보다 약 1,000배 빠릅니다. 이유는 5.4에서 IL과 함께 분석합니다.

5.3 ❌ catch (Exception) 으로 모든 예외 삼키기

C#
// ❌ 모든 예외 삼킴 — OutOfMemoryException 같은 치명적 오류도 무시됨
try {
    LoadSaveData();
} catch (Exception) {
    // 무시
}

// ✅ 처리할 수 있는 예외만 명시적으로
try {
    LoadSaveData();
} catch (FormatException ex) {
    Debug.LogError($"세이브 파일 형식 오류: {ex.Message}");
} catch (FileNotFoundException) {
    CreateDefaultSave();
}

catch (Exception)버그를 숨기는 도구 입니다. 디버깅이 필요한 시점에 빨간 줄이 안 떠서 문제가 더 깊은 곳에서 다른 모습으로 터지게 됩니다.

5.4 핫패스에서 예외 비용 — IL이 보여주는 진실

4.3의 IL을 다시 봅시다. ParseBad.try 블록과 catch 핸들러가 메서드 자체에 박혀 있습니다. 실패 시 CLR이 수행하는 일은 다음과 같습니다.

  1. FormatException 객체를 힙에 할당 — GC 대상 발생
  2. 스택 추적(StackTrace) 수집 — 호출 스택 전체를 문자열로 변환, 비용이 매우 큼
  3. 스택 해제(Unwinding)catch 블록을 찾을 때까지 호출 스택을 거슬러 올라감

ParseGoodfalse를 반환할 뿐입니다. 위 세 단계가 모두 없습니다. Unity의 Update()처럼 매 프레임 호출되는 핫패스에서 잘못된 입력으로 예외를 매 프레임 던지면 모바일 기기에서 즉각적인 프레임 드랍과 GC 스파이크 가 발생합니다. IL2CPP로 빌드해도 이 비용은 줄지 않습니다 — IL2CPP는 IL을 C++로 번역할 뿐 예외 처리 메커니즘 자체는 그대로입니다.

결론 — 예외는 "예외적인 상황" 에만 사용하세요. if-elseTryXxx 패턴으로 처리할 수 있는 케이스를 절대 try/catch로 묶지 마세요.

5.5 ❌ ArgumentNullException 자식 예외를 ArgumentException 던지기로 대체

C#
// ❌ 너무 일반적 — 호출자가 어떤 종류의 잘못인지 알기 어렵다
if (name == null) throw new ArgumentException("name", nameof(name));

// ✅ 구체적으로
if (name == null) throw new ArgumentNullException(nameof(name));
if (level < 0) throw new ArgumentOutOfRangeException(nameof(level), level, "음수 불가");

호출자는 catch (ArgumentNullException)처럼 자식 단위로 더 정확하게 잡고 싶어 합니다. 부모 클래스로 던지면 그 가능성을 박탈합니다.


6. C# 버전별 변화 — 입문자 방어 도구가 진화해 온 길

도구 도입 버전 효과
?. null 조건부 C# 6.0 (2015) NRE 무방비 멤버 접근 차단
?? null 병합 C# 6.0 (2015) null일 때 기본값
식 본문 멤버 (=>) C# 6.0 (2015) 가드 절을 한 줄로
Span<T> C# 7.2 (2017) 인덱스 접근 안전 + GC 없음
Nullable 참조 타입 C# 8.0 (2019) NRE를 컴파일 시점에 잡기
인덱스/범위 (^, ..) C# 8.0 (2019) 안전한 슬라이싱
??= null 병합 할당 C# 8.0 (2019) "null이면 할당" 한 줄
ArgumentNullException.ThrowIfNull .NET 6 (2021) 가드 절 한 줄 헬퍼
ArgumentOutOfRangeException.ThrowIfNegative .NET 8 (2023) 범위 가드 한 줄

가장 큰 분기점은 C# 8.0의 NRT 입니다.

Before — C# 7.x 이하

C#
public void Greet(string name) {
    if (name == null) throw new ArgumentNullException(nameof(name));
    Console.WriteLine($"Hello, {name}");
}

name이 null일 수 있다는 점을 코드를 읽는 사람이 직접 추론해야 했습니다.

After — C# 8.0+ NRT 활성화

C#
#nullable enable
public void Greet(string name) {                  // ← null 허용 안 함
    Console.WriteLine($"Hello, {name}");          // 안전
}

public void GreetOptional(string? name) {         // ← null 허용
    Console.WriteLine($"Hello, {name ?? "Guest"}");
}

타입에 ?가 없으면 컴파일러가 "이 매개변수에는 null이 들어오면 안 된다"고 보장합니다. 호출자가 null을 넘기려 하면 컴파일 시점에 경고가 뜹니다. 즉 NRE를 코드를 짜는 시점에 잡습니다.

.NET 6 — ArgumentNullException.ThrowIfNull

가드 절 보일러플레이트가 한 줄로 줄어들었습니다.

C#
// 이전 — 3줄
if (name == null) throw new ArgumentNullException(nameof(name));

// .NET 6+ — 1줄, 인자 이름 자동 추출
ArgumentNullException.ThrowIfNull(name);

ThrowIfNull은 내부적으로 [CallerArgumentExpression] 어트리뷰트를 사용해 인자 이름을 자동으로 잡습니다. nameof(name)을 직접 쓸 필요가 없습니다(이 어트리뷰트는 PART 10의 8번 글에서 다룹니다).


7. 정리 — 이름만이라도 기억하면 절반은 끝

이번 글에서 다룬 6종 예외와 즉시 적용할 수 있는 처방을 한 번에 모았습니다.

6종 예외 진단 카드

빨간 줄 메시지 진단 1차 처방
NullReferenceException 변수가 null인데 멤버 접근 ?., NRT, if (x != null)
ArgumentNullException 메서드 인자가 null ArgumentNullException.ThrowIfNull(arg)
ArgumentOutOfRangeException 인자 값이 허용 범위 밖 ArgumentOutOfRangeException.ThrowIfNegative(arg)
InvalidOperationException 객체 상태가 작업에 부적합 Any() 체크, FirstOrDefault(), 역순 for
FormatException 문자열 → 숫자 변환 실패 int.TryParse(s, out var n)
IndexOutOfRangeException 배열·문자열 인덱스 범위 초과 Length 검증, Span<T>, .. 범위

체크리스트 — 코드 작성 시 매번 확인

  • [ ] 메서드 시작 부분에 가드 절(ThrowIfNull, ThrowIfNegative)을 두었는가?
  • [ ] 외부 입력(InputField, JSON, PlayerPrefs)을 변환할 때 Parse 대신 TryParse를 썼는가?
  • [ ] 배열·List<T> 인덱스 접근 전에 Length/Count 검증을 했는가?
  • [ ] LINQ의 First() 대신 FirstOrDefault()를 검토했는가?
  • [ ] ?.UnityEngine.Object에 쓰고 있지 않은가? (Unity 객체는 if (x != null))
  • [ ] 핫패스(Update, FixedUpdate)에 try/catch로 흐름 제어하는 코드는 없는가?
  • [ ] catch (Exception)으로 모든 예외를 삼키고 있지 않은가?
  • [ ] NRT(<Nullable>enable</Nullable>)를 켜서 컴파일 시점에 NRE를 잡고 있는가?

한 문장 요약

6종 예외의 이름과 가족 관계만 외우면 콘솔 빨간 줄을 보는 순간 진단의 절반이 끝나고, ThrowIfNull · TryParse · Length 검증 · ?. 네 가지 도구만 손에 익히면 발생 자체를 막을 수 있습니다.

다음 글에서는 예외 처리와 짝을 이루는 또 하나의 핵심 — usingIDisposable을 통한 자원 정리 를 다룹니다.

반응형

+ Recent posts