[java] 예외처리

예외란 프로그램 실행 도중 발생하는 문제 상황을 얘기합니다.
따라서 컴파일 시 발생하는 문법적인 오류는 예외의 범주에 포함되지 않습니다.
예를 들면 아래와 같은 상황이 있습니다.

1
2
3
4
public static void main(String[] args){
String str = null;
System.out.println(str.length());
}

이 상황은 문법적으로는 문제가 없으므로, 컴파일 오류가 발생하지 않습니다.
그런데 실행하면

exception-result1

와 같이 Exception이 발생합니다.

str변수에서 length 메서드를 호출한건데, str에는 현재 null이 들어가 있으므로(주소값이 없으므로)
비어있는 주소를 참조하여 메서드를 실행하려 했다… 뭐 이런의미로 NullPointerException이 발생한 겁니다.

근데 보다시피 해당 NullPointerException은 java.lang 패키지 내에 있는 클래스네요.
이렇듯 Exception라고 별 다른게 아니라 다 클래스들입니다. 예외적인 상황에 따라 대부분의 클래스가 정의되어 있고,
약속된 예외 상황에 따라 해당 예외 클래스가 발생합니다.

몇가지 예를 볼까요

  • NullPointerException : 참조변수가 null인 상황에서 메서드를 호출하는 상황
  • NegativeArraySizeException : 배열 선언 과정에서 배열의 크기를 음수로 지정하는 상황
  • ClassCastException : 허용되지 않는 형변환을 하는 상황

이렇듯 대부분 상황에 따라 정의되어 있습니다.

예외 클래스의 계층도

예외도 결국 클래스라고 했습니다. 그러므로 각 클래스간 계층이 존재하고, 이에 따라 예외의 종류도 나뉩니다.

java-exception

보다시피 Throwable이 예외 클래스의 최상위 클래스입니다.
그리고 아래에 Error, Exception 2가지 클래스가 있는데, 이 2개의 클래스가 예외의 큰 범주입니다.

Error

단순히 예외라고 하기에는 심각한 오류의 상황을 표현하는 예외입니다.
위의 그림에서 OutOfMemoryError, StackOverflowError 등이 보이시죠? 다들 많이 보셨을겁니다.
한 단계 올라가보면 VirtualMachineError로 자바 가상머신에 문제가 생겼음을 알려주고 있습니다.
한 단계 더 올라가보면 Error 클래스가 보이지요. 이처럼 심각한 오류들은 다 Error 예외의 하위 예외로 정의되어 있습니다.
이건 뭐… 저희가 할 수 있는 특별한 방법이 없으므로 그냥 프로그램이 종료되도록 놔두는 수밖에 없습니다.
종료 후에 원인을 찾고 해결하던가 해야합니다. 당장 애플리케이션 단에서는 저희가 조치를 취할 방법이 없습니다.

Exception

일반적으로 우리가 마주하게 되는 예외들로, 저희가 직접 처리할 수 있는 대부분의 예외들을 말합니다.
Exception 내에서도 또 종류가 2가지로 나뉩니다.

예외 처리 방법

예외처리의 대상이 되는 Exception 예외의 하위 예외를 대상으로 하며, 처리 방법에는 2가지가 있습니다.

try ~ catch [ ~finally ]

사용자가 직접 예외를 처리하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args){
try{
String str1 = null;
String str2 = "asd";

System.out.println(str1.length());
System.out.println(str2.length());
} catch(NullPointerException e){
System.out.println("참조변수의 값이 NULL 입니다.");
} catch(Exception e){
System.out.println("다른 예외가 발생하였습니다.");
} finally{
System.out.println("마지막에 항상 실행되는 부분입니다.");
}
}

예외가 발생할 수 있는 부분을 try 구문으로 감싸고,
예외가 발생 시 발생 시점으로 부터 더 이상 try 부분의 코드는 진행되지 않고 catch 구문으로 들어가게 됩니다.

catch의 파라미터는 try 구문에서 발생한 예외 클래스를 받게 됩니다. 다형성 가능합니다.
catch 구문은 1개 이상 정의 가능하며(하나의 로직에서 발생할 수 있는 예외는 1개 이상이기 때문에),
위에서 부터 순차적으로 실행됩니다.

하나의 catch 구문에 들어갔을 경우 그 아래 catch구문은 건너뛰게 됩니다. switch case 처럼요.
그리고 마지막 finally 구문은
예외가 발생하든, 발생하지 않든 언제나 실행하는 부분으로써 선택적인 사항입니다.

결과는 아래와 같습니다.

excetpion-result2

먼저 System.out.println(str1.length()); 부분에서 null값 참조로 NullPointerException이 발생하게 됩니다.
(예외가 발생했으므로 진행이 멈추게 되어 그 아래 System.out.println(str2.length()); 은 진행되지 않습니다.)
발생된 NullPointerException은 try 구문을 빠져나와 아래의 catch 구문에서 자기가 들어갈 곳을 찾게 됩니다.
위쪽부터 살펴보니 파라미터로 NullPointerException을 받는 catch 구문이 있네요.

구문에 진입하고, 구문 내의 행위를 실행합니다.
catch 구문의 선택은 switch case 와 같다고 했었죠… NullPointerException 예외 처리 구문에 들어갔으니
아래의 Exception 예외 처리 구문에는 들어가지 않게 됩니다.

근데 여기서 유의하고 넘어가야 할 부분이 있습니다.
catch 구문의 위치를 바꾸었다면 어떻게 될까요?
예를 들어 아래와 같이 예외를 처리했을 경우를 보시죠.

1
2
3
4
5
6
7
catch(Exception e){
// Exception 예외 처리
} catch(IOException e){
// IOException 예외 처리
} catch(NullPointerException e){
// NullPointerException 예외 처리
}

Exception은 모든 예외의 상위 클래스이므로, 발생하는 예외를 모두 다 받을 수 있습니다. 상속관계도 가능하다고 했었죠.
즉, 이런식으로 예외처리 지정해버리면 백날 천날 예외 터져봐야 젤 위의 Exception 받는 부분에서 다 걸리므로
아래에 IOException, NullPointerException에 대한 예외 처리는 무용지물이 됩니다. 항상 유의해야 합니다.

마지막으로 finally 구문은 선택적인 부분입니다. 써도 그만 안 써도 그만.
try 구문이 정상적으로 끝나든, 예외가 발생해서 catch 구문에 들어가고 끝나든 언제든 실행 될 부분을 적어주면 됩니다.
예를 들면 자원 반납, 로깅 처리 등이 있습니다.

throws

throws는 던지다 라는 의미를 갖고 있습니다. 그리고 예외처리에서의 throws도 동일한 의미로 사용됩니다.
발생된 예외를 자신을 호출한 쪽으로 던져버리는 것입니다. 처리를 위임한다고 표현하면 되곘네요.

사용 예는 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static void main(String[] args){
try{
System.out.println(calculator('+',1,2));
System.out.println(calculator('/',5,0));
System.out.println(calculator('*',3,3));
} catch(ArithmeticException e){
System.out.println("0으로 나눌 수 없습니다.");
}
}

static int calculator(char sign, int num1, int num2) throws ArithmeticException{
int result = 0;
switch(sign){
case '+':
result = num1 + num2;
break;
case '-':
result = num1 - num2;
break;
case '*':
result = num1 * num2;
break;
case '/':
result = num1 / num2; // 0으로 나누는 경우가 발생할 수 있다
break;
}

return result;
}

간단한 계산기 프로그램입니다.
보다시피 / 연산에서 0으로 나누는 예외인 ArithmeticException이 발생할 수 있지만 그에 대한 처리가 전혀 없습니다.
근데 잘 보시면 처리가 없는 대신 위에 throws 에서 ArithmeticException을 선언해주고 있네요.

위 구문의 의미는, ArithmeticException이 발생할 경우 자신을 호출한 메서드 쪽으로 그 처리를 던지겠다는 의미입니다.
위의 상황에서 calculator를 호출한 쪽은 main 메서드이므로, 보다시피 main 메서드에서 해당 예외를 잡아 처리하고 있음을 볼 수 있습니다.

JVM의 예외처리

main 메서드는 프로그램의 시작점이지만, main 메서드도 예외를 받았을 경우 throws로 던져버릴 수 있습니다.
그러면 결국 main을 호출한 쪽으로 던져지게 됩니다. main을 호출한 영역은 가상머신(JVM) 이죠.

결과적으로 예외처리가 가상머신에 의해 이루어지게 되는 것입니다.

  • 가상머신의 예외처리 방식
    1. getMessage 메서드를 호출한다.
    2. printStackTrace 메서드를 호출해 예외상황이 발생해서 전달되는 과정을 출력해 준다.
    3. 프로그램을 종료한다.

getMessage, printStackTrace 메서드는 예외의 최 상위 클래스인 Throwable에 정의된 메서드입니다.
getMessage는 예외에 대해 정의된 간단한 메시지를 보여주며, printStackTrace는 예외 발생 과정을 상세하게 출력해줍니다.
위의 calculator에서 Throwable 제공 메서드들을 사용해보겠습니다.

1
2
3
4
5
6
7
8
try{
System.out.println(calculator('+',1,2));
System.out.println(calculator('/',5,0));
System.out.println(calculator('*',3,3));
} catch(ArithmeticException e){
System.out.println(e.getMessage());
e.printStackTrace();
}

결과는 아래와 같습니다.

exception-result3

getMessage는 간단하게 예외의 내용을 출력해주고, printStackTrace는 예외 발생 과정을 상세하게 출력해줍니다.
특히나 printStackTrace는 예외 발생 시 원인을 찾는데 상당한 도움이 됩니다.

사용자 정의 예외

앞서 언급했던 NullPointerException, ArithmeticException 등의 경우,
실행 시 문제가 되므로 자바 가상머신에서 예외로 정의해놓았습니다. 미리 정의된 예외들이죠.
하지만 이 외에도 개발자가 직접 예외를 정의하는 방법도 있습니다.

예를 들어 비즈니스 로직에서의 예외가 있을 수 있습니다.
입/출금 관련 프로그램이 있다고 가정했을 때 잔고보다 많은 돈을 출금하려는 경우 자바 프로그램 상에서는 전혀 문제가 안되지만, 입/출금 관련 업무에서는 예외 상황이 됩니다.

현실에선 마이너스 잔고라는게 없기 때문입니다. (마이너스 통장 말고 일반 통장 기준입니다…ㅋㅋ)
이럴 경우는 개발자가 직접 예외를 정의해줘야 합니다.

위의 상황을 간단하게 정의해보겠습니다.

일단 사용자 정의 예외를 만들어줍니다. 예외 클래스가 되는 조건은 아래와 같습니다.

Exception 클래스를 상속한다

Exception은 예외 클래스의 상위 클래스입니다. 따라서 이를 상속함으로써 해당 클래스는 예외 클래스가 되고, try ~ catch 구문에 활용이 가능한 예외 클래스가 됩니다.

먼저 위의 상황에 맞는 예외 클래스를 하나 만들어보겠습니다.

1
2
3
4
5
public class NoMoneyException extends Exception{
public NoMoneyException(){
super("돈이 없습니다..");
}
}

별다른 처리 없이 간단히 문자열을 출력하게 하였습니다.
Exception 클래스를 상속하였으므로, 모든 예외처리 문법에서 사용 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
static int totalMoney = 100000;

static int withdraw(int money) throws NoMoneyException{
if(money > totalMoney){ // 예외 상황 발생시
throw new NoMoneyException(); // 예외 발생!
}else{
totalMoney -= money;
}

return totalMoney;
}

전달받은 돈이 전체 돈보다 작을 경우는 위에서 우리가 언급한 예외 상황이 됩니다.
그리고 해당 예외 상황이 발생했을 경우, throw 구문을 사용하여 예외를 발생시킵니다.
(new로 해당 예외를 생성하는 순간 해당 메서드에 예외가 발생된 것이기 때문에 해당 메서드에 예외 처리 구문이 필요합니다.
위와 같이 throws를 통해 호출한 곳으로 던져줘도 되고, 해당 메서드에서 직접 처리해도 됩니다.)

main에서 예외상황을 발생시켜 보겠습니다.

1
2
3
4
5
6
7
8
9
static int totalMoney = 100000;

public static void main(String[] args){
try{
System.out.println(withdraw(10000000));
} catch(NoMoneyException e){
e.printStackTrace();
}
}

잔고는 10만원인데 1000만원 인출을 시도하고 있습니다… 대단한…

exception-result4

가슴 아픈 예외가 발생했네요…
보다시피 상황에 맞춰 정의했던 예외가 발생하고 있음을 보실 수 있습니다!

체크 예외, 언체크 예외

우리가 마주하는 일반적인 예외인 Exception 하위 예외들은 그 사이에서도 2가지로 종류가 나뉩니다.
체크 예외와 언체크 예외 라는 이름으로 나뉩니다. 이는 문법적으로도 차이가 있으니 알고 가시는게 좋습니다.

일단 해당 예외를 나누는 기준은, RuntimeException 이라는 예외의 상속 여부입니다.
Exception의 하위 예외이면서 RumtimeException을 상속하지 않았을 경우 체크 예외, RumtimeException을 상속했을 경우 언체크 예외입니다.
(RumtimeException은 Exception의 서브 클래스이므로, 상속하면 자동으로 Exception의 하위 예외가 됩니다.)

체크 예외

RumtimeException을 상속하지 않은 일반적인 예외들입니다.
반드시 try ~ catch나 throws를 통해 처리를 해줘야 하기 때문에 체크 예외라고 부릅니다.
항상 문법적으로 체크한다 라는 의미로 보시면 좋을 것 같습니다.

예를 들면 IOException, SQLException 등이 있습니다.

1
2
3
4
5
6
7
8
9
public static void main(String[] args){
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

try{
System.out.println(br.readLine());
} catch(IOException e){
System.out.println("IOException 발생");
}
}

앞서 작성했던 예외처리와 별 다를것 없어 보이지만, 이를 직접 IDE에서 코딩해보시면 차이를 볼 수 있습니다.
IOException은 체크 예외이므로 위와 같은 try ~ catch 구문이나, throws 구문이 없으면 컴파일 오류가 발생합니다.
프로그램상에서 반드시 이 예외를 처리하고 넘어가도록 강조하는 것입니다.
만약 throws를 통해 체크 예외를 던져줬다면, 호출하는 쪽에서도 해당 예외를 처리하는 구문을 필수로 입력해야 합니다.
throw를 통해 체크 예외를 발생시킬 경우도 마찬가지입니다. 처리가 없을 경우 컴파일 오류가 발생합니다.

언체크 예외

RumtimeException을 상속한 예외입니다.
체크 예외처럼 해당 예외에 대한 처리를 문법적으로 강요하지 않기 때문에 언체크 예외라고 부릅니다.
개발자가 부주의할 경우 발생할 수 있는 예외들이라, 예상하지 못한 상황에 발생할 수 있는 그런 예외들이 아니므로 명시적인 처리를 강요하지 않은 것입니다.
예를 들면 NullPointerException, IllegalStatementException 등이 있습니다. 대부분의 예외가 런타임 예외입니다.

1
2
3
4
public static void main(String[] args){
String str = null;
System.out.println(str.length());
}

보다시피 해당 예외는 null값 참조로 인한 NullPointerException이 발생하는 코드입니다.
하지만 NullPointerException은 언체크 예외이므로 위의 코드는 컴파일 오류가 발생하지 않습니다.
throws를 통해 예외를 던져줬을 경우, throw를 통해 예외를 발생시켰을 경우에도 마찬가지입니다.

throws의 경우 호출하는 쪽에 해당 예외를 처리하는 코드가 없어도 되고,
throw의 경우에도 발생하는 메서드에 해당 예외를 처리하는 코드가 없어도 됩니다.
해당 예외를 처리하는 것은 선택사항입니다.