예외 계층
자바는 프로그램 실행 중에 발생할 수 있는 예상치 못한 상황, 즉 예외(Exception)를 처리하기 위한 메커니즘을 제공한다. 이는 프로그램의 안정성과 신뢰성을 높이는 데 중요한 역할을 한다.
자바는 예외 처리를 위한 다음 키워드를 사용한다.
`try` , `catch` , `finally` , `throw` , `throws`
자바에선 객체가 기본 구성이 되며, 예외 처리용 객체도 존재한다.
위 그림은 자바 예외에 대한 객체에 대한 계층이다.
- `Object` : 자바에서 기본형을 제외한 모든 것은 객체로 구성되어 있다. 예외도 객체이므로 모든 객체의 최상위 부모 객체인 `Object`를 최상위 부모로 사용한다.
- `Throwable` : 최상위 예외이다. 하위는 `Exception`과 `Error`가 있다.
- `Error` : 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이다. 그렇기 때문에 애플리케이션 개발자는 코드상에서 해당 예외를 처리하려고 하면 안 된다.
- `Exception` : 체크 예외이다.
- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.(개발자가 코드로 처리하기 위한 예외의 최상위 예외)
- `Exception`과 그 하위 예외는 모두 컴파일러가 체크하는 예외이다.
- 단, `RuntimeException`은 예외로 한다.
- `RuntimeException` : 언체크 예외, 런타임 예외이다.
- 컴파일러가 체크하지 않는 예외로 언체크 예외이다.
- `RuntimeException`과 그 자식 예외는 모두 언체크 예외이다.
- `RuntimeException`의 이름을 따라서 `RuntimeException`과 그 하위 언체크 예외를 런타임 예외라고 많이 부른다.
체크 예외와 언체크 예외
체크 예외는 발생한 예외를 개발자가 명시적으로 처리해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 언체크 예외는 발생한 예외를 명시적으로 처리하지 않아도 된다.
여기서 명시적 처리란 개발자가 코드에서 직접 예외를 처리해야 한다는 의미이다. 즉, 예외가 발생할 가능성이 있는 코드에 대해 `try-catch` 블록을 사용하거나 `throws` 키워드를 이용해 예외를 던지는 것을 의미한다.
자세한 것은 아래 코드를 통해 설명할 예정이다.
주의해야 할 점
자바에선 상속 관계에서 부모 타입이 자식을 담을 수 있다. 이러한 개념은 예외 객체에도 적용이 된다. 그렇기 때문에 상위 예외를 `catch`로 잡으면 그 하위 예외까지 함께 처리가 가능하다. 따라서 애플리케이션 로직에서는 `Throwable`예외를 잡으면 안 된다. 앞서 이야기한 `Error`예외까지 함께 잡을 수 있기 때문이다. 애플리케이션 로직은 이런 이유로 `Exception`부터 필요한 예외로 생각하고 잡으면 된다.
예외의 기본 규칙: "던지다(throw)" vs. "잡아서 처리한다(catch)"
예외 처리는 예외를 발생시키는 것(던지기)과 예외를 처리하는 것(잡기)이라는 두 가지 개념으로 나뉘어. 이를 통해 프로그램이 예외 상황에서도 안정적으로 동작할 수 있도록 한다.
1. 던지다(throw / throws)
- 예외가 발생했을 때, 이를 호출한 쪽으로 넘기는 것
- `throw` 키워드를 사용하여 특정 예외를 직접 발생시킬 수 있음
- `throws` 키워드를 사용하여 예외를 호출한 쪽에서 처리하도록 위임 가능
예제 1: throw를 사용해 예외 발생시키기
public class ExceptionExample {
public static void main(String[] args) {
int number = -1;
checkPositiveNumber(number); // 예외 발생
}
public static void checkPositiveNumber(int num) {
if (num < 0) {
throw new IllegalArgumentException("음수는 허용되지 않습니다."); // 예외를 던짐
}
}
}
🚀 결과
`IllegalArgumentException: 음수는 허용되지 않습니다.` (런타임 예외 발생)
✅ throw를 사용하면 개발자가 원하는 시점에 직접 예외를 발생시킬 수 있다.
예제 2: throws를 사용해 예외 위임하기
public class ThrowsExample {
public static void main(String[] args) {
try {
readFile(); // 예외를 호출한 곳에서 처리해야 함
} catch (Exception e) {
System.out.println("예외 처리: " + e.getMessage());
}
}
public static void readFile() throws Exception { // 호출한 쪽에서 예외를 처리해야 함
throw new Exception("파일을 읽을 수 없습니다.");
}
}
🚀 결과
`예외 처리: 파일을 읽을 수 없습니다.` (예외가 위로 전달됨)
✅ throws를 사용하면, 현재 메서드에서 예외를 처리하지 않고 호출한 쪽에서 처리하도록 위임 가능하다.
2. 잡아서 처리한다. (try-catch)
- 예외가 발생하면 프로그램이 강제 종료되지 않도록 예외를 "잡아서" 처리한다.
- try-catch 블록을 사용하여 예외를 감싸서 처리할 수 있다.
예제 3: try-catch를 사용한 예외 처리
public class TryCatchExample {
public static void main(String[] args) {
try {
int result = divide(10, 0); // 예외 발생
System.out.println("결과: " + result);
} catch (ArithmeticException e) { // 예외를 잡음
System.out.println("예외 발생: " + e.getMessage());
}
}
public static int divide(int a, int b) {
return a / b; // 0으로 나누기 예외 발생
}
}
🚀 결과
`예외 발생: / by zero` (프로그램이 정상 종료됨)
✅ 예외를 catch로 잡아서 프로그램이 비정상 종료되지 않도록 방어한다.
3. 예외 처리 흐름 (던지기 vs 잡기)
예외가 발생하면 JVM은 가장 가까운 `catch`블록을 찾을 때까지 예외를 위로 던진다.
다음 예제로 코드를 보자.
public class ExceptionFlow {
public static void main(String[] args) {
try {
level1();
} catch (Exception e) { // 최상위 메서드에서 예외를 잡음
System.out.println("최종 예외 처리: " + e.getMessage());
}
}
public static void level1() throws Exception {
level2(); // level2()에서 발생한 예외를 던짐
}
public static void level2() throws Exception {
throw new Exception("예외 발생 in level2");
}
}
해당 코드를 보면 `level2()`에서 `throw` 키워드를 통해 `Exception`예외를 생성하여 발생시켰다. 그런 다음 `throws`를 통해 발생시킨 예외를 메서드 밖으로 던지게 된다. (예외를 발생시켜 던진다.!!)
던져진 예외는 해당 메서드를 실행시킨 `level1()`으로 이동하고, `level1()`에서 잡아서 처리하지 않고 `throws`하여 예외를 던진다.
최종적으로 해당 예외는 `main()`까지 전달되어 `try-catch`구문을 이용해 처리가 된다.
단, 여기서 주의할 게 있는 게 계속 던지다가 최종적으로 `main()`에서도 처리를 안 하고 넘어간다면 예외로그가 표시되고 시스템이 종료가 된다.
던지기와 잡기에 대한 개념은 다음 표처럼 정리할 수 있다.
개념 | 키워드 | 역할 |
발생 | throw | 예외를 즉시 발생 |
위임하기(던지기) | throws | 예외를 호출한 메서드로 전달(발생시킨 예외를 메서드 밖으로 던진다 표현) |
잡기 | try-catch | 예외를 처리하여 프로그램을 안정적으로 유지 |
체크 예외 (Checked Exception)
체크 예외는 `Exception`과 그 하위 예외를 의미하며, 모두 컴파일러에 의해 체크가 되는 예외이다. 단 `RuntimeException`은 예외로 한다.
체크 예외는 반드시 잡아서 처리하거나, 또는 밖으로 던지도록 선언(명시적 처리)해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
예제 코드로 이해하기
import java.io.*;
public class CheckedExceptionExample {
public static void main(String[] args) {
FileReader file = new FileReader("test.txt"); // 파일이 없을 경우 IOException 발생
}
}
해당 코드를 보면 `java.io`에서 제공해 주는 FileReader가 초기화하면 다음과 같은 예외가 발생하게 된다. 파일이 없을 경우 `IOException`이 발생할 수 있다는 것이다. `IOException`은 체크 예외이기 때문에 반드시 처리작업이 필요하다.
1. `try-catch`를 이용한 예외처리
import java.io.*;
public class CheckedExceptionExample {
public static void main(String[] args) {
try {
FileReader file = new FileReader("test.txt"); // 파일이 없을 경우 IOException 발생
} catch (IOException e) { // 명시적 예외 처리
System.out.println("파일을 찾을 수 없습니다: " + e.getMessage());
}
}
}
위에서 설명했듯이 `try-catch`를 사용하면 예외를 잡아서 처리가 가능하다.
2. `throws`를 이용한 예외 던지기
또는 `throws`를 이용하여 예외를 호출한 쪽으로 던질 수 있다.
public void readFile() throws IOException {
FileReader file = new FileReader("test.txt");
}
이 경우, 이 메서드를 호출하는 쪽에서 다시 예외를 처리해야 컴파일 오류가 발생하지 않는다.
그 외에도
체크 예외에 경우 객체이므로 상속에 개념과 동일하게 처리가 가능하다. 위 예외에서 발생하는 예외는 코드를 분석해 보면 다음과 같다.
`FileNotFoundException`이며 체크 예외인 `IOException`을 상속받아 만들어진다. 그러므로 위 코드에서 `throws IOException`이 가능한 것이다.
마찬가지로 `IOException`의 상위 타입인 `Exception`을 적어주어도 `FileNotFoundException`에 대한 예외를 던질 수 있다.
정리하자면, 예외도 객체이기 때문에 다형성이 적용된다.
체크 예외의 장단점
체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 `throws 예외` 를 필수로 선언해야 한다. 그렇지
않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.
- 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전장치이다. 이를 통
해 개발자는 어떤 체크 예외가 발생하는지 쉽게 파악할 수 있다. - 단점: 하지만 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번거로운
일이 된다. 크게 신경 쓰고 싶지 않은 예외까지 모두 챙겨야 한다.
언체크 예외 (UnChecked Exception)
언체크 예외란 `RuntimeException` 과 그 하위 예외를 의미한다. 언체크 예외는 말 그대로 컴파일러가 예외를 체크하지 않는다라는 뜻이다.
언체크 예외는 체크 예외와 기본적으로 동일하다. 차이가 있다면 예외를 던지는 `throws`를 선언하지 않고, 생략할 수 있다. 생략한 경우 자동으로 예외를 던진다.
체크 예외 VS 언체크 예외
- 체크 예외 : 예외를 잡아서 처리하지 않으면 항상 `throws` 키워드를 사용해서 던지는 예외를 선언해야 한다.
- 언체크 예외 : 예외를 잡아서 처리하지 않아도 `throws` 키워드를 생략할 수 있다.
예제 코드로 이해하기
package exception.basic.unchecked;
/**
* RuntimeException을 상속받은 예외는 언체크 예외가 된다.
*/
public class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
package exception.basic.unchecked;
public class Client {
public void call() {
throw new MyUncheckedException("ex");
}
}
/**
* UnChecked 예외는
* 예외를 잡거나, 던지지 않아도 된다.
* 예외를 잡지 않으면 자동으로 밖으로 던진다.
*/
public class Service {
Client client = new Client();
/**
* 필요한 경우 예외를 잡아서 처리하면 된다.
*/
public void callCatch() {
try {
client.call();
} catch (MyUncheckedException e) {
//예외 처리 로직
System.out.println("예외 처리, message=" + e.getMessage());
}
System.out.println("정상 로직");
}
/**
* 예외를 잡지 않아도 된다. 자연스럽게 상위로 넘어간다.
* 체크 예외와 다르게 throws 예외 선언을 하지 않아도 된다.
*/
public void callThrow() {
client.call();
}
}
- 언체크 예외를 잡아서 처리하는 코드 - `callCatch()`
- 언체크 예외를 밖으로 던지는 코드 - `callThrow()`
실행코드
package exception.basic.unchecked;
public class UncheckedCatchMain {
public static void main(String[] args) {
Service service = new Service();
service.callCatch();
System.out.println("정상 종료");
}
}
예외 처리, message=ex
정상 로직
정상 종료
package exception.basic.unchecked;
public class UncheckedThrowMain {
public static void main(String[] args) {
Service service = new Service();
service.callThrow();
System.out.println("정상 종료");
}
}
Exception in thread "main" exception.basic.unchecked.MyUncheckedException: ex
at exception.basic.unchecked.Client.call(Client.java:5)
at exception.basic.unchecked.Service.callThrow(Service.java:29)
at
exception.basic.unchecked.UncheckedThrowMain.main(UncheckedThrowMain.java:7)
실행 결과는 `throws`를 생략했음에도 체크 예외와 동일한 결과가 발생하게 된다. 물론 언체크 예외에서도 `throws`를 명시적으로 선언해 줘도 된다. 개발문화(환경)에 따라 다르겠지만, 중요한 예외에 경우 명시적으로 `throws`를 선언해 주고 개발자나 IDE에서 좀 더 편리하게 인지할 수 있다. (언체크 예외를 던진다고 선언한다고 해서 체크 예외처럼 컴파일러를 통한 체크를 할 수 있는 것은 아니다. 다만, 메서드 호출하는 개발자가 IDE를 통해 호출 코드를 보고, 이런 예외가 발생한다고 인지할 수 있다.)
언체크 예외의 장단점
언체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 `throws 예외` 를 생략할 수 있다. 이것 때문에 장점과 단점이 동시에 존재한다.
- 장점 : 신경 쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 `throws 예외`를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다.
- 단점 : 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누락을 잡아준다.
추가적으로, 현대 애플리케이션 개발에서는 체크 예외보단 언체크 예외를 활용하는 경우가 대부분이다. 컴파일러와 IDE로 체크해 주는 건 매우 장점이지만, 언체크 예외를 활용함에 따라 발생하는 실용적인 요소가 있다.
다음 포스팅에선 그 실용적인 요소에 대해 알아보자.
참고
김영한의 실전 자바 - 중급 1편 강의 | 김영한 - 인프런
김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을
www.inflearn.com
'Language > Java' 카테고리의 다른 글
[Java] 스레드와 자바 메모리의 관계 (0) | 2025.02.05 |
---|---|
[Java] 제네릭(Generic) (1) | 2025.01.31 |
[Java] Class 클래스 (0) | 2025.01.30 |
[Java] 클래스 로더 (Class Loader)와 로딩 과정 (1) | 2025.01.29 |
[Java] Wrapper 클래스와 성능(오토 박싱 & 오토 언박싱) (0) | 2025.01.23 |