예외 처리 심화
내가 만든 예외와 예외 필터입니다.
받을 것만 받고 나머지는 보냅니다
1.10에서 예외를 받는 법을 보았습니다. 실제로 어려운 것은 문법이 아니라 무엇을 받고 무엇을 그대로 흘려보낼지 정하는 것입니다.
잘못 받으면 두 가지가 망가집니다. 하나는 무엇이 잘못됐는지 알 수 없게 되는 것이고, 다른 하나는 어디서 잘못됐는지 알 수 없게 되는 것입니다.
try { Save(order); } catch (Exception) { // 아무것도 하지 않습니다. 무엇이 잘못됐는지 영영 모릅니다. } catch (Exception ex) { throw ex; // 어디서 났는지가 여기로 바뀝니다 }
이 단원은 셋을 봅니다. 조건을 붙여 받기(when),
내가 만든 예외로 알리기, 그리고 다시 던질 때 지켜야 할
것입니다.
같은 예외를 조건으로 나누어 받기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
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; }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
catch 가 둘인데 형식은 같습니다.
when 이 붙은 위쪽이 먼저 조건을 보고, 맞지 않으면 아래로
내려갑니다. 1.10의 좁은 것부터 적는 규칙이
형식뿐 아니라 조건에도 적용된다고 보면 됩니다.
catch 안에서 거르는 것과 다릅니다
조건을 보고 싶으면 catch 안에서
if 로 확인한 뒤 아니면 다시 던져도 됩니다. 결과는 같아
보이지만 같지 않습니다.
catch (OutOfStockException ex) { if (ex.Left == 0) throw; // 여기 오기 전에 스택이 // 이미 되감겼습니다 }
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("바닥에서 던졌습니다"); }
at Loader.Parse() at Loader.Load() at Loader.Keep() at Program.Main()
at Loader.Lose() at Program.Main()
오른쪽에는 Parse 도 Load 도
없습니다. 실제로 잘못된 자리가 기록에서 사라진 것이고, 로그만 보고
원인을 찾는 상황에서는 되돌릴 방법이 없습니다.
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.0try · catch · finally · throw · 사용자 정의 예외
- 6.0when — 예외 필터
- 8.0using 선언 — 닫는 일을 맡기기(3.8)
예외 필터는 C#보다 런타임에 먼저 있던 기능입니다. .NET은
처음부터 필터를 지원했고 Visual Basic 은 그것을 사용할 수 있었지만, C#에는
문법이 없어 catch 안에서
if 로 거르고 다시 던지는 수밖에 없었습니다.
C# 6.0의 when 은 이미 있던 것을 C#에서도
적을 수 있게 연 것입니다. 위에서 본 되감기 순서의 차이가 그
까닭입니다. ThrowIfNull 같은 도우미는 언어가
아니라 .NET 6 이후의 라이브러리입니다.
직접 해보기
아래 코드는 문제가 생겼을 때 무엇이 어디서 잘못됐는지 알 수 없게 만듭니다. 잘못된 곳 셋을 짚고 고쳐보세요.
try { Save(order); } catch (Exception ex) { Console.WriteLine("오류가 났습니다"); throw ex; }
값을 읽어 오는 Fetch() 가 이따금
TimeoutException 을 던집니다.
시간 초과일 때만 최대 세 번까지 다시 시도하고, 다른 예외는 그대로
올려 보내세요. 세 번째도 실패하면 그 예외가 밖으로 나가야 합니다.
when은 스택을 되감기 전에 평가됩니다. 받지 않기로 하면 처음 던진 자리가 그대로 남습니다.- 필터에는 참·거짓만 보는 식을 적습니다. 값을 바꾸거나 무언가를 닫지 마세요.
- 예외는 부르는 쪽이 따로 처리해야 할 때만 만듭니다. 이름은
Exception으로 끝내고 생성자 셋을 갖춥니다. - 판단에 필요한 값은 속성으로 담습니다. 메시지를 글자로 뜯어보게 하지 않습니다.
throw;는 자리를 지키고throw ex;는 지웁니다.- 아래에서 올라온 예외는 안에 담아서(
InnerException) 업무의 말로 바꿔 올립니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.