C#

C# 1.0 - 예외

devrabbit22 2025. 9. 24. 06:49

1. 오류(error) vs 예외(exception)

오류 (Error)

  • 보통 컴파일러 수준이나 런타임에서 복구 불가능한 상황을 가리킬 때 사용한다.
  • 예: 컴파일 오류(세미콜론 빠짐, 형식 불일치), OutOfMemoryError 같은 치명적 상황.
  • 프로그램이 정상적으로 실행될 수 없을 때 발생한다.

예외 (Exception)

  • 프로그램 실행 중 발생하는 비정상적인 상황을 나타내는 객체.
  • C#, Java 같은 언어에서는 예외를 try-catch로 잡아서 처리할 수 있다.
  • 예: NullReferenceException, IndexOutOfRangeException, FileNotFoundException.

2. “비정상 종료”의 의미

  • 프로그램을 실행했는데 비정상 종료되면, 대부분 예외를 던졌는데 처리되지 않고 전파된 경우입니다.
    → 즉, 예외를 잡지 못했기 때문에 프로세스 크래시로 이어진다.
  • 하지만 네이티브 호출(P/Invoke) 같은 경우에는 CLR 예외가 아니라 AccessViolation 같은 치명적인 런타임 오류로 바로 꺼질 수도 있으며, 이건 관리 코드의 “예외”라기보다 OS 수준에서 막은 것이다.

3. 정리

  • 비정상 종료
    • 관리 코드(C# 순수 코드): 보통 예외(Exception) 때문에 비정상 종료됨.
    • 네이티브 코드(P/Invoke 등): 예외가 아니라 메모리 접근 오류, DLL 로딩 실패 등 오류(Error) 로 프로세스가 바로 죽을 수 있다.

예외가 발생하면 개발자는 예외 메시지로부터 오류의 원인을 찾을 수 있다.

예외 타입

CLR에 의해 전달되는 예외는 그 자체도 타입(Type)의 인스턴스다.

System.NullReferenceException과 System.IndexOutOfRangeException 예외는 다름 아닌 클래스의 이름이고, 이를 위한 최소한의 조건은 System.Exception을 상속받는 것이다. 단지 관례상 다음과 같은 기준이 제시

  • 응용 프로그램 개발자가 정의하는 예외는 System.Exception을 상속받은 System.ApplicationException을 상속 받는다.
  • 접미사로 Exception을 클래스명에 추가한다.
  • CLR에서 미리 정의된 예외는 System.SystemException을 상속받는다.

이 규칙에 강제성이 있는 것은 아니다.

마이크로소프트에서도 내부적으로 CLR의 일부 예외를 ApplicationException 타입에서 상속받아 정의했다.

나중에는 ApplicationException의 의미가 퇴색되어 최근의 닷넷 가이드라인 분서에는 응용 프로그램 개발자가 만드는 예외를 System.Exception에서 직접 상속받도록 권장한다.

예외 타입의 상속 구조

 

System.Exception 타입은 기본적으로 예외 정보를 구할 수 있는 속성과 메서드를 제공한다.

멤버 타입 설명
Message 인스턴스 프로퍼티 예외를 설명하는 메시지를 반환한다.
Source 인스턴스 프로퍼티 예외를 발생시킨 응용프로그램의 이름을 반환한다.
StackTrace 인스턴스 프로퍼티 예외가 발생된 메서드의 호출 스택을 반환한다.
ToString 인스턴스 메서드 Message, StackTrace 내용을 포함하는 문자열을 반환한다.

System.Exception 타입의 주요 멤버

 

CLR이 콘솔 응용 프로그램을 실행하다 처리되지 않은 예외(Unhandled Exception)을 만난 경우 예외 타입의 ToString 메서드로부터 반환받은 문자열을 화면에 출력한다.

초보 시절에 가능한 많은 예외 타입을 경험하고 상황에 대한 기록을 남기거나 기억해두어 더움 얭하거 발생했을 때 좀 더 빠르게 오류 상황을 해결할 수 있다.

예외 처리기

  • 예외가 발생한 경우 CLR의 기본 처리 과정은 예외 메시지를 출력하고 프로그램을 종료하는 것이다.
  • 이는 개발자가 의도한 동작이 아닐 수 있다. 대부분의 경우 프로그램이 강제로 종료되기보다는 예외 상황을 사용자에게 알리고 프로그램은 여전히 계속 실행되기를 원한다. 
  • 개발자는 예외가 발생할 수 있는 코드를 미리 try/catch로 묶어둬야 한다.
int divisor = 0;

try
{
	int quotient = 10 / divisor;
}
catch { }

위의 코드에서는 10을 0으로 나누려 했기 때문에 CLR에서는 Systme.DivideByZeroException을 발생시키지만 개발자가 try/catch 예약어를 사용해 예외를 처리하겠다고 지정했으므로 프로그램이 종료되지 않고 실행 흐름이 catch블록으로 넘어간다.

즉, 개발자는 try/catch 구문을 사용해 예외처리기(exception handler)를 제공할 수 있다.

CLR에서는 예외가 감지된 경우 예외를 유발한 코드에 예외 처리기가 있는지 검사한다. 

예외처리기가 있다면 처리를 해당 예외 처리기로 넘기고, 없다면 예외 메시지를 출력하고 프로그램을 종료한다.

예외처리기가 제공되지 않은 경우 → '처리되지 않은 예외(unhandled exception)'

 

try 블록은 예외가 발생할 수 있는 코드를 묶는 데 사용된다. 예외가 발생하지 않는다면 try 블록의 모든 코드가 실행된다.

예외가 발생한다면 원인이 되는 코드부터 try 블록의 마지막 코드까지는 실행되지 않는다. 

catch 블록 내의 코드는 오직 예외가 발생한 경우에만 실행된다.

 

catch 블록 내에 넣어둔 코드에서도 예외가 발생할 경우?

→ 해당 코드 역시 try/catch로 중첩시켜 묶는 것이 가능하다.

int divisor = 0;

try
{
	int quotient = 10 / divisor;
}
catch
{
	try
    {
    	// [사용자 코드]
    }
    catch { }
}

try/catch 말고도 finally 예약어를 사용하는 블록도 있다. finally 블록은 try 블록 내에서 예외가 발생하는 것과 상관 없이 언제나 실행된다는 특징이 있다.

예외가 발생하지 않으면 try 블록의 코드가 실행된 다음 finally 블록의 코드가 실행되고, 예외가 발생하면 try 블록의 일부 코드가 실행된 다음 catch 블록의 코드가 실행되고, 이어서 finally 블록의 코드가 실행된다.

try
{
	int quoient = 10; / divisor;
    Console.WriteLine("예외가 발생하지 않으면 실행됨!");
}
catch
{
    Console.WriteLine("예외가 발생하면 실행됨!");
}
finally
{
    Console.WriteLine("언제나 실행됨");
}

finally 블록의 특성 때문에 일반적으로 finally 블록은 자원을 해제하는 코드를 넣어두는 용도로 적합하다.

ex) try 블록에서 파일을 열었다고 가정하면, finally 블록이 없다면 열린 파일을 닫기 위해서 try와 catch 블록 모두 파일을 닫는 코드를 넣어야 한다.

FileStream file = null;
try
{
    file = ..[파일열기]..
    // 열린 파일로 작업, 이 과정에서 예외가 발생할 수 있다.
}
finally
{
    file.Close();
}

예외 처리기에서 유일하게 다중 블록을 허용하는 catch 블록을 보면 catch 블록에서 try 블록의 모든 예외를 잡는다.

하지만 catch에서는 개발자가 원하는 예외만 잡을 수 있다.

→ catch 구문에 예외 타입을 지정해야 한다.

int divisor = 0;

try
{
    int quotient = 10 / divisor;
{
catch (System.DivideByZeroException)
{
}

위의 코드는 catch 절에서 System.DivideByZeroException 타입을 명시하고 있으며 그 밖의 예외가 발생한 경우 CLR의 기본 예외 처리 작업을 거친다. 따라서 다음 코드를 실행하면 프로그램이 비정상적으로 종료된다.

int divisor = 0;
string txt = null;

try
{
	Console.WriteLine(txt.ToUpper());	//System.NullReferenceException 예외 발생
    int quitient = 10 / divisor;
}
catch
{
	System.DivideByZeroException)
}

이 처럼 다양한 유형의 예외가 try 블록에서 발생할 수 있다면 catch 블록도 다중으로 구성할 수 있다.

int divisor = 0;
string txt = null;

try
{
    Console.WriteLine(txt.ToUpper());	//System.NullReferenceException 예외 발생
    int quotient = 10 / divisor;
}
catch(System.NullReferenceException) { }
catch(System.DivideByZeroException) { }
catch(System.Exception) { }

CLR은 catch 구문의 타입을 발생한 예외와 순서대로 비교하므로 상속 관계를 고려해 예외 타입을 지정해야 한다.

System.Exception이 맨 위에 있으면 모든 예외가 System.Exception 으로 형 변환 가능하므로 다음의 catch 블록에 있는 코드는 결코 실행되지 않는다.

catch 순서가 잘못된 예시

int divisor = 0;
string txt = null;

try
{
    Console.WriteLine(txt.ToUpper());	//System.NullReferenceException 예외 발생
    int quitient = 10 / divisor;
}
catch (System.Exception)
{
    Console.WriteLine("예외가 발생하면 언제나 실행된다.");
}
catch(System.NullReferenceException)	//컴파일 오류 발생
{
    Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다.");
}
catch(System.DivideByZeroException)	//컴파일 오류 발생
{
    Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다.");
}

catch 구문에는 예외 타입뿐만 아니라 예외의 인스턴스를 변수로 받는 것도 가능하다.

이 변수를 이용하면 해당 예외 타입에서 제공되는 모든 멤버에 접근해서 정보를 가져올 수 있다.

위의 방법을 이용하면 프로그램이 실행되는 도중 발생하는 예외의 기록을 남길 수 있다.

이 기록을 잘 활용하면 사용자 컴퓨터에서 발생한 오류의 원인을 쉽게 찾을 수 있다.

호출 스택

  • System.Exception 타입에는 StackTrace라는 string 타입의 멤버가 있다.
  • StackTrace는 자료구조의 일종인 스택에 저장된 데이터를 추적하는 것이다. 프로그램이 실행될 때 내부적으로는 스택 자료구조가 사용되고 그 안에 메서드의 호출 과정과 메서드에 정의된 지역 변수의 데이터가 담긴다.
  • 일반적으로 스택 트레이스라고 하면 메서드의 호출 과정만 포함하고 이 때문에 (메서드의) 호출 스택을 얻는다와 스택트레이스를 얻는다는 표현은 동일한 의미이다.

호출 스택이 중요한 이유?

→ 문제가 발생한 경우 호출 스택 정보의 유무에 따라 원인 파악의 난이도가 결정된다.

예외는 WriteText 메서드에서 발생하지만, 이 메서드는 두 군데에서 사용되고 있다. 오류 원인이 된 메서드의 이름만 알아도 문제 해결에 도움이 되지만, 원인을 파악하렴녀 해당 메서드를 사용한 곳을 모두 검사해야 한다.

이 문제는 호출 스택을 알게 되면 자연스럽게 해결된다.

CLR은 예외 객체에서 Stack-Trace 속성을 통해 호출 스택을 제공하며, 위의 코드에서 NULL 참조 예외가 발생한 경우 그 속성에 값이 담긴다.

at Program.WriteText(String txt) in c:\temp\ConsoleApp1\Program.cs:line 24
at Program.HasProblem() in c:\temp\ConsoleApp1\Program.cs:line 19
at Program.Main(String[] args) in c:\temp\ConsoleApp1\Program.cs:line 9

호출 스택을 볼 때는 아래에서 위로 실행된 메서드의 단계를 확인하면 된다.

Main 메서드 내에서 HasProblem 메서드가 호출됐고, HasProblem 내에서 다시 WriteText 메서드가 호출된 것으로 해석할 수 있다.

예외 발생

예외를 처리하는 것도 가능하지만 임의로 발생시키는 것 또한 가능하다.

C#은 throw 예약어를 제공한다.

throw를 사용하는 법은 간단하다. 예외 타입의 인스턴스를 생성한 후 그것을 throw에 전달하면 된다.

123이 아닌 문자열을 입력하면 직접 System.ApplicationException 객체를 생성해 예외를 발생시킨다.

 

CLR로 부터 전달받은 예외 객체를 throw에 전달하는 것도 가능하다.

try
{
    string txt = null;
    Console.WriteLine(txt.ToUpper());
}
catch (System.Exception ex)
{
	throw ex;
}

catch 블록 내에 있는 throw는 예외 객체 없이 단독으로 사용할 수도 있다.

try
{
    // 생략
}
catch(System.Exception)
{
    throw;
}

throw ex와 throw의 표현에 있는 차이점?

→ throw 단독으로 사용하는 것이 좋다. 이 차이는 예외를 발생시킨 코드를 별도의 메서드로 정의해보면 알 수 있다.

throw를 단독 사용했을 때

throw를 단독으로 사용해야 원래 예외의 발생 지점(stack trace)을 유지할 수 있기 때문이다. throw ex;를 쓰면 스택 추적이 초기화되어 디버깅이 힘들어진다.

언제 throw ex;를 쓰나?

  • 사실 특별한 이유 없으면 쓰지 말아야 한다.
  • 굳이 쓸 경우는 새로운 예외로 감싸거나, 메시지를 수정해서 던지고 싶을 때 사용한다.

정리

  • throw; → 원래 예외 그대로 재던짐 (스택 추적 보존 ✅) → 거의 항상 이걸 사용해야 한다.
  • throw ex; → 스택 추적 초기화됨 ❌ → 원래 발생 위치를 잃어버려 디버깅 불편하다.
  • 새로운 의미를 부여하고 싶으면 새 예외 + ex를 InnerException으로 감싸기.

사용자 정의 예외 타입

예외는 타입이다. 따라서 개발자가 원한다면 별도로 클래스를 만들어 사용할 수 있다.

사용자 정의 예외는 System.Exception을 부모로 두는 것을 권장한다.

이렇게 사용자 정의 예외를 지원하긴 하지만 현실적으로 사용 빈도는 높지 않다. 규모가 큰 프로젝트에서 내부 규정에 의해 체계적인 예외를 강제화 하는 상황에서나 겨우 사용되는 정도라고 한다.

오류가 발생한 상황을 문자열 인자로 전달할 수 있기 때문에 굳이 별도의 예외 타입을 정의해서 사용하면 번거롭기만 할 수 있다.

올바른 예외 처리

프로그램의 오작동을 방지를 위해 예외를 발생시켜야 하는가? 아니면 동작하지 않았다는 결과를 알리는 것으로 만족해야 하는가?

bool LogText(string txt)
{
    if(txt == null)
    {
        return false;	//잘못된 txt 인자이므로 false 반환
    }
    Console.WriteLine(txt.ToUpper());
    return true;	//정상 동작을 했다는 의미에서 true 반환
}

void LogTextWithException(string txt)
{
    if(txt == null)
    {
        //txt 인자가 null이면 안되므로 예외 발생
        throw new ArgumentNullException("txt");
    }
    Console.WriteLine(txt.ToUpper());
}

 

첫 번째 메서드는 전달된 인자가 null이면 아무런 동작도 하지 않고 제어를 반환한다.

두 번째 메서드에서는 예외를 발생시키는데, 호출 스택의 상위 메서드에서 try/catch를 수행하지 않고 있다면 프로그램이 비정상적으로 종료되는 위험이 있다.

즉, 프로그램을 계속 실행되게 하려면 반드시 try/catch를 지정해야 하는 번거로움이 있다.

 

두 메서드를 사용하는 측면을 보면 차이점을 실감할 수 있다. 

(앞서 실행된 메서드의 반환값이 false이면 더는 실행하지 못하게 막아야 하는 상황을 가정)

if(LogText(aText) == false)
{
    return;
}

if(LogText(bText) == false)
{
    return;
}

if(LogText(cText) == false)
{
    return;
}

반면 예외를 발생시키는 코드의 경우 좀 더 간결하게 사용 가능하다.

try
{
    LogTextWithException(aText);	//여기서 예외가 발생하면 바로 catch문으로 이동
    LogTextWithException(bText);	//여기서 예외가 발생하면 바로 catch문으로 이동
    LogTextWithException(cText);
}
catch(ArgumentNullException) { }

중요한 차이점이 하나 더 있다.

개발자는 사람이고 사람은 실수할 수 있다. LogText 메서드는 false 반환값을 무시해도 쓰는데 아무런 지장이 없다.

강제성이 없기 때문에 개발자가 아래와 같이 사용해도 막을 수 있는 방법이 없다.

LogText(aText);
LogText(bText);
LogText(cText);

위와 같은 실수가 전자 상거래 사이트에서 물건 결제 코드에 발생했다면 반환값 처리를 소홀히 해서 물건을 발송하는 코드가 실행될 수 있다. 차라리 결제 과정에서 예외가 발생하도록 설계했다면 물건이 무료로 발송되는 최악의 경우는 막을 수 있었을 것이다.

예외를 사용하라는 규칙만으로 만족할 수 있는가?

→ 규칙을 세워 예외 발생을 남용하다 보면 개발자는 습관적으로 try/catch문을 작성한다.

try
{
    LogTextWithException(aText);
    LogTextWithException(bText);
    LogTextWithException(cText);
}
catch { }

습관적인 예외 처리가 낳는 부정적인 결과로는 '예외를 먹는(swallowing exceptions)' 상황이 있다.

  • 프로그램에 문제가 발생했는데, 예외 처리로 인해 외부에 아무런 문제 현상이 나타나지 않는 것을 의미한다.
  • 예외 처리를 이렇게 해버리면 오류를 나타내는 반환값을 무시하는 방식과 다를 게 없다.
  • try/catch는 스레드 단위마다 단 한번만 전역적으로 적용해야 한다.
  • 코드에서 예외처리가 필요하다면 try/catch를 하더라도 catch에 정확한 예외 타입을 지정하는 것을 원칙으로 한다.
  • 자원 수거가 목적인 try/finally 절은 자유롭게 사용할 수 있다.

이런 원칙에서 한가지 문제가 있다.

예외가 발생한 경우의 처리가 매우 무겁다.

for(int i = 0; i< 100000; i++)
{
    try
    {
        int j = int.Parse("53");
    }
    catch (System.FormatException)
    {
    }
}

위의 코드는 53이 정상적인 문자열이므로 예외가 발생하지 않지만, 10만번 반복되는 수행 시간을 측정해보면 10밀리초도 안되어 완료된다.

이 메서드는 문자열이 숫자 형식이 아닌 경우 System.FormatException 예외를 발생시킨다. 따라서 53을 5T로 바꾸고 다시 실행하면 10만번의 예외가 발생하게 되고 프로그램이 실행되는데 3초가 넘게 걸린다. → 실행 환경에 따라 다르다.

 

이유

  • 예외 처리할 때 CLR 입장에서는 실행해야 할 내부 코드가 늘어나기 때문에 처리 시간이 그만큼 늘어나는 것이다.
  • 이러한 문제점을 인식한 마이크로소포트는 Parse 메서드를 out 인자를 사용해 개선한 TryParse 메서드를 BCL에 포함시켰다.
  • 이 메서드는 문자열이 숫자로 바꿀 수 있는 경우에만 out 형식의 인자에 숫자값을 담고, 메서드 실행이 성공했는지 여부만 반환할 뿐 예외는 발생시키지 않는다.
for(int i = 0; i < 100000; i++)
{
    int j;
    bool success = int.TryParse("5T", out j);
}

TryParse로 바뀐 메서드는 문자열 값에 상관없이 10밀리초 내로 수행된다.

정리

예외처리를 할 때 다음과 같은 규칙을 지키는 것이 좋다.

  1. 공용(public) 메서드에 한해서는 인자값이 올바른지 확인하고, 올바른 인자가 아니라면 예외를 발생시킨다.
  2. 예외를 범용적으로 catch하는 것은 스레드마다 하나만 둔다. 그 외에는 catch 구문에 반드시 예외 타입을 적용한다.
  3. try/finally의 조합은 언제든 사용 가능하다.
  4. 성능상 문제가 발생할 수 있는 경우, 호출 시 예외가 대량으로 발생하는 메서드가 있다면 예외 처리가 없는 메서드를 함께 제공한다.

Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지

'C#' 카테고리의 다른 글

C# 1.0 - 힙과 스택  (0) 2025.09.26
C# 1.0 - 프로젝트 구성  (0) 2025.09.24
C# 1.0 - 연산자  (0) 2025.09.23
C# 1.0 - 문법요소  (0) 2025.09.21
C# 객체지향 문법 [C#의 클래스 확장 - 멤버 유형 확장]  (4) 2025.07.26