C#LAB
C# 3.7 · 3부. 중급 문법

예외 처리 심화

내가 만든 예외와 예외 필터입니다.

예상 학습 시간 18분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 중급
개념 설명

받을 것만 받고 나머지는 보냅니다

1.10에서 예외를 받는 법을 보았습니다. 실제로 어려운 것은 문법이 아니라 무엇을 받고 무엇을 그대로 흘려보낼지 정하는 것입니다.

잘못 받으면 두 가지가 망가집니다. 하나는 무엇이 잘못됐는지 알 수 없게 되는 것이고, 다른 하나는 어디서 잘못됐는지 알 수 없게 되는 것입니다.

try {
    Save(order);
}
catch (Exception) {
    // 아무것도 하지 않습니다. 무엇이 잘못됐는지 영영 모릅니다.
}

catch (Exception ex) {
    throw ex;   // 어디서 났는지가 여기로 바뀝니다
}

이 단원은 셋을 봅니다. 조건을 붙여 받기(when), 내가 만든 예외로 알리기, 그리고 다시 던질 때 지켜야 할 것입니다.

최소 예제 · 편집 가능

같은 예외를 조건으로 나누어 받기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
var stock = new Dictionary<string, int> { ["A-100"] = 2, ["B-200"] = 0 };

foreach (var (code, count) in new[] { ("A-100", 1), ("A-100", 5), ("B-200", 3) }) {
    try {
        Order(code, count);
    }
    // 같은 예외지만 조금 남아 있을 때와 아예 없을 때를 나눕니다
    catch (OutOfStockException ex) when (ex.Left > 0) {
        Console.WriteLine($"{ex.Message} {ex.Left}개까지 됩니다");
    }
    catch (OutOfStockException ex) {
        Console.WriteLine($"{ex.Message} 지금은 주문할 수 없습니다");
    }
}

void Order(string code, int count) {
    var left = stock[code];
    if (count > left) throw new OutOfStockException(code, left);

    Console.WriteLine($"{code} {count}개 주문했습니다");
}

// 남은 수량을 예외에 담아 보냅니다
class OutOfStockException(string code, int left)
    : Exception($"재고가 모자랍니다({code}).") {

    public int Left { get; } = left;
}
출력
A-100 1개 주문했습니다 재고가 모자랍니다(A-100). 2개까지 됩니다 재고가 모자랍니다(B-200). 지금은 주문할 수 없습니다

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

catch 가 둘인데 형식은 같습니다. when 이 붙은 위쪽이 먼저 조건을 보고, 맞지 않으면 아래로 내려갑니다. 1.10의 좁은 것부터 적는 규칙이 형식뿐 아니라 조건에도 적용된다고 보면 됩니다.

상세 사용법

catch 안에서 거르는 것과 다릅니다

조건을 보고 싶으면 catch 안에서 if 로 확인한 뒤 아니면 다시 던져도 됩니다. 결과는 같아 보이지만 같지 않습니다.

받았다가 다시 던지면
catch (OutOfStockException ex) {
    if (ex.Left == 0) throw;

    // 여기 오기 전에 스택이
    // 이미 되감겼습니다
}
when 으로 거르면
catch (OutOfStockException ex)
    when (ex.Left > 0) {

    // 받기로 정해진 뒤에야
    // 스택을 되감습니다
}

when 은 스택을 되감기 전에 평가됩니다. 받지 않기로 하면 아무 일도 없었던 것처럼 예외가 지나가고, 나중에 아무도 받지 못해 프로그램이 멈추더라도 처음 던진 자리가 그대로 남아 있습니다. 받았다가 다시 던지는 쪽은 그 사이에 스택이 정리되어 진단이 어려워집니다.

순서를 실제로 보면 이렇습니다.

try {
    try { throw new InvalidOperationException("x"); }
    finally { Console.WriteLine("2. 안쪽 finally"); }
}
catch (Exception) when (Ask()) {
    Console.WriteLine("3. catch 본문");
}

static bool Ask() { Console.WriteLine("1. when 평가"); return true; }

// 1. when 평가
// 2. 안쪽 finally
// 3. catch 본문

안쪽 finally 보다 when 이 먼저 실행됩니다. 받을지 말지를 먼저 정하고, 받기로 한 뒤에 되감기가 시작되기 때문입니다.

그래서 필터 안에서 무언가를 바꾸지 마세요. 값을 고치거나 파일을 닫으면 아직 되감기지 않은 상태에서 실행되고, 조건이 거짓이면 그 일만 남고 예외는 그대로 지나갑니다. when 에는 참·거짓만 보는 식을 적습니다.

내가 만든 예외

부르는 쪽이 이 경우를 따로 처리해야 할 때만 만듭니다. 그냥 실패를 알리는 것이면 이미 있는 것으로 충분합니다.

이미 있는 것언제
ArgumentException넘겨받은 값이 규칙에 맞지 않을 때
ArgumentNullException넘겨받은 값이 null 일 때
ArgumentOutOfRangeException넘겨받은 값이 허용 범위 밖일 때
InvalidOperationException지금 상태에서는 할 수 없는 일일 때
NotSupportedException이 형식이 지원하지 않는 기능일 때

만들기로 했다면 이름을 Exception 으로 끝내고, Exception 을 상속하고, 생성자 셋을 갖추는 것이 관례입니다.

public class OrderFailedException : Exception {
    public OrderFailedException() { }
    public OrderFailedException(string message) : base(message) { }
    public OrderFailedException(string message, Exception inner)
        : base(message, inner) { }
}

셋을 갖추는 까닭은 다른 사람이 이 예외를 다룰 때 기대하는 모양이기 때문입니다. 특히 세 번째는 아래에서 올라온 예외를 안에 담을 때 필요합니다.

여기에 판단에 필요한 값을 속성으로 담습니다. 위 최소 예제의 Left 가 그것입니다. 메시지를 글자로 뜯어보게 하지 말고 값으로 꺼내 사용하게 하세요.

다시 던질 때

받아서 무언가를 한 뒤 그대로 올려 보낼 때는 throw; 라고만 적습니다. throw ex; 라고 적으면 발생한 자리가 지워집니다.

class Loader {
    public static void Keep() {
        try { Load(); }
        catch (InvalidOperationException) { throw; }        // 그대로
    }

    public static void Lose() {
        try { Load(); }
        catch (InvalidOperationException ex) { throw ex; }   // 지웁니다
    }

    static void Load() => Parse();
    static void Parse() => throw new InvalidOperationException("바닥에서 던졌습니다");
}
throw; — 던진 자리가 남습니다
at Loader.Parse()
at Loader.Load()
at Loader.Keep()
at Program.Main()
throw ex; — 두 줄이 사라졌습니다
at Loader.Lose()
at Program.Main()

오른쪽에는 ParseLoad 도 없습니다. 실제로 잘못된 자리가 기록에서 사라진 것이고, 로그만 보고 원인을 찾는 상황에서는 되돌릴 방법이 없습니다. throw ex;같은 예외 객체를 여기서 새로 던지는 것이라 그렇습니다.

감싸서 던지기

아래 계층에서 올라온 예외를 그대로 올려보내면 부르는 쪽이 아래 사정을 알아야 합니다. 주문을 넣었는데 FormatException 을 받으면 무엇을 해야 할지 알 수 없습니다. 이럴 때는 안에 담아서 올립니다.

try { int.Parse(code); }
catch (FormatException ex) {
    throw new OrderFailedException("주문을 저장하지 못했습니다", ex);
}

// 받는 쪽
catch (OrderFailedException ex) {
    Console.WriteLine(ex.Message);
    // 주문을 저장하지 못했습니다

    Console.WriteLine(ex.InnerException?.GetType().Name);
    // FormatException

    Console.WriteLine(ex.InnerException?.Message);
    // The input string '' was not in a correct format.
}

바깥은 업무의 말로 알리고, 안쪽은 원인을 그대로 담고 있습니다. InnerException 을 버리지 마세요. 그것을 버리면 throw ex; 와 같은 일이 됩니다.

던지기 전에 걸러 내기

받은 값을 확인해 스스로 던지는 코드는 도우미 메서드로 짧게 적을 수 있습니다. 매개 변수 이름은 적지 않아도 자동으로 들어갑니다.

static void Check(string name, int count) {
    ArgumentNullException.ThrowIfNull(name);
    ArgumentException.ThrowIfNullOrEmpty(name);
    ArgumentOutOfRangeException.ThrowIfNegative(count);
}

// 나오는 메시지
// ArgumentNullException       : Value cannot be null. (Parameter 'name')
// ArgumentException           : The value cannot be an empty string. (Parameter 'name')
// ArgumentOutOfRangeException : count ('-1') must be a non-negative value. (Parameter 'count')

'name' 이라는 이름은 적은 적이 없는데 들어가 있습니다. 넘긴 식을 컴파일러가 글자로 함께 넘겨 주기 때문입니다. 이 도우미들은 언어가 아니라 .NET 6 이후의 클래스 라이브러리에 들어 있습니다.

하지 말아야 할 것

  • catch — 받아 놓고 아무것도 하지 않으면 무엇이 잘못됐는지 영영 모릅니다. 정말 무시해도 되는 것이면 왜 무시해도 되는지 주석으로 남기세요.
  • catch (Exception) 으로 다 받기 — 내가 다룰 수 있는 것만 받습니다. 다룰 수 없는 것까지 받으면 위에서 처리할 기회를 빼앗습니다.
  • throw ex; — 발생한 자리를 지웁니다. throw; 라고만 적으세요.
  • 필터에서 값을 바꾸기when 은 참·거짓만 보는 자리입니다.
  • 흐름 제어에 사용하기1.10에서 본 대로, 늘 일어나는 일은 예외가 아니라 TryParse 같은 방법으로 처리합니다.
버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
5.0
6.0
7.0
8.0
9.0
11.0
14.0
  • 1.0try · catch · finally · throw · 사용자 정의 예외
  • 6.0when — 예외 필터
  • 8.0using 선언 — 닫는 일을 맡기기(3.8)
C# 6.0+

예외 필터는 C#보다 런타임에 먼저 있던 기능입니다. .NET은 처음부터 필터를 지원했고 Visual Basic 은 그것을 사용할 수 있었지만, C#에는 문법이 없어 catch 안에서 if 로 거르고 다시 던지는 수밖에 없었습니다. C# 6.0의 when이미 있던 것을 C#에서도 적을 수 있게 연 것입니다. 위에서 본 되감기 순서의 차이가 그 까닭입니다. ThrowIfNull 같은 도우미는 언어가 아니라 .NET 6 이후의 라이브러리입니다.

실습 문제

직접 해보기

1. 원인을 지우는 코드 고치기 난이도 하

아래 코드는 문제가 생겼을 때 무엇이 어디서 잘못됐는지 알 수 없게 만듭니다. 잘못된 곳 셋을 짚고 고쳐보세요.

try {
    Save(order);
}
catch (Exception ex) {
    Console.WriteLine("오류가 났습니다");
    throw ex;
}
하나는 받는 범위, 하나는 남기는 내용, 하나는 다시 던지는 방법입니다. ex 를 받아 놓고 그 안의 내용을 사용하지 않은 것도 보세요.
// ① catch (Exception) — 다룰 수 없는 것까지 받습니다 // ② "오류가 났습니다" — 무엇이 잘못됐는지가 없습니다 // ③ throw ex; — 발생한 자리를 지웁니다 try { Save(order); } catch (IOException ex) { // 내가 다룰 수 있는 것만 Console.WriteLine($"주문을 저장하지 못했습니다: {ex.Message}"); throw; // 자리를 그대로 두고 올려 보냅니다 } // 위쪽에서 업무의 말로 바꿔야 한다면 감싸서 올립니다 catch (IOException ex) { throw new OrderFailedException("주문을 저장하지 못했습니다", ex); }
2. 될 때까지 세 번 난이도 중

값을 읽어 오는 Fetch() 가 이따금 TimeoutException 을 던집니다. 시간 초과일 때만 최대 세 번까지 다시 시도하고, 다른 예외는 그대로 올려 보내세요. 세 번째도 실패하면 그 예외가 밖으로 나가야 합니다.

시도 횟수를 when 조건에 넣으면 마지막 회에는 필터가 걸리지 않아 예외가 저절로 밖으로 나갑니다. 세어 두고 if 로 다시 던질 필요가 없습니다.
for (var attempt = 1; ; attempt++) { try { Console.WriteLine(Fetch(attempt)); break; } // 세 번째에는 조건이 거짓이라 필터가 걸리지 않고 그대로 나갑니다 catch (TimeoutException ex) when (attempt < 3) { Console.WriteLine($"{attempt}번째 실패({ex.Message}). 다시 시도합니다"); } } static string Fetch(int attempt) { if (attempt < 3) throw new TimeoutException("응답이 없습니다"); return "값을 받았습니다"; } // 1번째 실패(응답이 없습니다). 다시 시도합니다 // 2번째 실패(응답이 없습니다). 다시 시도합니다 // 값을 받았습니다 // Fetch 가 계속 실패하면 세 번째 TimeoutException 이 그대로 밖으로 나갑니다. // TimeoutException 이 아닌 예외는 애초에 이 catch 에 걸리지 않습니다.
요약
  • when스택을 되감기 전에 평가됩니다. 받지 않기로 하면 처음 던진 자리가 그대로 남습니다.
  • 필터에는 참·거짓만 보는 식을 적습니다. 값을 바꾸거나 무언가를 닫지 마세요.
  • 예외는 부르는 쪽이 따로 처리해야 할 때만 만듭니다. 이름은 Exception 으로 끝내고 생성자 셋을 갖춥니다.
  • 판단에 필요한 값은 속성으로 담습니다. 메시지를 글자로 뜯어보게 하지 않습니다.
  • throw; 는 자리를 지키고 throw ex;지웁니다.
  • 아래에서 올라온 예외는 안에 담아서(InnerException) 업무의 말로 바꿔 올립니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.