예외 계층은 다음과 같이 구성되어 있다.
예외 또한 객체고, 객체의 최상위에는 Object가 있으므로 예외의 최상위 부모는 결국 Object다.
그 밑에 최상위 예외인 Throwable이 있으며, Exception과 Error가 Throwable을 상속받는다.
Error는 개발자가 잡을수도 없고, 잡아서도 안되는 복구 불가능한 시스템 에러를 말한다.
내가 아는 Error를 상속 받는 예외로는 아래 두 가지가 있다.
OutOfMemoryError
: JVM이 할당한 메모리가 부족한 경우 발생하는 에러StackOverflowError
: 재귀가 지속되는 등 호출이 깊어져 stack overflow가 발생하는 경우 발생하는 에러상위 예외를 잡으면 그 하위 예외까지 잡아버리기 때문에 Throwable을 잡아서 처리하면 Error까지 처리하게 된다. Throwable은 잡지말자.
Exception은 애플리케이션 로직에서 사용 가능한 실질적인 최상위 예외다.
Exception을 상속 받는 예외로는 그림에서 보이듯 SQLException
과 IOException
같은 체크 예외와 RuntimeException
(언체크 예외)이 있다.
예외는 폭탄 돌리기와 똑같다.
내가 가진 폭탄이 나에게서 터지면 안되기 때문에 옆 사람에게 폭탄을 돌리는 것처럼 예외가 발생하면 똑같이 예외를 던진다.
만약 예외를 받은 쪽에서 해당 예외를 처리할 방법을 알고 있다면, 예외를 잡아서 처리하고 이후에는 정상적인 흐름을 반환하게 된다. 그리고 예외를 잡거나 던질 때 모든 하위 예외들도 함께 잡히거나 던져진다.
예외는 크게 체크 예외와 언체크 예외 두 가지로 분류된다.
체크 예외는 말 그대로 컴파일러가 체크하는 예외를 말하며, RuntimeException
을 제외한 Exception을 상속받는 모든 예외는 모두 체크 예외다.
체크 예외는 잡아서 처리할 수 없어 밖으로 던져야 하는 경우 메서드 선언부에 throws 예외
를 필수로 선언해주어야 한다. 이를 선언하지 않으면 컴파일 오류가 발생하는데, 이로 인해 아래와 같은 장단점이 존재한다.
장점
컴파일러가 잡아주기 때문에 개발자가 실수로 예외를 누락할 일이 없다.
단점
반드시 모든 체크 예외를 잡거나 던져서 처리해야 하기 때문에 상당히 번거롭다. 추가로 의존관계에 대한 문제도 존재하는데, 이는 뒤에서 자세히 알아보겠다.
언체크 예외는 체크 예외와 반대로 컴파일러가 체크하지 않는 예외를 말한다.
기본적으로 체크 예외와 동일하지만, 밖으로 던지는 경우 언체크 예외는 메서드 선언부에 throws 예외
를 선언하지 않아도 된다. 이로 인해 아래와 같은 장단점이 존재한다.
장점
개발자는 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다.
단점
개발자가 실수로 예외를 누락할 가능성이 존재한다.
결국 체크 예외와 언체크 예외의 차이는 처리가 불가하여 던져야 하는 경우 throws 예외
를 선언해야 하는가, 아니면 생략해도 되는가에 대한 차이다.
그렇다면 언제 무슨 예외를 사용해야 할까?
체크 예외와 언체크 예외 중 무엇을 어느 상황에서 사용하는 것이 좋을지는 아래 두 가지만 기억하면 된다.
체크 예외가 언체크 예외보다 더 안전하고 좋아보이는데 체크 예외를 기본으로 사용하는 것이 문제가 되는 이유는 의존관계에 있다.
왜 문제가 되는지는 다음 예시를 보며 알아보자.
Repository에서는 DB에 접근해서 데이터를 저장 및 관리하며, SQLException
체크 예외를 던진다.
NetworkClient에서는 외부 네트워크에 접속해서 어떤 기능을 처리하며, ConnectException
체크 예외를 던진다.
서비스에서는 Repository와 NetworkClient를 호출하기 때문에 두 곳에서 올라오는 체크 예외인 SQLException
과 ConnectException
을 처리해야 한다.
하지만 서비스는 이 둘을 처리할 방법을 모른다. 즉, 두 개의 체크 예외를 처리할 수 없어 다음과 같이 모두 밖으로 던진다. ⇒ method() throws SQLException, ConnectException
컨트롤러도 두 예외를 처리할 방법을 모르기 때문에 똑같이 밖으로 던진다.
결국 서비스와 컨트롤러는 아래에서 올라온 복구 불가능한 예외를 알고 있어야 하며, 이는 불필요한 의존관계를 의미한다.
아직 뭐가 문제인지 잘 모르겠다면 SQLException
이 JDBC 예외라는 사실을 상기시키고, JDBC에서 JPA로 기술을 변경한다고 상상해보자.
JPA는 SQLException 대신 다른 예외를 가지고 있기 때문에 모든 SQLException 관련 코드를 수정해야 한다.
즉, 기술을 변경하기 위해 컨트롤러, 서비스 등 모든 계층에서 코드를 수정해야 한다는 큰 문제가 발생한다.
method() throws SQLException, ConnectException
과 같이 체크 예외를 하나하나 던지지 말고, 그들의 최상위 부모인 Exception을 던져도 되지 않나? 오히려 하위 타입인 두 예외도 함께 던질 수 있으니까 좋을 것 같은데… 까지가 큰 착각이다.
Exception을 던지는 것은 모든 체크 예외를 던진다는 의미가 되는데, 이는 어떤 예외를 잡고 던지는지 파악이 불가능하게 된다는 문제를 가져온다.
따라서 체크 예외를 사용하는 경우 잡을건 잡고, 던질건 명확하게 선언하면서 던져야 한다.
체크 예외를 밖으로 던지는 것은 문제가 많기 때문에 다음과 같이 체크 예외를 언체크 예외로 전환하여 던진다.
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e); // 기존 예외 포함!!!
}
}
여기서 언체크 예외로 전환할 때 기존 예외를 포함해야 하는데, 이는 예외 출력시 스택 트레이스에서 기존 예외를 함께 확인할 수 있기 때문이다.
만약 실제 DB에 연동했을 때 기존 예외를 포함하지 않는다면, 기존에 DB에서 발생한 예외를 확인할 수 없는 심각한 문제가 발생할 것이다.
예외를 전환하여 던졌다면, 예외를 공통으로 처리하는 곳에서 올라온 예외들을 한꺼번에 처리하게 된다. 구현 기술이 변경되는 경우 모든 계층에서 코드를 수정하지 않고, 예외 공통 처리 계층에서만 코드를 수정하면 된다.
글 재미있게 봤습니다.