예외라는 것은 프로그래밍에서 발생하는 오류를 제어하고 처리하는 것이다.
오류라는 것은 우리(개발자)를 힘들게 하기에 다소 부정적인 느낌이 들 수도 있다.
하지만 제대로 된 실패(또는 덜 실패하는법)를 배워야 성공도 있는 법이기에 예외는 무척 중요하다.
또한 실패에 대한 두려움을 줄이거나 실패를 관리하는 것에도 큰 도움이 될 것이다.
프로그래밍을 하면 많은 오류 상황에 직면하게 된다. 기능이 많아질수록 오류가 발생할 확률은 기하급수적으로 증가한다. 따라서 오류를 잘 처리하는 방법들이 필요해지게 되었는데
자바에서 예외(Exception)란 개발자가 상정한 정상적인 처리에서 벗어나는 경우에 이러한 예기치 못한 오류를 처리하기 위한 방법이다.
try {
예외의 발생이 예상되는 로직
} catch (예외클래스 인스턴스) {
예외가 발생했을 때 실행되는 로직
} finally {
예외여부와 관계없이 실행되는 로직
}
//예시
public void divide() {
try {
System.out.println(this.left/this.right);
} catch (Exception e) {
System.out.println("오류가 발생했습니다 : " + e);
} finally {
System.out.println("finally");
}
}
// 오류가 발생했습니다 : java.lang.ArithmeticException: / by zero
// finally
예외가 발생하면 자바 버츄얼 머신은 메소드를 호출하듯이 catch를 호출하는데 매개변수로는 에러에 대한 정보를 담고 있는 객체를 인스턴스로 전달하고 이때 데이터 타입은 예외클래스 Exception이 사용된다.
finally는 예외가 발생해서 catch가 실행되든 예외없이 try가 실행되든 상관없이 언제나 실행되는 로직으로 생략이 가능하다.
예외를 처리하지 않았더라면 오류가 발생했을 때 프로그램이 중지되었을텐데 try-catch문을 이용함으로써 중지없이 나머지 로직들이 실행될 수 있게 되었다.
예외의 핵심은 뒷수습이다. 하지만 제대로 된 뒷수습은 대단히 어려운 문제이다.
자바에서 기본적으로 제공하는 뒷수습의 방법으로 Exception 클래스가 가지고 있는 여러가지 메소드들을 사용할 수 있는데 출력 결과를 보고 정보를 얼마나 상세하게 나타낼 것인지에 따라 적절한 메소드를 사용하면 된다. (상세도 : getMessage() < toString() < printStackTrace())
public void divide() {
try {
System.out.println(this.left / this.right);
} catch (Exception e) {
System.out.println("\n\ne.getMessage()\n" + e.getMessage());
System.out.println("\n\ne.toString()\n" + e.toString());
System.out.println("\n\ne.printStackTrace()");
e.printStackTrace();
}
}
// 출력 결과
Exception in thread "main" java.lang.ArithmeticException: / by zero
e.getMessage()
/ by zero
e.toString()
java.lang.ArithmeticException: / by zero
e.printStackTrace()
java.lang.ArithmeticException: / by zero
at org.javatorials.exception.Calculator.divide(Calculator.java:11)
at org.javatorials.exception.CalculatorDemo.main(CalculatorDemo.java:25)
자바에서는 여러 유형의 예외가 발생할 수 있다. 예를 들어, 두 개 이상의 서로 다른 예외가 발생할 가능성이 있는 상황에서는 다음과 같이 다중 catch 블록을 사용하여 각 예외를 개별적으로 처리할 수 있다.
예를 들어, 배열에 접근할 때 인덱스가 범위를 벗어날 수 있고, 0으로 나누기 연산을 수행할 때는 산술 예외가 발생할 수 있는 경우의 예시 코드다.
그런데 Exception 캐치를 가장 위로 올렸을 경우에는 모든 예외를 모두 포함한 예외이기 때문에 다른 캐치는 실행될 기회가 없어진다. (예외 상속관계의 가장 상단에 Exception이 있기 때문이다.)
public void handleMultipleExceptions(int[] numbers, int index, int divisor) {
try {
// 배열의 특정 인덱스 값 출력
int result = numbers[index] / divisor;
System.out.println("Result: " + result);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("예외 발생: 잘못된 인덱스 접근 - " + e.getMessage());
} catch (ArithmeticException e) {
System.out.println("예외 발생: 0으로 나눌 수 없습니다 - " + e.getMessage());
} catch (Exception e) {
System.out.println("예외 발생: 알 수 없는 오류 - " + e.getMessage());
}
}
예외를 반드시 처리해야만 하는 상황도 존재한다.
아래의 코드는 프로젝트 안에 memo.txt 파일을 만든 후에 위의 코드를 작성하면 memo.txt안의 텍스트를 input에 담아 출력하는 코드의 예시이다.
BufferedReader와 FileReader와 FileNotFoundException와 IOException는 자바가 기본적으로 제공하는 자바패키지가 아니고 java.io에 속한 패키지이므로 import 해야한다.
import java.io.*;
public class CheckedExceptionDemo {
public static void main(String[] args) {
BufferedReader bReader = new BufferedReader(new FileReader("memo.txt"));
String input = bReader.readLine();
System.out.println(input);
}
}
다만 저렇게만 작성하면 이클립스나 인텔리제이같은 IDEA가 파일을 읽기위한 객체인 'new FileReader("memo.txt")'부분에 빨간 밑줄을 만들어 줄 것이다. 거기에 마우스를 가져가 우클릭을 하면 컴파일러가 아래의 사진처럼 FileNotFoundException을 핸들링하지 않았다고 알려주면서 빠른 해결 수정법을 제공해준다.

자바 API에서 FileReader 생성자 도움말을 살펴보자

FileReader생성자는 파일 이름에 해당하는 파일이 존재하지 않거나 파일이 아닐때 FileNotFoundException 예외를 발생시킬 수 있기에 사용시에 반드시 예외처리를 할 것을 강제하고 있다.
import java.io.*;
public class CheckedExceptionDemo {
public static void main(String[] args) {
try {
BufferedReader bReader = new BufferedReader(new FileReader("memo.txt"));
String input = bReader.readLine();
System.out.println(input);
bReader.close();
} catch (FileNotFoundException e) {
System.out.println("파일을 찾을 수 없습니다. 파일 경로를 다시 확인해 주세요 : memo.txt");
} catch (IOException e) {
// TODO Auto-generated catch block
System.out.println("파일을 읽는 도중 오류가 발생했습니다.");
logError(e); // 로그 기록을 남기는 메서드 (예시)
String defaultInput = ""; // 기본값으로 설정하거나 빈 문자열 처리
e.printStackTrace(); e.printStackTrace(); // 오류의 상세 원인 출력
}
}
}
사실 위의 코드는 오류를 처리하는 것이라기 보다는 오류를 모면하는 코드에 가깝다고 볼 수도 있다. 하지만 영어로 된 에러메세지를 그대로 주는 것 보다는 한글로 예외 상황을 알려주는 것이 사용 결과는 동일하더라도 사용자 경험은 판이하게 달라진다고 볼 수 있다. 추가적으로 처리할 수 있는 방법들 몇가지를 더 소개하자면
섬세한 대처를 추가적으로 공부하면 좋을 것 같다.


그리고 사실 FileNotFoundException의 상속관계를 살펴보면 IOException이 상위이기 때문에 포함된다고 할 수가 있다. 따라서 FileNotFoundException부분 catch한 코드를 삭제해도 문제가 없지만 세세하게 예외를 조작하는 상황을 가정하여 각각 처리하였다.
예외라는 폭탄(문제)은 내가 처리할 수도 다른 사람에게 넘길 수도 있다.
위에서 살펴본 FileReader의 경우에는 java API가 우리에게 예외를 던지면(throw) try-catch문의 catch가 잡은 셈이다. 그런데 만약 내가 해결할 수 없어서 다음 사용자에게 처리를 던지는(throw) 경우도 있는데 다음 사용자라는 개념을 알아보자.
class B {
void run() {
}
}
class C {
void run() {
B b = new B();
b.run;
}
}
public class A {
public static void main(String[] args) {
C c = new C();
c.run();
}
}
위 코드의 관계를 살펴보면 클래스C가 클래스B의 run()을 사용하고 있기 때문에 사용자의 관계라고 볼 수 있고 마찬가지로 클래스A가 클래스 C의 사용자이다. 그리고 클래스A의 앞에는 일반사용자(엔드유저)가 있을 것 이다.
B ➡️ C ➡️ A ➡️ 일반사용자
만약 어떠한 이유로 클래스B의 run메소드에서 예외가 발생했을때 B가 try-catch로 처리할 수도 있지만 처리하지않고 C에게 넘길 수도 있다.
마찬가지로 C도 A에게 넘길 수 있고, A도 일반 사용자에게 넘길 수 있다. (이 경우는 어플리케이션을 자동으로 종료시키게 된다는 의미겠지만)
이제 예외를 던지는 방법을 살펴보자.
throws 키워드를 이용해서 메소드 시그니처 뒤에 'throws FileNotFoundException'와 같이 추가함으로서 사용자에게 메소드 내부에서 FileNotFoundException이라는 예외에 대응할 것을 강제할 수 있다.
import java.io.*;
class B {
void run() throws FileNotFoundException, IOException {
BufferedReader bReader = null;
String input = null;
bReader = new BufferedReader(new FileReader("memo.txt"));
input = bReader.readLine();
System.out.println(input);
bReader.close();
}
}
class C {
void run() throws FileNotFoundException, IOException {
B b = new B();
b.run();
}
}
public class A {
public static void main(String[] args) {
C c = new C();
try {
c.run();
} catch (FileNotFoundException e) {
System.out.println("memo.txt 파일이 필요합니다.");
} catch (IOException e) {
System.out.println("파일을 읽을 수 없습니다.");
}
}
}
API의 생산자로서 사용자에게 예외를 던지는 방법을 알아보자.
자바 버츄얼 머신이 만든 예외가 아니라 내가 직접 예외를 만드는 것이다.
예를 들어 메소드의 인자가 부적합 할 때의 경우에 조건문을 통해서 exception이 발생하게 할 수 있다.
자바 버츄얼 머신은 여러가지 형태의 익셉션을 제공하고 있으며 (ex) IllegalArpumentException, ArithmeticException, illegalstateexception..) 다양한 익셉션 중에서 어떤 익셉션을 발생시킬지는 고르거나 직접 정의할 수도 있다.

동작방식을 설명하자면 자바 버츄얼 머신이 익셉션 객체를 만나면 디바이드에 대한 실행을 중단하고, 메인 메소드가 가지고 있는 try로 가서 같은 이름의 익셉션의 catch를 찾아서 메소드처럼 실행이 되는데 이때 변수 e 에는 우리가 new로 선언한 인스턴스를 변수에 넣어주게 되므로 e.getMessage()의 결과와 인스턴스 인자로 넣은 메세지가 동일하다
public void setOprands(int left, int right) {
if(right == 0) {
throw new IllegalArgumentException("두번째 인자는 0을 허용하지 않습니다.");
}
this.left = left;
this.right = right;
}
// throw new Exception(인자에는 예외상황에 대한 설명을 넣음)

위 사진으로부터 IllegalArgumentException 상속관계를 살펴보자.
모든 객체의 최상위에 존재하는 Object객체에서 부터 시작되어,
모든 예외클래스의 부모인 Throwable객체가 그 다음으로 존재한다.
Throwable이라는 단어에서 알 수 있듯 Throwable 하위에 있는 객체이기에 예외를 던질 수 있다는 것도 알 수 있다.
Throwable객체에 정의되어 있는 메소드들을 살펴보면 자주(공통적으로) 사용하는 getMessage(), printStackTrace(), toString()을 가지고 있다.
그 아래로 RuntimeException, 그 아래로 ArithmeticException이 있다.
try-catch문을 사용할 때의 catch의 순서도 덜 포괄적인 하위에서 부터 상위의 순서로 작성하는 것이 좋다는 것도 다시 한 번 느낄 수 있다.

Throwable의 하위 객체 중에는 error exception와 RuntimeException가 있다.
error exception은 Throwable를 직접 상속하고 있으며 자바버추얼머신에 어떠한 문제가 발생했을때 자바 가상머신이 더이상 운영할 수 없는 상태에 이르렀을 때 던지는 exception이다. 보통 애플리케이션이 너무 많은 메모리가 쓰여서 더 이상 메모리를 쓸 수 없거나 컴퓨터 하드웨어에서 너무 적은 메모리를 지원하는 경우와 같은 때에 발생할 수 있다. 이 경우에는 기반 시스템의 문제이므로 개발자가 코드로서 해결할 수 있는 방법이 없기 때문에 신경쓸 필요가 없다. (물론 메모리를 헤프게 쓰는 부분이 없었는지 살펴보고 컴퓨터를 업그레이드 하는 등의 조취를 취하는 방법으로 해결하기 위해 궁리하겠지만)
따라서 unchecked Exception이다.
부모에 RuntimeException이 있어도 unchecked Exception이다.
그런데 IOException 의 경우라면 상위에 RuntimeException가 없기 때문에 checked Exception일 것이다. 이 경우에는 예외를 처리하지 않으면 코드에 빨간 밑줄이 생기는 컴파일 에러가 발생한다. 따라서 try-catch나 throws를 사용하여 처리하거나 throws를 통해 사용자에게 책임을 넘기는 작업을 해야 할 것이다.
class E {
void ThrowArithmeticException() {
throw new ArithmeticException();
}
// void ThrowIOException() {
// throw new ArithmeticException(); // 컴파일 에러 발생
// }
void ThrowThrowIOException1() { // 처리방법 1
try {
throw new ThrowIOException();
} catch (IOException e) {
e.printStackTrace();
}
}
void ThrowThrowIOException2() throws IOException { // 처리방법 2
throw new ArithmeticException();
}
}
자바에서 기본적으로 제공하는 '표준 예외' 클래스로도 많은 예외 상황을 표현하고 대처할 수 있으며, 권장된다. 한번도 본 적 없는 예외를 보는 것 보다는 알고있는 예외에 대처하기가 더 쉽기도 하고, 유지 보수 측면에서도 좋기 때문이다. 하지만 특수한 필요에 의해 수용할 수 없는 표준 예외 클래스가 없는 경우에는 exception 클래스를 직접 정의해서 사용할 수도 있다.
가장 먼저 checked 와 unchecked Exception 중에 어떤 exception을 만들어야 할지 판단하여야 한다. API쪽에서 예외를 던졌을 때 사용자 쪽에서 예외 상황을 복구 할 수 있다면 checked 예외를 사용하여 문제를 해결할 기회를 주면서 처리를 강제하겠지만 너무 잦은 예외를 던지면 사용자가 힘들 수도 있으므로 적정선을 찾는 것이 중요하다. checked Exception을 만들려면 상위 클래스에 runtimeException이 있어야 한다. 사용자가 API의 사용방법을 어겨서 발생하는 문제거나 예외 상황이 이미 발생한 시점에서 프로그램을 종료해버리는 것이 덜 위험(실패)한 경우에 unchecked Exception을 사용한다.
예시로서 직접 Exception를 정의해보자.
// unchecked 예외 (RuntimeException이나 ArithmeticException 상속)
class DivideException extends RuntimeException {
DivideException() { // 기본 생성자
super();
}
DivideException(String message) { // 추가로 만든 생성자
super(message);
}
}
// checked 예외 (Exception 상속)
class DivideException extends Exception {
public int left;
public int right;
DivideException() {
super();
}
DivideException(String message) {
super(message);
}
DivideException(String message, int left, int right) {
super(message);
this.left = left;
this.right = right;
}
}