
사용자의 사소한 실수 하나 때문에 프로그램이 종료된다면
정말 당황스럽겠죠? 숫자를 입력할 곳에 문자를 넣는 것처럼,
우리가 통제할 수 없는 돌발 상황은 언제나 일어날 수 있습니다.
예외(Exception)가 발생했을 때 프로그램이 비정상 종료(crash)되는 것을 막고,
미리 정해둔 방법으로 대처하는 기술이 예외 처리(Exception Handling)입니다.
이번 글에서는 소중한 우리 프로그램을 지키는 방법을 알아보겠습니다.
예외는 프로그램 실행 중에 발생하는 '비정상적인' 또는
'예기치 않은' 사건을 의미하는 객체입니다. C#에서는 이런 사건이 발생하면
시스템이 "문제가 발생했어요!"라고 외치며 예외 객체를 던집니다. (throw)
만약 우리가 이 예외를 잡아서(catch) 처리하지 않으면, 프로그램은 그대로 멈춰버립니다.
// 사용자가 "안녕"이라고 입력했다고 상상해 보세요.
Console.Write("숫자를 입력해 주세요: ");
string userInput = Console.ReadLine();
// "안녕"이라는 글자는 숫자로 변환할 수 없으므로,
// 여기서 FormatException이 발생하며 프로그램이 즉시 종료됩니다!
int number = int.Parse(userInput);
Console.WriteLine($"입력한 숫자는 {number}입니다."); // 이 코드는 실행되지 않음
try-catch구문은 예외가 발생할 만한 코드를 안전하게 실행하고,
문제가 생겼을 때 대처할 수 있게 해주는 핵심 문법입니다.
위에서 프로그램이 종료되었던 코드를 try-catch로 감싸서 안전하게 만들어 보겠습니다.
[문법]
try { ... }: 예외 발생이 예상되는 '위험한' 코드를 넣는 공간입니다.
catch { ... }: try블록에서 예외가 발생했을 때, 처리하는 공간입니다.
Parse( ... ): 문자열을 숫자로 변환을 시도하고, 실패하면 예외를 던집니다.
[코드]
using System;
class Program
{
static void Main()
{
Console.Write("숫자를 입력해 주세요: ");
string userInput = Console.ReadLine();
try
{
// '위험한' 코드를 try 블록 안에 넣습니다.
int number = int.Parse(userInput);
Console.WriteLine($"입력한 숫자는 {number}입니다.");
}
catch (FormatException ex) // FormatException 타입의 예외만 잡습니다.
{
// 예외가 발생했을 때 실행될 코드
Console.WriteLine("형식이 올바르지 않아요.");
Console.WriteLine($"오류 메시지: {ex.Message}"); // 예외에 대한 상세 정보
}
}
}
[숫자를 입력한 결과]
숫자를 입력해 주세요: 5
입력한 숫자는 5입니다.
[문자를 입력한 결과]
숫자를 입력해 주세요: f
형식이 올바르지 않아요.
오류 메시지: The input string 'f' was not in a correct format.
화재 종류에 따라 다른 소화기를 쓰는 것처럼, 예외의 종류에 따라
catch블록을 여러 개 사용하여 특정 예외를 각각 처리할 수 있습니다.
[예시]
try
{
// ... 위험한 코드 ...
}
catch (FormatException ex) // 1순위: 형식 변환 실패 시
{
Console.WriteLine("숫자 형식으로 변환할 수 없습니다.");
}
catch (DivideByZeroException ex) // 2순위: 0으로 나누려고 할 때
{
Console.WriteLine("0으로 나눌 수는 없어요!");
}
catch (Exception ex) // 3순위: 위에서 잡지 못한 모든 나머지 예외
{
Console.WriteLine($"알 수 없는 오류가 발생했습니다: {ex.Message}");
}
TryParse()는 변환에 실패해도 예외를 던지지 않고 false를 반환합니다.
마치 '돌다리도 두드려보고 건너는 것'과 같습니다.
true 또는 false값으로 반환합니다.out매개변수를 통해 변환된 숫자 값을 전달합니다.[코드]
using System;
class Program
{
static void Main()
{
Console.Write("숫자를 입력해 주세요: ");
string userInput = Console.ReadLine();
// TryParse는 bool 값을 반환하므로 if문의 조건으로 바로 사용할 수 있습니다.
if (int.TryParse(userInput, out int number))
{
Console.WriteLine($"입력한 숫자는 {number}입니다.");
}
else
{
// 예외가 발생했을 때 실행될 코드
Console.WriteLine("형식이 올바르지 않아요.");
}
}
}
[숫자를 입력한 결과]
숫자를 입력해 주세요: 3
입력한 숫자는 3입니다.
[문자를 입력한 결과]
숫자를 입력해 주세요: C#
형식이 올바르지 않아요.
Parse()와 TryParse()는 변환에 성공하면 성능 차이는 거의 없습니다.
하지만 Parse()는 변환에 실패하면 막대한 성능 저하가 발생합니다.
TryParse()의 경우 변환에 실패하면 예외 처리 없이 false값을 반환합니다.
대부분의 경우 Parse() 대신 TryParse()를 사용하는 것이 권장되는 방식입니다.
그럼에도 실무에서는 Parse()를 사용하는 경우가 있습니다.
Parse()는 100% 올바른 형식이라고 확신할 수 있는 상황에서 사용합니다.
만약 실패한다면, '심각한 오류' 또는 '예상치 못한 버그'라고 간주하는 것이죠.
[예시]
public const string DefaultTimeoutInSeconds = "30";
public void Initialize()
{
// 이 파싱이 실패하는 것은 개발자의 명백한 실수(예: "30a"로 잘못 적음)입니다.
// 이런 경우, 프로그램이 조용히 넘어가기보다는 빠르게 실패(Fail-Fast)하여
// 개발 단계에서 버그를 잡는 것이 훨씬 낫습니다.
int timeout = int.Parse(DefaultTimeoutInSeconds);
// ...
}
"입력 값의 유효성을 확신할 수 없다면, 항상
TryParse()를 사용하세요."
대부분의 경우, TryParse()를 사용하는 것이 더 안전하고, 견고하며,
성능 면에서도 효율적인 코드를 작성하는 방법입니다.
Parse()는 변환 실패가 프로그램의 예외적인 오류임을
명확히 하고 싶을 때 제한적으로 사용하는 것이 좋습니다.
| 구분 | Parse() | TryParse() |
|---|---|---|
| 실패 시 동작 | 예외(Exception) 발생 | false 반환, 예외 없음 |
| 성능 | 실패 시 매우 느림 (예외 처리 비용) | 성공/실패 모두 빠름 |
| 핵심 철학 | "실패는 곧 버그" | "실패는 예상 가능한 시나리오" |
| 주요 사용처 | 내부 상수, 설정 값, 이미 검증된 값 등 | 사용자 입력, 파일 내용, 네트워크 응답 등 |
finally블록은 try-catch구문의 마지막에 추가할 수 있는 선택적인 블록으로,
예외 발생 여부와 상관없이 무조건 실행되는 코드를 담습니다.
'finally'는 주로 파일 스트림을 닫거나, 데이터베이스 연결을 해제하는 등
사용한 리소스를 정리(cleanup)하는 중요한 역할을 합니다.
비유: 서커스 공연
서커스가 성공적으로 끝나든, 중간에 실수가 있었든 간에
공연이 끝나면 반드시 조명을 끄고 퇴장하는 것과 같습니다.
[예시]
FileStream file = null;
try
{
file = new FileStream("MyFile.txt", FileMode.Open);
// ... 파일 관련 작업 수행 ...
}
catch (FileNotFoundException ex)
{
Console.WriteLine("파일을 찾을 수 없습니다.");
}
finally
{
// 예외가 발생하든 안 하든, 파일 리소스는 반드시 닫아주어야 합니다.
if (file != null)
{
file.Close();
Console.WriteLine("파일 리소스를 정리했습니다.");
}
}
때로는 우리가 직접 시스템에 신호를 보내야 할 때가 있습니다.
예를 들어, 회원의 나이가 음수로 입력되는 경우처럼 말이죠.
이런 상황은 시스템이 우리가 정한 규칙을 어긴 논리적 오류입니다.
[회원 가입 예제]
회원 가입 시 나이를 검증하는 메서드를 만든다고 가정해 봅시다.
나이는 0보다 커야 한다는 규칙이 있겠죠?
[코드]
using System;
public class MemberService
{
public void RegisterMember(string name, int age)
{
if (age <= 0)
{
// 'age' 매개변수가 규칙에 어긋나므로, 예외를 직접 던집니다!
// ArgumentOutOfRangeException은 .NET에 미리 정의된 예외입니다.
throw new ArgumentOutOfRangeException(nameof(age), "나이는 0보다 커야 합니다.");
}
Console.WriteLine($"{name}님({age}세) 회원가입이 완료되었습니다.");
}
}
class Program
{
static void Main()
{
MemberService service = new MemberService();
try
{
service.RegisterMember("홍길동", 25); // 성공
service.RegisterMember("김철수", -5); // 여기서 예외가 throw 됩니다.
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"오류: {ex.Message}");
}
}
}
[실행 결과]
홍길동님(25세) 회원가입이 완료되었습니다.
오류: 나이는 0보다 커야 합니다. (Parameter 'age')
catch블록에서 잡은 예외를 던져서 상위 호출자에게 처리를 위임할 수도 있습니다.
이때 throw ex;와 throw;는 미묘하지만 아주 중요한 차이가 있습니다.
throw ex;는 예외가 '여기서 새로 시작된 것처럼' 만들어 던집니다.
가장 큰 문제는, 기존의 스택 트레이스 정보가 날아가고
현재 위치에서 스택 트레이스가 새로 시작된다는 점입니다.
마치 최초 범죄 현장(MethodB)의 모든 증거를 지워버리고,
"여기(MethodA)에서 새로운 사건이 발생했어!"라고 보고하는 것과 같아요.
[코드]
using System;
class Program
{
static void Main()
{
try
{
MethodA();
}
catch (Exception ex)
{
Console.WriteLine("--- 최종 예외 정보 ---");
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
}
}
static void MethodA()
{
try
{
MethodB();
}
catch (Exception ex)
{
Console.WriteLine("MethodA에서 예외를 잡았지만... 다시 던집니다.");
throw ex; // 스택 트레이스가 여기서 새로 시작됩니다.
}
}
static void MethodB()
{
// 이곳이 최초 예외 발생 지점!
throw new InvalidOperationException("MethodB에서 최초 예외 발생!");
}
}
[실행 결과]
MethodA에서 예외를 잡았지만... 다시 던집니다.
--- 최종 예외 정보 ---
MethodB에서 최초 예외 발생!
at Program.MethodA() in C:\9주차\MyApp\Program.cs:line 28
at Program.Main() in C:\9주차\MyApp\Program.cs:line 9
스택 트레이스에 최초 예외 발생 지점인 MethodB가 사라지고,
MethodA가 예외의 시작점처럼 기록되었습니다.
예외가 발생했던 위치 정보(Stack Trace)가 사라져 디버깅이 어려워집니다.
throw;는 잡았던 예외를 '원래 발생했던 정보 그대로' 다시 던집니다.
따라서 최초 예외가 발생했던 정보, 즉 스택 트레이스가 온전히 보존됩니다.
마치 최초 범죄 현장(MethodB)의 증거를 그대로 보존한 채,
(MethodA)에서 (MethodB) 사건을 확인하고 보고하는 것과 같아요.
[코드]
using System;
class Program
{
static void Main()
{
try
{
MethodA();
}
catch (Exception ex)
{
Console.WriteLine("--- 최종 예외 정보 ---");
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
}
}
// Main, MethodB는 위와 동일합니다.
static void MethodA()
{
try
{
MethodB();
}
catch (Exception ex)
{
Console.WriteLine("MethodA에서 예외를 잡고, 그대로 다시 던집니다.");
throw; // 스택 트레이스를 보존합니다.
}
}
static void MethodB()
{
// 이곳이 최초 예외 발생 지점!
throw new InvalidOperationException("MethodB에서 최초 예외 발생!");
}
}
[실행 결과]
MethodA에서 예외를 잡고, 그대로 다시 던집니다.
--- 최종 예외 정보 ---
MethodB에서 최초 예외 발생!
at Program.MethodB() in C:\9주차\MyApp\Program.cs:line 35
at Program.MethodA() in C:\9주차\MyApp\Program.cs:line 23
at Program.Main() in C:\9주차\MyApp\Program.cs:line 9
스택 추적 정보가 보존되므로, 이 방식을 사용하는 것이 권장됩니다.
'재고 부족', '잔액 부족', '로그인 실패' 같은 오류는
InvalidOperationException으로 뭉뚱그리기보다는,
OutOfStockException, InsufficientBalanceException처럼
명확한 이름의 예외로 표현하는 것이 훨씬 좋습니다. 이렇게 우리가
직접 만드는 예외를 사용자 정의 예외(Custom Exception)라고 합니다.
[코드]
// 1. Exception 클래스를 상속받습니다.
// 2. 클래스 이름은 'Exception'으로 끝나는 것이 관례입니다.
// 3. 아래 세 가지 생성자를 만들어주는 것이 표준적인 패턴입니다.
public class OutOfStockException : Exception
{
// 기본 생성자
public OutOfStockException() : base() { }
// 에러 메시지를 받는 생성자
public OutOfStockException(string message) : base(message) { }
// 에러 메시지와 내부 예외(Inner Exception)를 받는 생성자
public OutOfStockException(string message, Exception innerException)
: base(message, innerException) { }
}
public class VendingMachine
{
private Dictionary<string, int> _inventory = new Dictionary<string, int>()
{
{ "콜라", 5 },
{ "사이다", 0 }
};
public void Purchase(string itemName)
{
if (!_inventory.ContainsKey(itemName))
{
throw new ArgumentException($"{itemName}은(는) 판매하지 않는 상품입니다.");
}
if (_inventory[itemName] <= 0)
{
// 직접 만든 예외를 throw!
throw new OutOfStockException($"'{itemName}' 상품은 현재 재고가 없습니다.");
}
_inventory[itemName]--;
Console.WriteLine($"{itemName} 구매 완료!");
}
}
class Program
{
static void Main()
{
VendingMachine vm = new VendingMachine();
try
{
vm.Purchase("콜라");
vm.Purchase("사이다"); // 여기서 OutOfStockException 발생!
}
catch (OutOfStockException ex) // 우리가 만든 예외를 정확히 캐치!
{
Console.WriteLine($"[구매 실패] 재고 문제: {ex.Message}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"[구매 실패] 상품 문제: {ex.Message}");
}
}
}
[실행 결과]
콜라 구매 완료!
[구매 실패] 재고 문제: '사이다' 상품은 현재 재고가 없습니다.
코드를 읽는 사람은 "이 부분에서 재고 부족 오류를 처리하는구나!"라고
명확하게 이해할 수 있습니다. 이는 비즈니스 로직의 의도를 명확히 합니다.
C# 6.0부터 도입된 when키워드는 catch문에 조건을 직접 달아주는
예외 필터(Exception Filters) 역할을 합니다. 예외가 발생했을 때,
catch블록으로 들어가기 전에 when절의 조건을 먼저 확인하죠.
true이면 해당 catch블록이 실행됩니다.false이면 해당 catch블록은 무시하고 다음 catch블록을 찾거나,[코드]
using System;
using System.IO; // 파일 입출력 처리를 위해 필요합니다.
class Program
{
static void Main()
{
try
{
// 일부러 예외를 발생시키는 상황을 가정합니다.
ProcessFile("invalid_file.txt");
}
// 1. 첫 번째 필터: IOException 중 메시지에 "access"가 포함될 때만!
catch (IOException ex) when (ex.Message.Contains("access"))
{
Console.WriteLine("파일 접근 권한이 없습니다. 권한을 확인해주세요.");
}
// 2. 두 번째 필터: 위의 when 조건에 맞지 않는 모든 IOException을 처리
catch (IOException ex)
{
Console.WriteLine($"파일 입출력 오류가 발생했습니다: {ex.Message}");
}
}
static void ProcessFile(string fileName)
{
// 실제로는 파일 접근 권한 오류나 다른 I/O 오류가 발생할 수 있습니다.
if (!File.Exists(fileName))
{
// 여기서는 예시를 위해 간단하게 예외를 던집니다.
throw new IOException("File not found");
}
}
}
[실행 결과]
파일 입출력 오류가 발생했습니다: File not found
[코드 분석]
catch (IOException ex) when (ex.Message.Contains("access"))
1. IOException이 발생하면, 무조건 when뒤의 조건을 먼저 검사합니다.
2. 예외 객체에 "access"라는 문자열이 포함되어야만 해당 catch블록이 선택됩니다.
catch (IOException ex)
1. 만약 첫 번째 catch의 when조건이 false라면, 다음 catch문으로 넘어옵니다.
2. 이 블록에는 when필터가 없으므로, 나머지 IOException을 처리하게 됩니다.
이처럼 when을 사용하면 가장 구체적인 조건의 catch블록을 맨 위에 두고,
점차 일반적인 경우를 처리하는 순서로 구성할 수 있어 훨씬 깔끔하고 직관적입니다.
when을 사용하면 단순히 코드가 예뻐지는 것 이상의 장점이 있습니다.
1. 더 명확한 코드
catch문에 조건이 명시되어 있어, 어떤 상황에 이 예외를 처리하는지 파악하기 쉽습니다.
2. 디버깅의 편리함
when의 가장 큰 장점 중 하나입니다. if-throw방식에서는 예외를
다시 던지는(throw) 시점에 스택 정보가 다시 기록될 수 있습니다.
하지만 when을 사용하면 조건이 false일 때는 예외를 건드리지 않고
그대로 통과시키기 때문에, 최초 예외가 발생한 지점의 스택 정보가 보존됩니다.
3. 여러 조건을 깔끔하게 처리
when을 사용하면 여러 종류의 예외 조건을 간결하게 나열할 수 있습니다.
| 키워드 | 주요 특징 |
|---|---|
try-catch | 예외가 발생할 수 있는 코드를 실행하고, 발생 시 처리하는 기본 구조 |
다중 catch 문 | 예외의 종류에 따라 각각 다른 처리를 하고 싶을 때 사용 |
Parse() | 변환 실패 시 예외를 던지고 예외 처리 비용이 발생 |
TryParse() | 예외를 발생시키지 않고 값 변환을 시도하는 안전한 방법 |
finally | 예외 발생 여부와 관계없이 항상 실행되는 코드 블록 |
throw | 개발자가 직접 예외를 발생시킬 때 사용 |
throw; | 원본 위치 정보(Stack Trace)가 초기화되어 디버깅이 어려워짐 |
throw ex; | 최초 예외가 발생한 원본 위치 정보(Stack Trace)를 보존 |
| 사용자 정의 예외 | 상황에 맞는 예외를 직접 만들어 코드의 의도를 명확히 함 |
when (예외 필터) | catch 문에 조건을 추가하여 특정 조건일 때만 예외를 잡도록 필터링 |