---
title: "예외는 정상 흐름과 오류 흐름을 갈라 놓는다"
description: "매 줄마다 성공했는지 확인하지 않아도 됩니다. 정상 코드는 정상 코드대로 두고 오류 처리는 따로 모읍니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-exception/
---
지금까지 예외를 여러 번 만났습니다.

| 예외 | 언제 |
| --- | --- |
| `IndexOutOfRangeException` | 배열의 없는 칸을 건드릴 때 |
| `KeyNotFoundException` | `Dictionary` 에 없는 키를 꺼낼 때 |
| `NullReferenceException` | 아무것도 안 가리키는 것을 따라갈 때 |
| `InvalidOperationException` | 빈 `Stack` 에서 꺼낼 때 |
| `StackOverflowException` | 재귀가 너무 깊을 때 |

그때마다 "프로그램이 죽는다" 고만 했습니다. 이제 죽지 않게 하는 방법을 봅니다.

## 성공 여부를 돌려주면 되지 않나

앞에서 본 `TryParse` 방식이 있습니다.

```csharp
if (int.TryParse(input, out int n))
{
    ...
}
```

괜찮은 방법인데, 모든 것을 이렇게 하면 코드가 이렇게 됩니다.

```csharp
if (!TryOpenFile(path, out var file)) return false;
if (!TryReadAll(file, out var text)) return false;
if (!TryParse(text, out var data)) return false;
if (!TrySave(data)) return false;
return true;
```

하려는 일은 "열고 읽고 해석해서 저장" 네 단계인데,
**절반이 실패 확인**입니다. 정상 흐름이 오류 처리에 파묻혔습니다.

그리고 실패를 알려도 **무엇이 왜 실패했는지**는 안 넘어옵니다.
`false` 하나로는 파일이 없는 것인지 권한이 없는 것인지 모릅니다.

> **정상 코드는 정상 코드대로 두고, 오류는 따로 모아서 처리하면 됩니다.**

## try catch 는 이렇게 생겼다

```csharp
try
{
    var file = OpenFile(path);
    var text = ReadAll(file);
    var data = Parse(text);
    Save(data);
}
catch (Exception e)
{
    Console.WriteLine("실패: " + e.Message);
}
```

`try` 안은 **전부 성공한다고 치고** 적습니다. 확인이 한 줄도 없습니다.
중간 어디서든 문제가 생기면 그 자리에서 멈추고 `catch` 로 넘어옵니다.

<x-flow>
<script type="application/json">
{
  "label": "예외가 나면 어디로 가나",
  "dwell": 1.4,
  "lines": [
    "try",
    "{",
    "    var text = ReadAll(path);",
    "    var data = Parse(text);",
    "    Save(data);",
    "}",
    "catch (Exception e)",
    "{",
    "    Console.WriteLine(\"실패: \" + e.Message);",
    "}",
    "Console.WriteLine(\"계속 진행\");"
  ],
  "cases": [
    {
      "label": "다 잘 될 때",
      "path": [
        { "line": 2, "why": "읽습니다." },
        { "line": 3, "why": "해석합니다." },
        { "line": 4, "why": "저장합니다.", "out": "저장 완료" },
        { "line": 10, "why": "catch 는 건너뜁니다.", "out": "계속 진행" }
      ],
      "skip": [6, 7, 8, 9]
    },
    {
      "label": "Parse 에서 터질 때",
      "path": [
        { "line": 2, "why": "여기까지는 같습니다." },
        { "line": 3, "why": "여기서 예외가 났습니다. 이 줄이 끝나지 않습니다." },
        { "line": 8, "why": "아래를 건너뛰고 곧장 catch 로 옵니다.", "out": "실패: 형식이 잘못됨" },
        { "line": 10, "why": "처리했으니 프로그램은 계속 돕니다.", "out": "계속 진행" }
      ],
      "skip": [4],
      "note": "Save 는 실행되지 않았습니다. 흐린 줄이 건너뛴 자리입니다."
    }
  ]
}
</script>
</x-flow>

**중간에 터지면 남은 줄을 건너뜁니다.** 이게 중요합니다.
`Parse` 가 실패했는데 `Save` 가 실행되면 이상한 값이 저장됩니다.
예외는 그 자리에서 멈춰 주니 그런 일이 안 생깁니다.

## 예외도 객체다

`catch (Exception e)` 의 `e` 는 예외 객체입니다. 클래스입니다.

```csharp
catch (Exception e)
{
    Console.WriteLine(e.Message);      // 무슨 일인지
    Console.WriteLine(e.StackTrace);   // 어디서 났는지
}
```

`StackTrace` 는 앞에서 본 호출 스택입니다.
어느 메서드가 어느 메서드를 부르다가 여기까지 왔는지가 적혀 있습니다.
버그를 찾을 때 이걸 봅니다.

그리고 **예외에도 상속 관계가 있습니다.**

```
Exception
  ├─ ArgumentException
  │    └─ ArgumentNullException
  ├─ InvalidOperationException
  ├─ FormatException
  └─ ...
```

`Exception` 이 전부의 부모입니다. 그래서 `catch (Exception e)` 는 뭐든 잡습니다.

## catch 는 구체적인 것부터 적는다

여러 개를 적으면 **위에서부터** 맞는 것을 찾습니다.

```csharp
try
{
    ...
}
catch (FormatException e)
{
    Console.WriteLine("숫자 형식이 아닙니다");
}
catch (Exception e)
{
    Console.WriteLine("그 밖의 문제: " + e.Message);
}
```

순서를 뒤집으면 안 됩니다.

```csharp
catch (Exception e) { ... }        // 여기서 다 잡힙니다
catch (FormatException e) { ... }  // 여기는 영원히 실행되지 않습니다
```

앞에서 `else if` 순서를 잘못 쓰면 아래가 죽은 코드가 된다고 했습니다. 같은 이야기입니다.
다만 이건 컴파일러가 막아 줍니다. 부모를 먼저 적으면 컴파일 에러입니다.

## finally 는 어느 쪽이든 실행된다

파일을 열었으면 닫아야 합니다. 예외가 나도 닫아야 합니다.

```csharp
FileStream file = null;

try
{
    file = File.Open(path, FileMode.Open);
    ...
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}
finally
{
    file?.Close();      // 성공하든 실패하든 실행됩니다
}
```

`finally` 는 `try` 를 어떻게 빠져나가든 실행됩니다.
정상적으로 끝나도, 예외가 나도, 중간에 `return` 을 해도 실행됩니다.

정리해야 하는 것을 여기 둡니다. 파일, 연결, 잠금 같은 것들입니다.

`using` 을 쓰면 더 짧습니다.

```csharp
using (var file = File.Open(path, FileMode.Open))
{
    ...
}     // 블록을 벗어나면 자동으로 닫힙니다
```

## 예외를 직접 던질 수 있다

`throw` 로 던집니다.

```csharp
void SetAge(int age)
{
    if (age < 0)
    {
        throw new ArgumentException("나이는 음수일 수 없습니다");
    }

    this.age = age;
}
```

앞에서 잘못된 값을 조용히 고쳤습니다.

```csharp
if (value < 0) value = 0;      // 조용히 고침
```

둘 중 무엇이 맞는지는 **누구의 잘못이냐**에 달렸습니다.

- 사용자가 잘못 입력한 것 → 고치거나 다시 물어본다
- **코드가 잘못 부른 것** → 던진다

`SetAge(-5)` 를 부른 것은 프로그래머의 실수입니다.
조용히 0으로 고치면 그 실수가 묻히고, 한참 뒤에 이상한 값으로 나타납니다.
**던져서 그 자리에서 멈추게 하는 편이 낫습니다.**

## 잡을 수 있을 때만 잡는다

예외를 잡았으면 **무언가를 해야 합니다.**

```csharp
try
{
    ...
}
catch (Exception e)
{
    // 아무것도 안 함
}
```

가장 나쁜 코드입니다. 문제가 있었는데 아무도 모르게 됩니다.
나중에 값이 이상해도 어디서 시작됐는지 찾을 수가 없습니다.

**여기서 처리할 수 없으면 안 잡는 편이 낫습니다.**
안 잡으면 부른 쪽으로 올라갑니다. 거기서도 안 잡으면 더 올라갑니다.
처리할 수 있는 곳까지 올라가게 두면 됩니다.

기록만 하고 다시 던질 수도 있습니다.

```csharp
catch (Exception e)
{
    logger.Save(e.Message);
    throw;                  // 그대로 다시 던집니다
}
```

`throw;` 는 원래 예외를 그대로 올려보냅니다.
`throw e;` 라고 적으면 `StackTrace` 가 여기서부터로 덮어써집니다. 세미콜론만 쓰는 편이 낫습니다.

## 예외로 흐름을 만들지 않는다

예외는 **예상 밖의 일**에 씁니다. 흔히 일어나는 일에 쓰면 안 됩니다.

```csharp
try
{
    n = int.Parse(input);
}
catch (FormatException)
{
    n = 0;
}
```

```csharp
if (!int.TryParse(input, out n)) n = 0;
```

아래쪽이 맞습니다. 사용자가 숫자를 잘못 넣는 것은 **예상되는 일**입니다.
예상되는 것은 `if` 로 처리하고, 예외는 예상 밖의 것에 남겨 둡니다.

그리고 예외는 비쌉니다. 던지고 잡는 데 호출 스택을 훑어야 해서
반복문 안에서 자주 일어나면 눈에 띄게 느려집니다.

## 정리

- `try` 안은 전부 성공한다고 치고 적습니다. 확인이 코드를 채우지 않습니다.
- 중간에 예외가 나면 남은 줄을 건너뛰고 `catch` 로 갑니다.
- 예외도 객체입니다. `Message` 와 `StackTrace` 로 무슨 일인지 어디서 났는지 압니다.
- 예외에는 상속 관계가 있습니다. `catch` 는 구체적인 것부터 적습니다.
- `finally` 는 어떻게 빠져나가든 실행됩니다. 정리할 것을 여기 둡니다.
- 코드가 잘못 부른 것이면 `throw` 로 던집니다. 조용히 고치면 실수가 묻힙니다.
- 처리할 수 없으면 잡지 않습니다. 빈 `catch` 가 제일 나쁩니다.
- 예상되는 일은 `if` 로, 예상 밖의 일만 예외로 다룹니다.