4장 예외 - 4.1 사라진 SQLException
4.1 사라진 SQLException
deleteAll() 메소드의 정의를 들여다보면 JdbcTemplate 적용 이전에 있었던 throws SQLException 이 사라진 것을 알 수 있다.
// JdbcTemplate 적용 전
public void deleteAll() throws SQLException {
this. jdbcContext.executeSql("delete from users");
}
// JdbcTemplate 적용 후
public void deleteAll() {
this.jdbcTemplate.update("delete from users");
}
4.1.1 초난감 예외처리
예외 블랙홀
예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 그러곤 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 정말 위험한 일이다.
예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보되어야 한다는 것이다.
예외를 잡아서 뭔가 조치를 취할 방법이 없다면 잡지 말아야 한다.
메소드에 throws SQLException 을 선언해서 메소드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가해버려라.
무의미하고 무책임한 throws
메소드 선언에 throws Exception 을 기계적으로 붙이는 개발자도 있다.
자신이 사용하려고 하는 메소드에 throws Exception 이 선언되어 있다면 그 메소드 선언에서는 의미 있는 정보를 얻을 수 없다.
이런 메소드를 사용하는 메소드에서는 정보부족으로 예외를 처리할 수 없기 때문에 메소드 선언에 throws Exception 을 따라 붙이는 수밖에 없다.
4.1.2 예외의 종류와 특징
자바 개발자들 사이에 예외처리에 관한 큰 이슈는 체크 예외 checked exception 라고 불리는 명시적인 처리가 필요한 예외를 다루는 방법에 관한 것이다.
자바에서 throw 를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.
-
Error
첫째는 java.lang.Error 클래스의 서브클래스이다. 에러는 시스템에 비정상적인 상황이 발생했을 때 사용된다.
애플리케이션에서는 이런 에러에 대한 처리는 신경쓰지 않는다.
-
Exception 과 체크 예외
java.lang.Exception 클래스와 그 서브클래스이다. 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.
Exception 클래스는 다시 체크 예외와 언체크 예외 unchecked exception 으로 구분된다.
체크 예외는 Exception 클래스의 서브클래스이지만 RuntimeException 을 상속하지 않은 클래스이다.
언체크 예외는 Exception 클래스의 서브클래스이고 RuntimeException 클래스을 상속한 클래스이다.
일반적으로 예외라고 하면 체크 예외라고 생각해도 된다.
체크 예외가 발생할 수 있는 메소드를 사용할 때는 catch 문으로 잡든 throws 로 던지든 반드시 예외를 처리하는 코드를 함께 작성해야 한다.
그렇지 않으면 컴파일 에러가 발생한다.
-
RuntimeException 과 언체크/런타임 예외
java.lang.RuntimeException 클래스와 그 서브클래스이다.
명시적인 예외처리를 강제하지 않기 때문에 언체크 예외, 런타임 예외라고 불린다.
런타임 예외는 주로 애플리케이션의 오류가 있을 때 발생하도록 의도된 것들이다.
대표적으로 NullPointerException, IllegalArgumentException 등이 있다.
런타임 예외는 어플리케이션이 예상하지 못했던 예외상황에서 발생한 게 아니기 때문에 굳이 catch 나 throws 를 사용하지 않아도 된다.
4.1.3 예외처리 방법
예외 복구
예외를 처리하는 일반적인 방법 중 첫 번째는 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다.
예외로 인해 기본 작업 흐름이 불가능하다면 다른 작업 흐름으로 유도해주는 것이다.
예외가 처리됐으면 비록 기능적으로 사용자에게 예외상황으로 비쳐져도 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행되어야 한다.
예외처리 코드를 강제하는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.
API 를 사용하는 개발자로 하여금 예외상황이 발생할 수 있음을 인식하도록 도와주고 이에 대한 적절한 처리를 시도해보도록 요구하는 것이다.
예외처리 회피
두 번째 방법은 예외처리를 자신을 호출한 쪽으로 던져버리는 것이다.
예컨데 JdbcContext 를 사용하는 콜백 오브젝트는 ResultSet 이나 PreparedStatement 를 사용하여 작업중에 SQLException 이 발생하면 템플릿으로 던져버린다.
SQLException 을 처리하는 일이 콜백 오브젝트의 역할이 아니라고 보기 때문이다.
예외를 회피하는 것은 예외를 복구하는 것만큼 의도가 분명해야 한다.
자신을 사용하는 쪽에서 예외를 다루는게 최선의 방법이라는 분명한 확신이 있어야 한다.
예외 전환
마지막으로 예외를 처리하는 방법은 예외 전환 exception translation 하는 것이다.
발생한 예외를 적절한 예외로 전환해서 던진다는 특징이 있다.
예외 전환은 보통 두 가지 목적으로 사용된다.
첫 번째는 내부에서 발생한 예외를 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서이다.
예컨데 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어서 DB 에러가 발생하면 JDBC API 는 SQLException 을 발생시킨다.
이 경우 DAO 의 메소드에서 SQLException 을 받아서 좀 더 의미있는 DuplicateKeyException 으로 전환해서 던져주는 것이다.
전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외 nested exception 로 만드는 것이 좋다.
중첩 예외는 근본 원인이 되는 예외를 initCause() 메소드로 넣어줄 수 있고, getCause() 메소드를 이용해서 확인할 수도 있다.
두 번째는 예외를 처리하기 쉽고 단순하게 만들기 위해 포장 wrap 하는 것이다.
주로 체크 예외를 언체크 예외로 바꾸는 경우에 사용한다.
어짜피 복구가 불가능한 예외라면 가능한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.
4.1.4 예외처리 전략
런타임 예외의 보편화
일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램상의 오류에 사용한다고 했다.
자바가 처음 만들어질 때 많이 사용되던 애플릿이나 AWT, 스윙 swing 을 사용한 독립형 애플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션의 작업이 중단되지 않게 해주고 상황을 복구해야 했다.
하지만 자바 엔터프라이즈 서버환경은 다르다. 수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급된다. 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단시키면 그만이다.
자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다.
대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는 게 낫다.
예전에는 복구할 가능성이 조금이라도 있다면 체크 예외로 만든다고 생각했는데, 지금은 항상 복구할 수 있는 예외가 아니라면 일단 언체크 예외로 만드는 경향이 있다.
add() 메소드의 예외처리
add() 메소드는 DuplicationUserIdException 과 SQLException, 두 가지의 체크 예외를 던지게 되어 있다.
DuplicatedUserIdException 처럼 의미 있는 예외는 add() 메소드를 바로 호출한 오브젝트 대신 더 앞단의 오브젝트에서 다룰 수도 있다.
하지만 SQLException 은 대부분 복구 불가능한 예외이므로 앞으로 전달되어 봐야 처리할 것이 없다.
이런 경우에는 런타임 예외로 포장해서 밖의 메소드들이 신경 쓰지 않게 해주는 편이 낫다.
add() 메소드를 수정해보자
먼저 사용자 아이디가 중복됐을 때 사용하는 DuplicatedUserIdException 을 만든다.
필요하면 언제든 잡아서 처리할 수 있도록 별도의 예외로 정의하기는 하지만, 필요 없다면 신경 쓰지 않아도 되도록 RuntimeException 을 상속한 런타임 예외로 만든다.
중첩 예외를 만들 수 있도록 생성자를 추가해주는 것을 잊지 말자.
public class DuplicatedUserIdException extends RuntimeException {
public DuplicatedUserIdException(Throwable cause) {
super(cause);
}
}
add() 메소드를 런타임 예외로 전환해서 던지도록 수정하자.
public void add() throws DuplicatedUserIdException {
try {
// 작업
} catch (SQLException e) {
if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) {
throw new DuplicatedUserIdException(e);
} else {
throw new RuntimeException(e);
}
}
}
런타임 예외를 사용하는 경우에는 API 문서나 레퍼런스 문서 등을 통해, 메소드를 사용할 때 발생할 수 있는 예외의 종류와 원인, 활용 방법을 자세히 설명해두자.
애플리케이션 예외
런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있다.
일단 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이고, 꼭 필요한 경우는 런타임 예외라도 잡아서 복구하거나 대응해줄 수 있으니 문제 될 것이 없다는 낙관적인 태도를 기반으로 하고 있다.
애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 애플리케이션 예외도 있다.
애플리케이션 예외의 예시로 사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메소드를 생각해 볼 수 있다.
출금 요청이 있다면 현재 잔고를 확인해서 허용하는 범위를 넘어서 출금을 요청하면 출금 작업을 중단시키고 사용자에게 적절한 경고를 보내는 메소드이다.
정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 띤 예외를 던지도록 만들 수 있다.
이 때 사용하는 예외는 의도적으로 체크 예외로 만들어서 개발자가 잊지 않고 잔고 부족처럼 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.
4.1.5 SQLException은 어떻게 됐나?
99% SQLException 은 코드 레벨에서는 복구할 방법이 없기 때문에 가능한 빨리 언체크/런타임 예외로 전환해주는 것이 좋다.
스프링의 JdbcTemplate 은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLExeption 을 런타임 예외인 DataAccessException 으로 포장해서 던져준다.
스프링의 API 메소드에 정의되어 있는 대부분의 예외는 런타임 예외다. 따라서 발생 가능한 예외가 있다고 이를 처리하도록 강제하지 않는다.