예외는 프로그램의 비상 흐름이다
프로그램은 항상 우리가 예상한 값만 받지 않는다. 사용자가 숫자 대신 글자를 입력할 수 있고, 배열의 범위를 벗어난 위치를 읽을 수도 있으며, 파일이나 네트워크처럼 외부 환경 때문에 실패할 수도 있다.
예외는 프로그램 실행 중에 정상 흐름을 계속할 수 없을 때 발생하는 특별한 오류 객체다. 컴파일 오류가 코드를 실행하기 전에 발견되는 문제라면, 예외는 프로그램이 실행되는 도중에 발생한다.
다음 코드는 사용자가 입력한 값을 숫자로 바꾼다.
Console.Write("나이를 입력하세요: ");
string? input = Console.ReadLine();
int age = int.Parse(input!);
Console.WriteLine($"내년 나이: {age + 1}");실행 예:
나이를 입력하세요: 스물
Unhandled exception. System.FormatException: The input string '스물' was not in a correct format.int.Parse는 문자열을 정수로 바꿀 수 없을 때 FormatException을 발생시킨다. 이 예외를 처리하지 않으면 프로그램은 그대로 종료된다. 좋은 프로그램은 정상 입력만 처리하지 않는다. 잘못된 입력이 들어왔을 때도 사용자가 이해할 수 있는 방식으로 반응해야 한다.
다만 모든 잘못된 입력을 예외로 처리해야 하는 것은 아니다. 사용자가 자주 실수할 수 있는 입력 검사는 앞에서 배운 TryParse가 더 자연스럽다. 예외 처리는 보통 "정상 흐름에서는 일어나지 않아야 하지만, 실제로는 일어날 수 있는 문제"를 다룰 때 사용한다.
AI 생성 이미지
try, catch, finally로 복구 흐름 만들기
C#에서는 try, catch, finally로 예외를 처리한다.
try: 예외가 발생할 수 있는 코드를 넣는다.catch: 예외가 발생했을 때 실행할 복구 코드를 넣는다.finally: 예외 발생 여부와 관계없이 마지막에 실행할 정리 코드를 넣는다.
try
{
Console.Write("나이를 입력하세요: ");
string? input = Console.ReadLine();
int age = int.Parse(input!);
Console.WriteLine($"내년 나이: {age + 1}");
}
catch (FormatException)
{
Console.WriteLine("나이는 숫자로 입력해 주세요.");
}
finally
{
Console.WriteLine("프로그램을 종료합니다.");
}실행 예:
나이를 입력하세요: 스물
나이는 숫자로 입력해 주세요.
프로그램을 종료합니다.이번에는 프로그램이 갑자기 종료되지 않는다. FormatException이 발생하면 catch 블록으로 이동하고, 사용자에게 이해하기 쉬운 메시지를 보여 준다. 마지막에는 finally가 실행된다.
finally는 파일 닫기, 네트워크 연결 정리, 임시 상태 복구처럼 "성공하든 실패하든 반드시 해야 하는 일"에 사용한다. 단순한 콘솔 예제에서는 자주 필요하지 않지만, 외부 자원을 다룰수록 중요해진다.
구체적인 예외 타입을 잡아야 하는 이유
C#의 예외는 모두 Exception 클래스를 바탕으로 한다. FormatException, DivideByZeroException, IndexOutOfRangeException 같은 예외는 모두 Exception의 하위 타입이다.
그래서 다음처럼 모든 예외를 한 번에 잡을 수도 있다.
try
{
int number = int.Parse("abc");
}
catch (Exception)
{
Console.WriteLine("오류가 발생했습니다.");
}하지만 처음부터 모든 예외를 Exception으로 잡는 습관은 좋지 않다. 어떤 문제가 발생했는지 구분할 수 없고, 고쳐야 할 버그까지 조용히 지나칠 수 있기 때문이다. 가능한 한 구체적인 예외부터 처리하고, 마지막에 정말 필요한 경우에만 Exception을 사용한다.
try
{
Console.Write("첫 번째 숫자: ");
int left = int.Parse(Console.ReadLine()!);
Console.Write("두 번째 숫자: ");
int right = int.Parse(Console.ReadLine()!);
int result = left / right;
Console.WriteLine($"결과: {result}");
}
catch (FormatException)
{
Console.WriteLine("숫자 형식으로 입력해 주세요.");
}
catch (DivideByZeroException)
{
Console.WriteLine("0으로 나눌 수 없습니다.");
}
catch (Exception ex)
{
Console.WriteLine("예상하지 못한 문제가 발생했습니다.");
Console.WriteLine($"개발자용 메시지: {ex.Message}");
}catch는 위에서 아래로 검사된다. 따라서 더 구체적인 예외를 먼저 쓰고, 더 넓은 예외인 Exception은 마지막에 둔다. 만약 catch (Exception)을 맨 위에 쓰면 그 아래에 있는 구체적인 catch는 도달할 수 없는 코드가 되어 컴파일 오류가 난다.
사용자에게는 "0으로 나눌 수 없습니다"처럼 행동에 도움이 되는 메시지를 보여 주고, 개발자는 ex.Message나 디버거로 실제 원인을 확인한다. 실제 프로그램에서는 이런 개발자용 정보는 화면에 그대로 보여 주기보다 로그로 남기는 편이 안전하다.
throw로 잘못된 상태를 호출자에게 알리기
예외는 시스템만 발생시키는 것이 아니다. 우리가 직접 발생시킬 수도 있다. 이때 throw를 사용한다.
메서드가 처리할 수 없는 잘못된 값을 받았을 때 조용히 무시하면 문제가 더 커진다. 차라리 즉시 예외를 발생시켜 호출한 쪽이 문제를 알게 하는 편이 안전하다.
class BankAccount
{
public decimal Balance { get; private set; }
public BankAccount(decimal initialBalance)
{
if (initialBalance < 0)
{
throw new ArgumentOutOfRangeException(nameof(initialBalance), "초기 잔액은 0보다 작을 수 없습니다.");
}
Balance = initialBalance;
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount), "출금액은 0보다 커야 합니다.");
}
if (amount > Balance)
{
throw new InvalidOperationException("잔액이 부족합니다.");
}
Balance -= amount;
}
}사용하는 쪽에서는 예외를 처리할 수 있다.
try
{
BankAccount account = new BankAccount(10000m);
account.Withdraw(15000m);
Console.WriteLine($"잔액: {account.Balance:N0}원");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}실행 결과:
잔액이 부족합니다.여기서 ArgumentOutOfRangeException은 인수 값이 허용 범위를 벗어났다는 뜻이고, InvalidOperationException은 객체의 현재 상태에서 그 작업을 수행할 수 없다는 뜻이다. 예외 타입을 적절히 고르면 코드를 읽는 사람이 실패 이유를 더 쉽게 이해한다.
참고로 catch에서 예외를 다시 던져야 할 때는 보통 throw;를 사용한다. throw ex;처럼 새로 던지면 예외가 처음 발생한 위치 정보가 흐려질 수 있다.
디버깅은 코드를 멈추고 관찰하는 일이다
디버깅은 오류를 추측으로 고치는 일이 아니다. 프로그램을 중간에 멈추고, 변수 값과 실행 흐름을 직접 확인하는 과정이다.
Visual Studio와 VS Code 모두 기본적인 디버깅 흐름은 비슷하다.
의심되는 줄 왼쪽에 중단점을 건다.
디버그 실행을 시작한다. 보통
F5를 사용한다.프로그램이 중단점에서 멈추면 변수 값을 확인한다.
F10으로 다음 줄로 이동하거나,F11로 메서드 안으로 들어간다.원인을 찾으면 코드를 수정하고 다시 실행한다.
다음 코드는 배열 범위를 벗어나는 실수를 담고 있다.
int[] scores = { 90, 80, 70 };
for (int i = 0; i <= scores.Length; i++)
{
Console.WriteLine(scores[i]);
}실행 결과:
90
80
70
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.scores.Length는 3이고, 배열의 마지막 인덱스는 2다. 그런데 조건이 i <= scores.Length라서 i가 3일 때도 반복문 안으로 들어간다. 디버거로 i와 scores.Length를 확인하면 이 문제가 바로 보인다.
수정 코드는 다음과 같다.
int[] scores = { 90, 80, 70 };
for (int i = 0; i < scores.Length; i++)
{
Console.WriteLine(scores[i]);
}또는 인덱스가 필요 없다면 더 간단히 쓸 수 있다.
foreach (int score in scores)
{
Console.WriteLine(score);
}디버깅할 때 특히 자주 보는 창은 다음과 같다. 도구마다 이름은 조금 다를 수 있지만 역할은 비슷하다.
Variables 또는 Locals: 현재 위치에서 사용할 수 있는 변수와 값을 보여 준다.
Watch: 관심 있는 식을 직접 등록해 계속 확인한다.
Call Stack: 어떤 메서드들이 차례로 호출되어 현재 위치까지 왔는지 보여 준다.
Debug Console 또는 Immediate Window: 실행 중인 상태에서 간단한 식을 확인할 수 있다.
오류 메시지는 결과이고, 디버거는 원인을 찾는 도구다. 예외가 발생한 줄만 보지 말고, 그 줄에 도착하기 전 변수 값이 어떻게 바뀌었는지 따라가야 한다.
예외를 숨기지 말고 친절하게 처리하기
다음 코드는 겉보기에는 안전해 보이지만 좋지 않다.
try
{
int age = int.Parse(Console.ReadLine()!);
Console.WriteLine($"나이: {age}");
}
catch
{
}catch가 비어 있으면 문제가 발생해도 아무 일도 일어나지 않은 것처럼 보인다. 사용자는 왜 결과가 없는지 모르고, 개발자는 어디서 문제가 생겼는지 알기 어렵다. 이런 코드는 예외를 처리한 것이 아니라 숨긴 것이다.
더 나은 코드는 실패 이유에 맞는 메시지를 제공한다.
try
{
Console.Write("나이: ");
int age = int.Parse(Console.ReadLine()!);
Console.WriteLine($"입력한 나이는 {age}세입니다.");
}
catch (FormatException)
{
Console.WriteLine("나이는 숫자로 입력해 주세요. 예: 25");
}
catch (Exception ex)
{
Console.WriteLine("문제가 발생했습니다. 잠시 후 다시 시도해 주세요.");
Console.WriteLine($"개발자용 메시지: {ex.Message}");
}친절한 오류 메시지는 사용자를 탓하지 않는다. 대신 무엇이 잘못되었고, 어떻게 다시 시도하면 되는지 알려 준다. "잘못된 입력입니다"보다 "나이는 숫자로 입력해 주세요. 예: 25"가 더 좋은 메시지다.
흔한 실수
정상적인 입력 검사를 모두 예외 처리로 해결하려고 한다. 자주 발생하는 입력 검사는
TryParse가 더 적합하다.catch (Exception)만 사용한다. 문제 종류를 구분하지 못해 버그를 놓치기 쉽다.비어 있는
catch를 작성한다. 예외를 숨기면 나중에 원인을 찾기 어렵다.사용자에게 내부 오류 메시지를 그대로 보여 준다. 파일 경로, 서버 정보 같은 민감한 내용이 노출될 수 있다.
디버거 없이
Console.WriteLine만 계속 추가한다. 출력 확인도 도움이 되지만, 복잡한 문제는 중단점과 변수 확인이 더 빠르다.
작은 연습 문제
사용자에게 두 정수를 입력받아 나누는 프로그램을 작성해 보세요. 숫자가 아닌 값을 입력하면
FormatException, 0으로 나누면DivideByZeroException을 각각 처리하세요.int[] numbers = { 3, 6, 9 };배열을 반복문으로 출력하되, 일부러 인덱스 오류를 만든 뒤 디버거로i값을 확인해 보세요. 그다음 오류를 수정하세요.Product클래스를 만들고 가격이 0보다 작으면 생성자에서ArgumentOutOfRangeException을 던지게 해 보세요.비어 있는
catch가 있는 코드를 하나 작성한 뒤, 사용자용 메시지와 개발자용 메시지를 분리한 코드로 바꿔 보세요.
예외 처리는 프로그램이 실패하지 않는 척하게 만드는 기술이 아니다. 실패할 수 있는 지점을 분명히 알고, 복구할 수 있는 문제는 복구하고, 복구할 수 없는 문제는 정확히 드러내는 기술이다. 디버깅 습관은 그 과정을 추측이 아니라 관찰로 바꿔 준다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.