[프로그래밍 언어론] 예외의 처리
예외
예외(Exception)란 치명적이지 않은 오류, 비정상적 상황이라 할 수 있다. Java로 프로그램을 설계하거나 PS 문제를 풀어본 경험이 있다면 사실 익숙할 것이다. 프로그램에서 예외가 발생하면 이것을 처리하고 계속 수행하도록 해야 하며, 예외를 처리하지 못하면 프로그램은 종료된다. 따라서 예외를 적절히 처리해야 하는데, 예외를 처리하려면 다음 기능이 필요하다.
1. 예외 정의 - 새로운 예외를 정의할 수 있는 기능
2. 예외 발생 - 예외를 발생시킬 수 있는 문장
3. 예외 처리 - 예외를 처리하기 위한 문장
예외 처리 모델
예외 처리 모델에는 재개 모델, 종료 모델이 있다. 이 두 모델의 차이는 예외가 발생한 경우, 이후의 프로그램 실행 흐름에 있다. 아래 코드를 통해 재개 모델과 종료 모델의 코드 실행 흐름을 알아보자.
try {
raise Exception, (1) // 예외 발생
(2)
} catch (Exception) {
(3) // 예외 처리
}
(4)
재개 모델은 예외가 발생하면 예외 처리 후 다음 문장을 실행하는 모델이다. 따라서 프로그램은 예외가 발생하면, 이를 처리하고 다음 문장을 처리하므로 재개 모델에서는 위 프로그램은 (1) -> (3) -> (2)의 순서로 진행된다. 종료 모델은 예외가 발생하면 이 예외를 처리한 후, 프로그램을 종료하는 모델이다. 따라서 종료 모델에서는 (1)에서 예외가 발생하면 (3)에서 예외를 처리한 후, (4)로 이동해 프로그램을 종료한다. 대부분의 언어는 현재 종료 모델 방식을 채택하고 있다.
S 언어의 예외 처리
그렇다면 우리가 차근차근 설계해온 S 언어에 예외 처리를 추가하려면 어떻게 해야 할까? 우선 S 언어는 종료 모델 방식의 예외 처리를 기초로 한다.
<command> -> ... | exc id;
<stmt> -> ... | raise id; | try <stmt> catch(id) <stmt>
위와 같이 예외 처리를 구현할 수 있다. exc id;는 예외를 정의하는 문장이며, raise id;는 id 이름의 예외를 발생시키는 문장이다. 마지막 try <stmt> catch(id) <stmt> 문장은 Java 코드와 유사한 구성의 예외 처리 문장이다. 예외 처리 문장을 이용해 발생한 예외를 처리할 수 없다면 프로그램을 종료해야 한다. 위 예외 관련 문장을 통해 만든 S 언어의 예시를 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
exc InvalidInput;
let
int x = 0; int y = 1;
in
read x;
try { if (x < 0) then
raise InvalidInput;
else
while (x != 0) {
y = y * x;
x = x - 1;
}
print y;
} catch (InvalidInput) print "invalid input"; end;
|
cs |
Line 1에서는 예외 정의 문장을 통해 InvalidInput 예외를 정의하고 있다. 이후 Line 6에서 예외의 발생 조건을 작성하고, Line 7에서 앞서 정의한 InvalidInput 예외를 발생시켰다. Line 5 ~ 14의 try ~ catch 문장은 InvalidInput의 예외 처리 구문으로, 실질적 예외 처리 문장은 Line 14에 작성되어 있다. InvalidInput 예외가 발생하면 "invalid input" 문자열을 출력하는 방식으로 InvalidInput 예외를 처리하고 있다.
1
2
3
4
|
try
S1
catch (E1) S2
catch (E2) S3
|
cs |
위처럼 문장 S1을 실행한 경우, 발생할 수 있는 예외가 여러 개 있다면 여러 개의 catch 문장을 이용해 여러 예외를 처리할 수 있도록 S 언어를 확장할 수 있다. 위 코드에 따라 S1 문장을 실행했을 때 E1 예외가 발생하면 S2 문장을 실행해 E1 예외를 처리하고, E2 예외가 발생하였다면 S3 문장을 실행해 E2 예외를 처리할 것이다. 위 문장은 다음과 같이 중첩된 try 구문을 이용하여 작성할 수도 있다.
1
2
3
4
5
|
try
try
S1
catch (E1) S2
catch (E2) S3
|
cs |
Java의 예외 처리
Java에서는 새로운 예외를 클래스로 선언하여 정의할 수 있다. 새로운 예외를 정의하는 클래스는 Exception 클래스나 서브클래스로부터 상속받아 정의해야 하며 다른 클래스와 마찬가지로 생성자, 멤버 필드, 메소드 등을 가질 수 있다. 즉, 예외를 나타내는 객체는 다른 일반 객체처럼 사용할 수 있다. Java에서는 ArimeticException, ArrayIndexOutOfBoundsException, NegativeArraySizeException, NullPointerException 등의 여러 예외들이 있다.
Java는 throw 문장을 이용해 예외를 발생시킨다. 발생한 예외가 호출되었는데 처리 구문이 없다면 에러 메세지를 출력하고, 호출 스택 트레이스(call sstack trace)를 포함해 main에서 예외 발생까지의 메소드 호출 과정을 보여준다.
1
2
3
4
5
6
7
|
try {
실행 코드
}
catch (E1 x) { 예외 처리 코드 }
...
catch (En x) { 예외 처리 코드 }
[finally { 예외 처리와 무관하게 실행할 코드 }]
|
cs |
발생된 예외는 try - catch 문장을 이용해 처리한다. 예외가 발생할 수 있는 실행 문장을 try 블록에 작성하고, 예외가 발생하면 catch 문장을 이용해 처리한다. E1 ~ En까지의 catch 절에서 발생한 예외가 처리되지 않으면 해당 예외가 정상적으로 처리되지 않은 것이다. Line 7의 finally 문장은 옵션으로 예외 발생과 처리 여부에 관계없이 실행되는 문장이다.
한 메소드 내에서 발생한 예외가 처리되지 않으면 예외가 발생한 메소드의 호출자 메소드에 예외가 전파된다. 전파된 예외는 호출자 메소드에서 처리될 수도 있고, 호출자 메소드에서도 처리되지 않으면 호출자의 호출자 메소드로 전파된다. 이와 같이 발생된 예외가 호출의 역순으로 처리될 때 까지 호출자에게 전파되는데, 이것을 예외 전파(Exception Propagation)라 한다. A() -> B() -> C() 순서로 메소드가 호출되는 프로그램에서 C()에서 예외가 발생되는 경우에는, 예외 전파가 C() -> B() -> A() 순으로 이루어질 것이다. 이렇게 전파된 예외가 처리되지 않으면 main() 메소드까지 전파되며, main() 메소드에서도 처리되지 않으면 이 프로그램은 종료될 것이다. 이러한 예외 전파의 특성을 이용해 호출자 메소드 내에서 발생된 예외를 호출자 메소드에서도 처리할 수 있다.
Java에서 예외가 발생할 수 있는 코드가 있으면 이를 처리할 try - catch 문장이 있어야 한다. 발생한 예외를 처리할 try - catch 문장이 없으면 예외의 발생으로 프로그램이 갑자기 종료될 것이다. 따라서 Java의 컴파일러는 예외를 처리할 try - catch 문장이 있는지 컴파일 시간에 미리 검사하는 예외 검사(exception checking) 방식을 사용한다. Java 컴파일러는 예외 검사를 통해 어떤 메소드 내에서 발생 가능한 예외가 해당 메소드 내에서 처리할 수 있는지, 혹은 메소드 헤더에 선언되었는지 검사한다. 이러한 예외 검사 과정은 다음과 같이 이루어진다.
1) 메소드 내 발생 가능한 예외가 해당 메소드 내에서 try - catch 문장에 의해 처리 가능한지 검사한다.
2) 처리 불가능하다면 메소드 헤더에 throws 절에 선언되어 있는지 검사한다.
1), 2)중 하나라도 가능하면 오류가 아니지만, 둘 다 아니라면 오류이므로 Java 컴파일러는 오류 메세지를 출력하게 된다. 앞서 설명하였듯 메소드 내에서 발생한 예외는 호출자 메소드에서 처리해도 무관하므로 호출자 메소드에서도 발생한 예외를 처리한 try - catch 문장이 있는지 확인해야 한다. Java의 예외 검사 방식을 정리하면 다음과 같다.
1) 메소드 내 발생 가능한 예외에 대해 이를 처리할 수 있는 try - catch 문장이 있는지 검사한다. 없으면 예외가 throws 절을 사용해 메소드 헤더에 선언되어 있는지 검사한다.
2) 메소드 내에서 다른 메소드를 호출하는 경우, 피호출자 헤더에 throws 절로 선언된 예외 정보를 참조하여 이 예외를 처리할 수 있는 try - catch 문이 있는지 검사하고, 없으면 호출자 메소드 헤더에 이 예외가 선언되어 있는지 검사한다.
하지만 이런 Java의 예외 검사에서 문제가 있다. 모든 예외에 대해 이렇게 미리 예외 검사를 할 수 있을까? 런타임 예외는 어디서 발생할 지 미리 알기도 힘들고, 발생 가능한 지점이 너무 많아 이런 예외들을 미리 모두 검사하는 것은 어렵다. 따라서 Java 컴파일러는 실행 시간 예외에 대해서는 예외 검사를 하지 않고, 코드 작성자에게 처리를 위임한다. 이와 같이 예외 검사 여부에 따라 검사 예외(checked exception)와 비검사 예외(unchecked exception)로 구분한다.
검사 예외는 예외 발생 시 이를 처리할 처리 문이 있는지 컴파일러가 미리 검사하는 예외이다. 실행 시간 예외를 제외한 모든 예외는 검사 예외이다. 메소드 내에서 처리되지 않는 예외는 throws를 사용해 메소드 헤더 부분에 선언되어야 한다. 비검사 예외는 실행시간 예외인 RuntimeException으로부터 상속받는 표준 런타임 예외들로 컴파일러가 예외 검사를 하지 않아 프로그래머가 이 예외를 필요에 따라 처리하도록 코드를 작성해야 한다.