예외를 잡는 것은 오류 처리의 절반이고, 나머지 절반은 올바른 예외를 던지는 것입니다. 잘 고른 예외는 무엇이 잘못되었는지, 그리고 그것이 호출하는 쪽의 실수인지 프로그램 상태의 문제인지를 호출하는 쪽에 정확히 알려 줍니다. 이 페이지는 던지는 쪽을 다루고, 처리는 try catch에서 다룹니다.
throw 문과 보호 절
throw는 예외 객체를 받습니다. 메서드의 실행은 거기서 멈추고, 예외는 호출 스택을 따라 처리기를 찾습니다. 가장 흔한 용도는 보호 절입니다. 메서드 맨 위에서 작업을 시작하기 전에 잘못된 입력을 거부하는 검사입니다.
출력:
ArgumentOutOfRangeException for parameter 'amount'
InvalidOperationException: The account is frozen.
Balance: 100
nameof(amount)는 문자열 "amount"를 만들고, 매개변수 이름이 바뀌어도 맞게 유지됩니다. 인수 예외는 이를 ParamName에 저장하며, 도구와 로그가 잘못된 인수를 가리키는 데 씁니다.
보호 절은 메서드의 나머지를 단순하게 유지해 줍니다. 검사를 통과한 뒤의 코드는 입력이 올바르다고 가정할 수 있습니다. 또 잘못된 값이 계속 이동해 세 메서드 뒤에서 헷갈리는 NullReferenceException을 일으키게 두지 않고 실수한 지점에서 실패합니다.
어떤 예외 타입을 던질까
상황을 설명하는 기본 제공 타입이 있으면 재사용하세요. 호출하는 쪽은 이미 그것을 처리하는 방법을 압니다.
| 상황 | 던질 것 |
|---|---|
필수 인수가 null | ArgumentNullException |
| 인수가 허용 범위 밖(음수 수량, 끝을 넘은 인덱스) | ArgumentOutOfRangeException |
| 인수가 다른 방식으로 잘못됨(빈 이름, 형식이 틀린 ID) | ArgumentException |
| 객체의 현재 상태에서는 올바르지 않은 호출 | InvalidOperationException |
이 타입이 절대 지원하지 않는 연산(읽기 전용 컬렉션의 Add) | NotSupportedException |
| 메서드가 아직 작성되지 않음 | NotImplementedException |
Dispose 뒤에 객체를 사용함 | ObjectDisposedException |
| 시간 제한이 있는 연산의 시간이 다 됨 | TimeoutException |
처음 세 행과 InvalidOperationException의 경계는 누가 무언가를 바꿔야 하느냐입니다. 인수 예외는 "다르게 호출하라"고 말합니다. InvalidOperationException은 "호출은 괜찮았지만 지금은 아니다"라고 말합니다.
Exception, SystemException, ApplicationException을 직접 던지지 마세요. 호출하는 쪽이 다른 모든 것까지 함께 잡지 않고는 이를 잡을 수 없습니다. NullReferenceException, IndexOutOfRangeException, StackOverflowException도 직접 던지지 마세요. 런타임이 실제 버그를 위해 남겨 둔 것입니다.
throw 식
C# 7 이전에 throw는 문장일 뿐이었습니다. C# 7부터는 세 곳에서 식으로도 쓸 수 있어서, 흔한 검사를 한 줄로 바꿔 줍니다:
출력:
Ana <ana@example.com>
Null: name
ArgumentException: email
ArgumentNullException은 ArgumentException에서 파생되므로, 먼저 둔 catch (ArgumentException)은 null 경우도 잡는다는 점에 주의하세요. 둘을 모두 처리할 때 catch 절의 순서가 중요한 것도 같은 이유입니다.
ThrowIfNull과 친구들(.NET 6 이상)
최신 .NET은 매개변수 이름을 자동으로 캡처하면서 검사와 예외 던지기를 대신 써 주는 정적 헬퍼를 추가했습니다:
public void Ship(Order order, int quantity, string address)
{
ArgumentNullException.ThrowIfNull(order); // .NET 6
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); // .NET 8
ArgumentException.ThrowIfNullOrWhiteSpace(address); // .NET 8
ObjectDisposedException.ThrowIf(disposed, this); // .NET 7
// ...
}
이들은 직접 쓴 if와 throw와 똑같이 동작하며, 보호 절을 각각 한 줄로 유지해 줍니다. 이전 대상에서는 앞에서 보인 if 형태를 쓰세요.
사용자 정의 예외 클래스 작성하기
호출하는 쪽이 이 특정 실패를 따로 잡아야 하거나, 처리기에 메시지 문자열로는 잘 담을 수 없는 데이터가 필요할 때 직접 예외 타입을 만드세요.
출력:
Cannot withdraw 25 from a balance of 15.
Short by 10
관례:
- 이름은
Exception으로 끝납니다. Exception에서 파생합니다(InvalidOperationException처럼 더 구체적인 기본 제공 타입의 특수한 경우라면 그 타입에서).- 세 가지 표준 생성자가 있습니다. 인수 없는 것, 메시지, 메시지와 내부 예외입니다. 그 위에 직접 만든 생성자를 추가하세요.
- 추가 데이터는 생성자에서 설정하는 읽기 전용 속성에 둡니다. 그러면 처리기가 메시지를 파싱하는 대신
e.Requested로 행동할 수 있습니다.
내부 예외로 감싸기
저수준 실패가 더 높은 수준의 실패로 드러나야 할 때는 감싸세요. 원래 예외는 InnerException으로 유지되므로 정보가 사라지지 않습니다:
출력:
Setting 'port' must be a number, got '80a'.
Caused by: FormatException
이제 호출하는 쪽은 자신이 이해하는 설정이라는 관점에서 다루게 되고, e.ToString()을 기록하면 FormatException과 그 스택 추적을 포함한 연쇄 전체가 출력됩니다. 의미를 더할 때만 감싸세요. 모든 예외를 범용 MyAppException으로 감싸면 처리기가 InnerException을 파헤쳐야 할 뿐입니다.
예외 던지기와 결과 반환
예외는 정상 동작에서 호출하는 쪽이 예상하지 않는 실패를 위한 것입니다. 자주 아무것도 찾지 못하는 조회나 자주 잘못되는 사용자 입력처럼 일상적인 결과에는 .NET의 관례인 Try 패턴을 쓰세요. bool을 반환하고 값은 out 매개변수로 돌려줍니다.
public bool TryWithdraw(decimal amount, out string error)
{
if (amount > Balance) { error = "Insufficient funds."; return false; }
Balance -= amount;
error = null;
return true;
}
많은 타입이 둘 다 제공합니다. int.Parse는 예외를 던지고 int.TryParse는 false를 반환합니다. dict[key]는 예외를 던지고 dict.TryGetValue는 false를 반환합니다. 예외를 던지는 것은 값을 반환하는 것보다 훨씬 비싸므로, 초당 수천 번 실행되는 경로에 두면 안 됩니다. out 매개변수는 ref와 out을 참고하세요.
좋은 메시지 쓰기
예외 메시지는 로그를 보는 개발자가 읽습니다. 무엇이 잘못되었는지, 그리고 안전하다면 문제의 값을 밝히세요. "Invalid input."보다 "Quantity must be between 1 and 99, got 0."이 낫습니다. 완전한 문장으로 쓰고, 로그 파일에 남게 되므로 비밀번호와 토큰 같은 비밀은 메시지에 넣지 마세요.
흔한 실수
Exception자체를 던지기. 호출하는 쪽이 선택적으로 잡을 수 없습니다. 구체적인 타입을 쓰세요.- 매개변수 이름 자리에 메시지 넘기기.
new ArgumentNullException("name")은 매개변수 이름을 받고, 메시지는 두 번째입니다. - 하드코딩한 매개변수 이름. 이름을 바꿔도 맞도록
nameof(param)을 쓰세요. - 추가 의미가 없는 사용자 정의 예외. 기본 제공 타입이 맞으면 그것을 쓰세요.
- 감쌀 때 원래 오류 잃기. 항상 내부 예외로 넘기세요.
자주 묻는 질문
C#에서 예외는 어떻게 던지나요?
예외 객체를 만들어 던집니다: throw new ArgumentException("Amount must be positive", nameof(amount));. 실행은 그 줄에서 멈추고, 예외는 호출 스택을 따라 가장 가까운 일치하는 catch로 이동합니다. 문제를 설명하는 가장 구체적인 기본 제공 타입을 고르거나, 호출하는 쪽이 이 경우를 따로 처리해야 한다면 사용자 정의 타입을 고르세요.
C#에서 사용자 정의 예외는 어떻게 만드나요?
이름이 Exception으로 끝나는 클래스를 Exception에서 파생하고, 표준 생성자를 주세요. 인수 없는 것, 메시지를 받는 것, 메시지와 내부 예외를 받는 것이며, 각각 대응하는 base(...) 생성자를 호출합니다. 주문 ID나 잔액처럼 처리기에 필요한 데이터는 읽기 전용 속성으로 추가하세요.
언제 ArgumentException을, 언제 InvalidOperationException을 던져야 하나요?
호출하는 쪽이 잘못된 값을 넘겼을 때는 ArgumentException(또는 ArgumentNullException / ArgumentOutOfRangeException)을 던지세요. 해결책은 메서드를 다르게 호출하는 것입니다. 인수는 괜찮지만 닫힌 연결에서 읽거나 동결된 계좌에서 출금하는 것처럼 객체가 그 호출에 맞지 않는 상태라면 InvalidOperationException을 던지세요.
C#의 throw 식이란 무엇인가요?
C# 7부터 throw는 세 곳에서 식으로 쓸 수 있습니다. ?? 뒤, ?:의 어느 분기, 식 본문 멤버나 람다의 본문입니다. 예를 들어 _name = name ?? throw new ArgumentNullException(nameof(name));는 한 줄에서 대입하거나 예외를 던집니다.
ArgumentNullException.ThrowIfNull은 무엇을 하나요?
.NET 6에서 추가된 정적 헬퍼입니다. ArgumentNullException.ThrowIfNull(customer);는 customer가 null이면 매개변수 이름을 자동으로 채운 ArgumentNullException을 던지고, 아니면 아무것도 하지 않습니다. 이후 버전은 ArgumentException.ThrowIfNullOrEmpty(.NET 7)와 ArgumentOutOfRangeException.ThrowIfNegative(.NET 8) 같은 비슷한 헬퍼를 추가했습니다.