왜 Throwable을 알아야 하는가
모든 예외 및 에러는 Throwble의 하위 클래스이다.
또한, 모든 표준 예외 및 에러 클래스는 Throwable에 정의된 메소드 및 프로퍼티 이외에 아무것도 추가적으로 정의하지 않는다.
그저 Throwable의 생성자를 호출해 초기화만 명시할 뿐이다.
커스텀 예외는 어떻게 정의하느냐에 따라 추가적인 기능이 존재할수도 있습니다.
다음은 흔히 사용하는 IllegalArgumentException의 실제 내부 구현이다.
앞서 말했다시피, super 키워드를 통해 부모 클래스의 생성자만 호출할 뿐, 추가적인 기능은 없다.
public
class IllegalArgumentException extends RuntimeException {
public IllegalArgumentException() {
super();
}
public IllegalArgumentException(String s) {
super(s);
}
public IllegalArgumentException(Throwable cause) {
super(cause);
}
}
모든 표준 예외 및 에러의 형태가 이렇다.
RuntimeException도, Exception도, Error 클래스도 마찬가지다.
즉, Throwable의 행위를 이해하는 것은 모든 예외와 모든 에러 클래스의 행위를 이해하는 것이 된다.
이것만으로도 Throwable을 이해할 이유는 충분하다!
Note) 우리가 개발하면서 실질적으로 다루는 객체는 Error가 아닌 Exception 뿐이므로, 아래에서 설명하는 모든 내용은 Exception을 기반으로 진행됩니다.
Throwable이란?
Oracle 공식 문서를 참고해 작성했습니다.
API를 알아보기 전에 우선 Throwable 객체 그 자체부터 알아보자.
Throwable이란, 예외적인 상황이 발생했음을 알리기 위해 존재한다.
이 Throwable이라는 객체는 모든 예외와 에러의 슈퍼클래스로,
이 객체의 인스턴스(혹은 하위클래스의 인스턴스)만 JVM에게서, 혹은 개발자가 작성한 throw 키워드로 던져질 수 있다.
마찬가지로 catch 구문에서 잡을 수 있는 객체도 오로지 Throwable 혹은, Throwable의 하위 클래스의 인스턴스뿐이다.
또한 Throwable은 생성 당시, 해당 스레드의 실행 스택 스냅샷을 가진다.
이외에도 Throwable은 메세지를 가질 수 있고, 원인 정보(cause)를 가질 수도 있다.
// 메세지를 가지는 경우
try {
throw new Throwable("저는 메세지입니다");
} catch (Throwable t) {
System.out.println(t.getMessage()); // "저는 메세지입니다" 출력
}
// 원인을 가지는 경우
try {
throw new Throwable(new Throwable("저는 원인 메세지입니다"));
} catch (Throwable t) {
System.out.println(t.getCause().getMessage()); // "저는 원인 메세지입니다" 출력
}
메세지는 디버깅용으로 사용한다고 쳐도, 원인 정보는 왜 존재하는걸까?
크게 두 가지 이유가 있다.
- 원인 정보가 없으면 추상화 계층이 깨질 수 있기 때문이다. 하위 추상화 레벨에서 발생한 예외가 상위 레벨까지 전달되는 경우를 방지하고자 새로운 예외의 cause로 넣어준다.
- 예외를 우리가 설계한 인터페이스에 맞게 던지게 하기 위함이다. IO 예외가 발생할 수 있는 Collection을 포함하는 객체를 구현한다고 가정해보자. 이런 경우 IO 예외가 발생하면 직접적으로 해당 예외를 윗단으로 던지는 것이 아니라, 보다 일반적인 예외의 cause로 넣어줘 처리할 수 있다.
API 알아보기
앞서 스택, cause 등을 이야기했는데, 이 정보만으로는 Throwable을 이해하기 어려운 것 같다.
전체적인 틀은 그렸으니, 이제는 직접 API를 하나씩 확인해보면서 Throwable에 대한 개념을 재정립하자.
생성자
- public Throwable()
public Throwable() {
fillInStackTrace();
}
기본 생성자이다.
아무런 파라미터를 명시하지 않으면 fillInStackTrace 메소드를 호출한다.
fillInStackTrace 메소드는 이후 메소드 소개 부분에서 제대로 다루겠다.
일단은 stack trace, 즉 실행 스택을 내부적으로 저장하는 것이라고만 이해하면 될 것 같다.
- public Throwable(String message)
public Throwable(String message) {
fillInStackTrace();
detailMessage = message;
}
메세지만 따로 detailMessage 라는 인스턴스 변수에 정의할 뿐, 기본 생성자와 행위 자체는 동일하다.
- public Throwable(String message, Throwable cause)
public Throwable(String message, Throwable cause) {
fillInStackTrace();
detailMessage = message;
this.cause = cause;
}
메세지와 cause 정보까지 저장한다.
cause는 Throwable 타입인데, 즉 쉽게 말해서 원인이 되는 예외까지 저장할 수 있다는 것이다.
- public Throwable(Throwable cause)
public Throwable(Throwable cause) {
fillInStackTrace();
detailMessage = (cause==null ? null : cause.toString());
this.cause = cause;
}
메세지가 아닌 원인 예외만 지정하는 것도 가능하다.
예외 메세지를 따로 넣어주지 않으므로, 원인 예외의 toString 값으로 초기화한다.
- protected Throwable(String message, Throwable cause, boolean enableSuppression, boolean writeableStackTrace)
protected Throwable(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
if (writableStackTrace) {
fillInStackTrace();
} else {
stackTrace = null;
}
detailMessage = message;
this.cause = cause;
if (!enableSuppression)
suppressedExceptions = null;
}
메세지, 원인 정보에 이어 enableSuppression, writableStackTrace라는 boolean 값을 받을 수 있다.
여기서 enableSuppression은 추후 나올 개념인 suppression이 가능한지 여부를 결정한다.
writableStackTrace는 stackTrace를 저장할지 말지를 결정하는데, false로 넘겨주는 경우 실행 스택 값이 저장되지 않는다.
중요한 점은, protected 생성자이기 때문에 외부에서 호출할 수는 없다는 것이다.
따라서 stackTrace를 만들고 싶지 않다거나, suppression을 불가능하게 하려면 커스텀 예외를 만들자.
메소드
- addSuppressed(Throwable)
방금 전 suppression의 개념이 나왔으니, 먼저 설명하고 넘어가겠다.
앞서 생성자에서 boolean 값을 넘겨주는 경우, 해당 boolean 값에 따라 suppression을 가능하게 할지, 말지를 결정할 수 있다고 했다.
suppression이란, try - catch - finally 구문에서 주로 발생하는데, 코드와 함께 이해해보자!
public static void demoSuppressedException(String filePath) throws IOException {
FileInputStream fileIn = null;
try {
fileIn = new FileInputStream(filePath);
} catch (FileNotFoundException e) {
throw new IOException(e);
} finally {
fileIn.close(); // 여기서 예외가 한번 더 발생한다면?
}
}
만약 위 코드의 finally 블록에서 예외가 또 한번 발생한다면 어떻게 될까?
이전 예외(FileNotFoundException)는 덮어씌워지게 된다.
이런 상황을 suppression이라고 하고, 이를 해결하기 위해 Throwable에서는 addSuppressed 메소드를 지원한다.
public static void demoAddSuppressedException(String filePath) throws IOException {
Throwable firstException = null;
FileInputStream fileIn = null;
try {
fileIn = new FileInputStream(filePath);
} catch (IOException e) {
firstException = e;
} finally {
try {
fileIn.close();
} catch (NullPointerException npe) {
if (firstException != null) {
npe.addSuppressed(firstException); // 이전 예외를 덮어씌우지 않고, 내부에 저장한다!
}
throw npe;
}
}
}
finally 블록 안에 한번 더 try - catch 문을 넣어주고,
close 시 예외가 발생하더라도 덮어씌워지지 않게 addSuppressed 메소드를 통해 이전 예외 상황을 내부적으로 저장한다.
메소드명에서 유추할 수 있듯이, addSuppressed는 suppressed된 Throwable을 저장하는 역할을 한다.
위처럼 개선하면 이전의 예외를 덮어씌우는 것이 아니기 때문에 두 가지 예외가 순차적으로 발생했음을 전달할 수 있다.
suppressed된 예외를 알고싶다면, 다음과 같은 방식으로 getSuppressed 메소드를 통해 접근 가능하다.
Throwable throwable = new Throwable();
throwable.addSuppressed(new IllegalArgumentException("not suppressed"));
System.out.println(throwable.getSuppressed()[0].getMessage()); // "not suppressed" 출력
- printStackTrace()
말 그대로 stack trace를 standard error stream을 통해 출력한다. (System.err)
우리가 흔히 보던 예외 발생 시의 stack trace가 이 메소드를 통해 호출되는 것이다.
- fillInStackTrace()
앞서 간략하게 '실행 스택을 저장한다'고만 설명했던 친구다.
모든 Throwable의 생성자에서 호출되고 있었다. 도대체 어떤 친구일까?
코드를 통해 어떻게 사용될 수 있는지 알아보면 이해가 바로 될 것이다.
public static void main(String[] args) {
try {
method_1();
} catch (IllegalArgumentException e) {
e.printStackTrace();
}
}
public static void method_1() {
method_2();
}
public static void method_2() {
method_3();
}
public static void method_3() {
method_4();
}
public static void method_4() {
throw new IllegalArgumentException();
}
이 경우 아래와 같은 stack trace가 나타난다.
반면 아래 코드는 어떨까?
public static void main(String[] args) {
try {
method_1();
} catch (IllegalArgumentException e) {
e.printStackTrace();
}
}
public static void method_1() {
method_2();
}
public static void method_2() {
try {
method_3();
} catch (IllegalArgumentException e) {
e.fillInStackTrace();
throw e;
}
}
public static void method_3() {
method_4();
}
public static void method_4() {
throw new IllegalArgumentException();
}
중간에 method_2가 예외를 가로채서 fillInStackTrace를 호출한 다음, 그대로 예외를 던져줬다.
이 경우 stack trace는 아래처럼 된다.
이를 통해 이해할 수 있는 것은 다음과 같다.
예외가 stack trace를 생성하는 기준은 fillInStackTrace 메소드의 호출 시점이다.
생성자에서는 자동으로 한번씩 호출되기에, 예외가 생성되는 시점이 곧 stack trace를 만드는 기점이 되었던 것이다.
- getStackTrace()
stack trace를 가져오는 기능을 한다.
출력을 원하지 않으면서, 따로 파일에 로깅을 하는 등의 작업을 원한다면 사용할 수 있다.
- setStackTrace(StackTraceElement[])
이 메서드를 사용하면 stack trace를 재정의할 수 있다.
역직렬화 과정에서 사용될 수 있다고 한다.
아직 직렬화를 깊게 공부해보지 않았기에, 정확한 쓰임새는 모르겠다.
(혹시 아시는 분 있으면 댓글 부탁드립니다..ㅎ)
- initCause(Throwable)
일종의 setter 개념으로, 발생 원인이 되는 예외를 지정해줄 수 있다.
하지만 생성자에서도 원인 예외를 지정해줄 수 있기 때문에 특수한 경우가 아니면 사용할 일이 없다.
사용하게 된다면 주의해야 할 점은, 예외마다 원인 예외(cause)는 단 한번만 초기화가 가능하기 때문에,
여러 번 초기화 하려고 하는 경우 예외가 발생한다. (IllegalStateException이 발생)
즉, 다음과 같은 경우는 런타임 에러가 발생한다.
Throwable causeThrowable = new Throwable();
Throwable throwable = new Throwable(causeThrowable);
throwable.initCause(new Throwable());
이외에도 getCause(), getMessage(), getLocalizedMessage(), getSuppressed() 등이 getter가 존재하지만,
위 개념을 모두 이해한다면 따로 학습이 필요하지 않다고 판단되어 여기서 기재하지는 않겠습니다.
요약
Java에서의 예외 및 에러는 모두 Throwable을 상속하고 있다.
단순히 상속 관계일 뿐만 아니라, 별 다른 메소드나 인스턴스 변수를 정의하고 있지 않기 때문에
Throwable을 이해하는 것은 곧 모든 예외 및 에러 객체를 이해하는 것이 된다.
먼저 우리는 Throwable의 생성자를 살펴봤다.
이를 통해 Throwable은 크게 두 가지 정보를 가질 수 있음을 알았다.
하나는 메세지이며, 하나는 원인 예외(cause)이다.
메세지의 경우 단순한 String 타입으로, 디버깅 시에 사용할 수 있었고,
원인 예외의 경우 해당 예외가 발생한 원인이 되는 예외를 내부적으로 저장해둘 수 있었다.
또한 어떤 protected 생성자는 boolean 값을 통해 suppression이 가능하게 할지, stack trace 기능을 사용할지를 명시했다.
그러고 나서 우리는 메소드에 대해 살펴봤었다.
메소드는 크게 1. stack trace에 관련된 메소드, 2. 원인 예외(cause)에 관련된 메소드, 3. suppression에 관련된 메소드가 존재했었다.
1. stack trace와 관련된 메소드의 경우,
stack trace를 출력하거나, 저장하거나, 가져오거나, 생성 시점을 정의해줄 수 있었다.
2. 원인 예외(cause)와 관련된 메소드의 경우,
원인 예외를 저장하거나, 가져올 수 있었다.
3. suppression과 관련된 메소드의 경우,
suppressed된 예외를 저장하거나, 가져올 수 있었다.
본질적으로 이 3가지가 존재하는 이유는 예외적인 상황의 문맥을 제공하기 위함이다.
즉, Throwable은 stack trace + cause + suppression 3개의 정보를 통해 예외적인 상황의 문맥을 설명하는 객체이다.
(이것만 기억해도 절반은 성공..!)
마치며
Throwable을 이해했으니, 모든 에러와 예외 객체의 행동을 이해하게 되었다.
하지만 공부하면서 느낀 점은, 예외 및 에러 핸들링의 핵심은 결국 JVM에게 있다는 것이다.
Throwable만으로는 예외 및 에러의 메커니즘을 완벽하게 이해할 수는 없다.
Throwable이 어떻게 JVM과 협력하는지까지 알아내야 할 것 같다는 생각이 든다.
그렇게 된다면, Java에서의 예외처리 메커니즘을 모두 익혔다 할 수 있을 것이다.
따라서 조만간 해당 내용에 대해서도 공부해 포스팅해야겠다!
참고자료
https://docs.oracle.com/javase/7/docs/api/java/lang/Throwable.html
https://www.baeldung.com/java-suppressed-exceptions
https://www.digitalocean.com/community/tutorials/java-exception-interview-questions-and-answers
'Java' 카테고리의 다른 글
I/O Stream이란 무엇일까? (+ Stream이란?) (2) | 2023.09.04 |
---|---|
Java: 커스텀 예외 사용에 대한 생각 (0) | 2023.03.16 |
Java: enum의 구현방식 알아보기 (8) | 2023.03.06 |
Java: enum 소개 및 API 파헤쳐 보기 (3) | 2023.03.05 |
Java: equals & hashCode (0) | 2023.03.04 |