예외란 프로그램 실행 중에 발생하는, 정상 로직에서 벗어난 의도하지 않은 상황이다.
자바는 예외 상황을 처리하기 위해 예외를 객체로써 다룬다. 따라서 예외 객체는 다른 객체와 마찬가지로 Object
가 최상위 객체이다.
최상위 예외 객체이다. 하위에 Exception
과 Error
가 있다. 예외 객체는 크게 체크 예외와 언체크 예외로 분류할 수 있다. 아래에서 살펴보자.
애플리케이션 로직에서 다룰 수 있는 실질적인 최상위 예외이다. 컴파일 단계에서 체크하기 때문에 체크 예외라고 한다.
단 RuntimeException
은 Exception
의 하위 예외 객체이지만 언체크 예외이다. 이름을 따서 주로 런타임 예외라고 불린다.
메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다. 개발자는 이 예외를 잡으려고 해서는 안된다.
Error
도 언체크 예외에 속한다.
예외는 폭탄 돌리기와 같다. 잡아서 처리하거나 처리할 수 없으면 외부로 던져야 한다.
컨트롤러의 의해 호출된 리포지토리에서 예외가 발생했다고 하자. 리포지토리는 서비스로 예외를 던졌고 서비스에서 이 예외를 처리했다면 컨트롤러는 정상 흐름을 반환받는다.
중간에 예외를 처리하지 못하면 호출한 곳으로 계속 예외가 던져지게 된다.
main()
쓰레드의 경우 예외 로그가 출력되면서 시스템이 종료된다.Exception
을 catch
로 잡으면 그 하위 예외들도 모두 잡을 수 있다.Exception
을 throws
로 던지면 그 하위 예외들도 모두 던질 수 있다체크 예외와 언체크 예외의 차이는 간단하다. 체크 예외는 try-catch
로 잡아서 처리하거나 throws
를 통해 외부로 던지거나 두 방법 중 하나를 택해야 한다. 그렇지 않으면 컴파일 단계에서 오류가 발생한다.
반면 언체크 예외는 개발자가 잡아 처리하거나 외부로 던지지 않아도 된다. 개발자가 예외 처리를 생략한다면 언체크 예외는 자동으로 외부로 던져진다.
작은 차이지만 이 차이로 인해 실제 코드에서는 큰 차이가 생기게 된다.
예외를 다룰 때 기본 원칙으로 다음 2가지를 기억하자 :
위와 같이 서비스의 로직에 의해 Repository
에서 SQLException
이 발생했고 NetworkClient
에서는ConnectException
이 발생했다고 하자.
NetworkClient
는 외부 네트워크에 접속해서 어떤 기능을 처리하는 객체이다.그런데 서비스와 컨트롤러는 이러한 DB 예외나 네트워크 예외를 처리할 방법이 없다. 이러한 예외들은 보통 DB 서버 장애, 네트워크 연결 오류 등의 심각한 문제이기 때문에 서비스나 컨트롤러가 처리하지 못하고 마지막까지 밖으로 던져진다.
웹 애플리케이션은 이러한 예외들을 모아 공통으로 처리한다. 서블릿의 오류페이지나 스프링 MVC의 ControllerAdvice
의 경우이다.
체크 예외는 두 가지 문제를 가지고 있다.
대부분의 예외는 복구가 불가능하다.
SQLException
을 예로 들면 쿼리에 문제가 있거나, DB 자체에 문제가 있거나, 연결에 문제가 있는 등 시스템의 문제인 경우일 것이다. 이러한 문제들은 서비스나 컨트롤러에서 해결할 수도 없고 복구하기 어려운 치명적인 문제이다.
체크 예외는 필수적으로 처리를 해야 하기 때문에 필연적으로 의존성이 생긴다. 예를 들어 서비스나 컨트롤러에서 throws SQLException
와 같은 코드를 가지고 있다는 것은 즉 JDBC(java.sql.SQLException
) 기술에 대한 의존을 갖고 있다는 것이다.
만약 JPA가 JPAException
이라는 예외를 사용한다 하면, JDBC를 JPA로 변경하고 싶다면 서비스 및 컨트롤러의 모든 throws SQLException
코드를 수정해주어야 한다.
JPAException
는 존재하지 않는 예외 (쉽게 예를 들기 위함)결과적으로 OCP, DI를 통해 클라이언트 코드의 변경 없이 대상 구현체를 변경할 수 있다는 스프링의 장점이 체크 예외 때문에 사라지게 된다.
실무에서 발생하는 예외는 대부분 데이터베이스나 네트워크에서 발생하는 시스템 예외이다. 그러나 이러한 예외는 서비스나 컨트롤러에서 복구할 수 없으므로 컴파일 단계에서 체크를 해도 소용이 없다.
체크 예외는 특별한 경우가 아니면 런타임 예외로 변환하여 사용하자.
void method() throws Exception {..}
SQLException
, ConnectException
을 상위 예외인 Exception
으로 간편하게 처리하는 것이 가능하지만 다른 예외까지 모두 한번에 처리되므로 사용하지 말자. 정말 필요한 체크 예외도 놓칠 수 있다.
체크 예외였던 SQLException
, ConnectException
을 상속을 통해 사용자 정의 예외인 RuntimeSQLException
, RuntimeConnectException
로 변환하여 사용해보자.
언체크 예외를 사용하면 의존성이 제거되어 다른 기술로 변경할 때도 편리하다.
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause)
}
}
처음 발생한 예외를 받을 수 있도록 cause
파라미터를 가지는 생성자를 만들었다.
static class Repository {
public void call() {
try {
run();
} catch (SQLException e) {
throw new RuntimeSQLException(e); //기존 예외 e를 바꿔서 던지기
}
}
public void run() throws SQLException {
throw new SQLException("ex");
}
}
run()
에서 SQLException
예외를 발생시켰다.call()
은 run()
을 호출한다. try-catch
를 통해 체크 예외인 SQLException
이 발생하는 경우 이 예외를 받아 RuntimeSQLException(e)
으로 생성하여 런타임 예외로 변환한 뒤 외부로 던진다.@Test
void unChecked() {
Controller controller = new Controller();
Assertions.assertThatThrownBy(() -> controller.request())
.isInstanceOf(RuntimeException.class);
}
최초에는 체크 예외인 SQLException
이 발생했지만 사용자 정의 런타임 예외로 변환되어 최종적으로는 언체크 예외인 RuntimeException
이 발생한 것을 확인할 수 있다.
controller.request()
는 서비스를 통해 리포지토리의 call()
을 호출하는 메서드이다.처음 자바를 설계할 때는 체크 예외가 더 좋다고 생각해서 이렇게 만들어졌다고 한다. 그러나 함께 사용하는 라이브러리가 많아지면서 개발자가 처리해야 하는 체크 예외가 너무 많아졌다. 이에 종종 throws Exception
이라는 극단적인 방식이 사용되기도 한다.
이러한 체크 예외의 문제점 때문에 최근의 라이브러리들은 보통 런타임 예외를 제공한다. JPA와 스프링도 마찬가지이다. 런타임 예외도 필요하면 잡을 수 있기 때문에 필요하면 잡아서 처리하고, 그렇지 않다면 그냥 두면 된다.
스택 트레이스란 애플리케이션이 실행되는 동안의 움직임을 저장하는 스택 기록이다. 예외가 발생했을 때 이것을 통해 예외를 추적할 수 있다.
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
//e.printStackTrace();
log.info("ex", e);
}
}
log.info("ex", e)
:System.out
으로 출력하려면 e.printStackTrace()
를 사용한다. (권장 X)static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
체크 예외를 언체크 예외로 변경하는 예시에서 RuntimeException
를 상속한 RuntimeSQLException
객체를 만들 때 cause
를 파라미터로 갖는 생성자를 만들었었다. 왜 cuase
가 필요한 지 알아보자.
public void call() {
try {
run();
} catch (SQLException e) {
throw new RuntimeSQLException();
}
}
SQLException
을 사용자 정의 런타임 예외인 RuntimeSQLException
로 바꾸는 코드이다.
이 때 만약 기본 생성자를 사용하여 전달 인자로 아무것도 주지 않는다면 스택 트레이스는 RuntimeSQLException
에 대한 로그만 출력한다. 처음에 어디서 예외가 발생했는지 알 수 없게 된다.
catch (SQLException e) {
throw new RuntimeSQLException(e);
}
이번에는 cause
를 파라미터를 갖는 생성자를 사용하여 원래의 예외인 e
를 파라미터로 전달했다.
hello.jdbc.exception.basic.UnCheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex
at hello.jdbc.exception.basic.UnCheckedAppTest$Repository.call(UnCheckedAppTest.java:64)
...
Caused by: java.sql.SQLException: ex
at hello.jdbc.exception.basic.UnCheckedAppTest$Repository.run(UnCheckedAppTest.java:69)
...
이번에는 로그의 Caused by:
에서 예외가 처음에 어디서 발생했는 지 확인할 수 있다. 디버깅을 위해서는 꼭 cause
를 사용하도록 하자.