메소드가 특정 처리 작업 도중 어떠한 이유로 처리가 실패됐을 경우, 이 실패를 알리는 방법에는 두 가지가 있다.
C언어에서 특정 함수가 처리를 하는 도중 처리가 실패됐음을 알리는 방법은 반환값을 통해 함수를 호출한 쪽에 알리는 방법을 사용하고 있다.
if(처리 실패 시)
return -1; // 실패를 의미하는 -1
else
return 0; // 성공을 의미하는 0
반환값으로 실패를 알리는 방법을 사용할 경우, 이 함수를 호출하는 쪽에서는
함수가 실패할 경우 어떠한 값을 반환하는지 체크해야하고, 함수를 호출할 때 마다 반환값을 체크하는 코드를 작성해야만 한다.
int state = func();
if(state == -1){
// 예외 처리 코드
}
이러한 방법은 "프로그래밍은 사람이 하는 것" 이라는 전제를 감안하면 실수할 여지를 충분히 줄 수 있다.
문제는 깜빡하고 반환값 체크를 하지 않는 것이 아니라, 깜빡하고 반환값 체크를 하지 않았음에도 불구하고 프로그램이 정상적으로 실행된다는 것이다.
이 경우 내가 기대하는 값과 다른 값들이 프로그램 내에서 움직일 수 있고, 예상치 못한 타이밍에 문제가 발견되어서 그때 문제가 어디서 발생했는지 파악하려고 해도 파악하기 어려운 상황이 만들어질 수 있다.
프로그래밍 언어가 탄생하기 이전부터 UNIBAC 의 명령에는 "연산 중 오버 플로우가 발생하면 특정 메모리 주소로 이동하라" 는 명령이 존재했었다.
그리고 COBOL 에서도 파일 작업 또는 연산 작업 중 특정 실패 상황이 발생할 경우 실패 처리 코드를 실행하는 기능을 제공했다.
COMPUTE THE-SUM = THE-SUM + INPUT-NUM // 연산 명령
// ON SIZE ERROR -> 연산 중 실패 상황
ON SIZE ERROR DISPLAY "The intermediate data is out of range."
END-COMPUTE
하지만 COBOL 에서 제공하는 특정 실패 상황은 COBOL 설계자가 정해놓은 두 가지 상황만 존재했기 때문에 논리적인 실패 상황(잘못된 인자값 사용 등)을 처리할 순 없었다.
이후 PL/I 라는 언어에서 개발자가 직접 "실패 상황" 을 정의하고, 또 개발자가 직접 실패 상황을 발생시킬 수 있는 기능을 제공했다.
(참고로 PL/I 언어에서는 우리가 흔히 알고 있는 예외를 "조건(Condition)" 이라고 표현한다.)
// 실패 정의 및 실패 처리 코드 작성
ON CONDITION(실패 이름) BEGIN;
PUT SKIP LIST('ACCOUNT HAD AN OVERDRAFT ON'||DATE())');
END;
.
.
.
// 특정 상황 시 정의한 실패(조건) 발생
IF ACCOUNT_BALANCE < TOTAL WITHDRAWAL THEN SIGNAL CONDITION(실패 이름);
얼핏보면 지금 우리가 알고 있는 자바의 예외 처리 매커니즘과 비슷해 보인다.
하지만, 큰 차이점이 하나 있는데 PL/I 에서의 실패 처리는 특정 실패 상황에 대한 실패 처리 코드가 이미 정해진 상태로 시작된다는 점이다.
자바의 경우 같은 예외가 발생하더라도 코드 위치에 따라 다른 처리가 가능한 유연한 방식이다.
즉, 자바는 우선 예외가 발생할 수 있는 코드를 try 에 묶어놓고 그 이후에 catch로 발생되는 예외를 처리하기 위한 코드를 작성한다.
이후로도 예외 처리에 대한 수많은 논의가 꾸준히 진행되었고,
1985년 C++ 언어가 등장하면서, 현재 우리가 알고 있는 예외 처리 매커니즘이 채택되었다.
try {
fun(); // fun 함수에서 예외가 발생할 수 있음 (throw)
} catch (예외) {
// 예외 처리 영역
return;
}
하지만, 이러한 C++ 언어의 예외 처리 매커니즘에도 한 가지 문제점이 있었는데, 그것은 try 영역에서 사용되는 자원(메모리 및 파일 리소스 등)을 해제와 관련이 있다.
try 영역의 코드를 실행하다가 예외가 발생하면 catch 영역의 코드들이 실행된다.
보통의 경우 예외가 발생했다는 건, 해당 함수의 실행이 더 이상 진행될 수 없음을 의미하기 때문에 catch 영역에서 예외 처리 후 함수를 종료하는데, 이때 catch 영역의 마지막 부분에 사용했단 자원을 해제해줘야 한다.
만약 예외가 발생하지 않았다면 try~catch 문의 다음 줄부터 실행을 이어나갈 것인데,
이후 문장들 중 return 문이 존재하는 문장이 많으면 많을수록 자원 해제 코드는 늘어나야할 것이다.
즉, 함수의 출구(return) 마다 자원 해제 처리가 필요하다는 것이다.
개발은 누가하는가, 사람이 하지않는가.
실수할 여지가 충분하다.
그래서, Windows Programming 에서는 SEH 도입 시 finally 구문을 추가했다.
finally는 try/catch 와 함께 사용할 수 있으며, try 영역으로 실행 흐름이 들어오면 try영역을 벗어날 때 반드시 실행되는 영역이다.
즉, try안에서 return이 되든 return이 안되고 try/catch 문을 벗어나든 예외가 발생되어 catch 영역으로 이동하든 무조건 실행되는 영역이 finally 영역인 것이다.
따라서 finally 영역에 자원 해제 코드를 모아놓으면 자원 사용 이후 자원 해제를 보장할 수 있게 된다.
그리고 이러한 SEH 의 철학을 이어받아 현재 자바도 try catch finally 구문을 제공하고 있다.
심지어 Java 7 버전부터는 finally 영역을 사용하지 않아도 try 영역에서 사용하는 자원을 자동으로 해제해주는 try with resources 구문을 지원한다.
참고로, C++ 에서 이러한 자원 해제 문제를 해결하기 위해 생성자와 소멸자를 이용하는 방법을 사용한다.
소멸자는 함수가 종료될 때 함수 내에서 사용되던 지역 변수가 참조하는 객체를 소멸시킬 때 호출하는 특수한 함수이다.
아무튼, 지금까지 try catch finally 구문까지 등장했고 이제 예외 처리 매커니즘은 더 이상 문제가 없는지 이후에도 꾸준히 논의되었다.
하지만, 이러한 매커니즘에서의 개발자는 자발적으로 예외가 발생할 것 같은 코드들을 try로 묶고 catch에서 발생 예상되는 예외를 명시해서 처리해야하기 때문에 역시 "사람의 실수" 로 부터 자유롭지 않다.
심지어 다른 사람이 만든 함수를 사용할때는 그 함수를 만든 사람만이 어떤 예외를 발생할 가능성이 있는지 정확히 알고 있다. 물론 개발 문서에 정리해두면 되지만, 개발하면서 문서를 하나하나 확인하기도 쉽지 않다.
즉, 다른 곳에서 어떤 예외를 발생시킬 가능성이 있는지 알기 어렵고, 문서를 통해 어떤 예외가 발생 가능성이 있는지 확인한다고 하더라도 예외 처리의 강제성이 없기 때문에 실수를 할 여지가 있다.
심지어 C++ 의 예외 처리 매커니즘 부터는 예외가 호출한 쪽으로 전파되기 때문에 이러한 예외 처리를 꼼꼼하게 하지 않으면 애플리케이션이 비정상적으로 종료될 수 있는 치명적인 상황까지 이어질 수 있다.
자바는 첫번째 문제인 "사용하려는 메소드가 어떤 예외를 던질 가능성이 있는지 확인하기 어렵다" 를 해결하기 위해 자바는 throws 절 이라는 구문을 추가했다.
throws 절은 메소드 내에서 발생하는 예외를 메소드 내에서 처리하지 않고, 호출한 쪽으로 예외를 전파키겠다는 의미로, 메소드 시그니처에 작성한다.
자바는 또한 두번째 문제인 "강제성이 없기 때문에 실수할 여지가 있다" 를 해결하는 방법으로 검사 예외(Checked Exception) 이라는 것을 채택했다.
"검사 예외" 라는 번역이 조금 어색하긴 하지만, 또 이게 가장 적합한 번역같기도 하다..
Checked Exception 은 말 그대로 컴파일러가 예외 처리를 체크하는 예외를 뜻한다.
즉, Checked Exception 을 throw 하는 순간 어디선가는 이 Checked Exception 를 처리해줘야한다.
이러한 검사 예외는 개발자의 실수를 컴파일 단계에서 발견해서 고칠 수 있기 때문에 소프트웨어 개발의 생산성을 향상시킬 수 있지만, 나름대로 귀찮다는 단점이 있다.
특정 메소드에서 Checked Exception 을 throws 하는 순간 이 메소드를 사용하는 모든 곳에서부터 예외 처리 코드가 필요하기 때문이다.
C# 이 자바의 많은 부분과 비슷하지만 검사 예외 만큼은 도입하지 않은 이유도 이 때문이지 않을까 싶다.