토비의 스프링 Vol.1 - 4장 예외
예외
사라진 SQLException
초난감 예외처리
try {
...
} catch(SQLException e) { // 예외를 잡고는 아무것도 하지 않는다.
}
예외가 발생했는데 무시하고 계속 진행해 버린다.
} catch (SQLException e) { System.out.println(e); }
} catch (SQLException e) { e.printStackTrace(); }
화면에 메시지를 출력한 것은 예외를 처리한 게 아니다.
모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.
SQLException이 발생하는 이유는 SQL에 문법 에러가 있거나 DB에서 처리할 수 없을 정도로 데이터 액세스 로직에 심각한 버그가 있거나, 서버가 죽거나 네트워크가 끊기는 등의 심각한 상황이 벌어졌기 때문이다. 그런데 그냥 예외를 무시하거나 콘솔이나 로그에 예외 메시지를 출력하는 건 아무 도움이 되지 않는다.
- 그나마 나은 예외처리
} catch (SQLException e) { e.printStackTrace(); System.exit(1); }
실전에서 이렇게 하라는 건 아니다. 예외를 무시하거나 잡아먹어 버리는 코드는 만들지 말라는 뜻이다. 굳이 예외를 잡아서 뭔가 조치를 취할 방법이 없다면 잡지 말아야 한다. 메소드에 throws SQLException을 선언해서 메소드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가하는게 낫다.
무의미하고 무책임한 throws
catch 블록으로 예외를 잡아봐야 해결할 방법도 없고 JDK API나 라이브러리가 던지는 각종 이름도 긴 예외들을 처리하는 코드를 매번 throws로 선언하기도 귀찮아지기 시작하면, 메소드 선언에 throws Exception을 기계적으로 붙이는 개발자도 있다. 이런 무책임한 throws 선언도 심각한 문제점이 있다. 적절한 처리를 통해 복구될 수 있는 예외상황도 제대로 다룰 수 있는 기회를 박탈당한다.
예외의 종류와 특징
- Error
- java.lang.Error 클래스의 서브 클래스들.
- 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다.
- 그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안 된다.
- OutOfMemoryError나 ThreadDeath같은 에러는 catch 블록으로 잡아봤자 아무런 대응 방법이 없기 때문이다.
따라서 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.
- Exception과 체크 예외
- java.lang.Exception 클래스와 그 서브클래스.
- 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외 상황이 발생했을 경우에 사용된다.
- Exception 클래스는 다시 체크 예외와 언체크 예외로 구분된다.
- 전자는 Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 후자는 RuntimeException을 상속한 클래스들을 말한다.
- RuntimeException은 Exception의 서브클래스이므로 Exception의 일종이긴 하지만 자바는 이 RuntimeException과 그 서브클래스는 특별하게 다룬다.
- 일반적으로 예외라고 하면 Exception 클래스의 서브클래스 중에서 RuntimeException을 상속하지 않은 것만을 말하는 체크 예외라고 생각해도 된다.
- 체크 예외가
발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다.
사용할 메소드가 체크 예외를 던진다면 이를 catch 문으로 잡든지, 아니면 다시 throws를 정의해서 메소드 밖으로 던져야 한다. 그렇지 않으면 컴파일 에러가 발생한다. IOException, SQLException 등
- RuntimeException과 언체크/런타임 예외
- java.lang.RuntimeException 클래스를 상속한 예외들은 명시적으로 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다.
- 에러와 마찬가지로 이 런타임 예외는 catch 문으로 잡거나 throws로 선언하지 않아도 된다.
- 물론 명시적으로 잡거나 throws로 선언해줘도 상관없다.
- 런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다.
- NullPointerException, IllegalArgumentException 등.
- 이런 예외는 코드에서 미리 조건을 체크하도록 주의 깊게 만든다면 피할 수 있다.
- 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외다.
따라서 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것이다.
예외처리 방법
예외 복구
예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다.
예를 들어 사용자가 요청한 파일을 읽으려고 시도했는데 해당 파일이 없다거나 다른 문제가 있어서 읽히지가 않아서 IOException이 발생했을 때는 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외상황을 해결할 수 있다.
예외처리 회피
예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.
throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다.
예외처리를 회피하려면 반드시 다른 오브젝트나 메소드가 예외를 대신 처리할 수 있도록 던져줘야햔다.
예외 전환
예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.
예외 전환은 보통 두 가지 목적으로 사용된다.
- 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄수 있는 예외로 바꿔주기 위해서
- 예를 들어 사용자 등록하려고 시도할 때 중복이 발생한다면 JDBC API는 SQLException을 발생시키지만, 이대로 던지면 왜 SQLException이 발생했는지 모르기 때문에 적절하게 정보를 해석해서 DuplicateUserIdException 같은 예외로 바꿔서 던져주는 게 좋다.
public void add(User user) throws DuplicateUserIdException, SQLException { try { // JDBC를 이용해 user 정보를 DB에 추가하는 코드 또는 // 그런 기능을 가진 다른 SQLException을 던지는 메소드를 호출하는 코드 } catch (SQLException e) { //ErrorCode가 MySQL의 "Duplicate Entry(1062)"이면 예외 전환 if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) throw DuplicateUserIdException(); else throw e; // 그 외의 경우는 SQLException 그대로 } }
보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋다. 중첩 예외는 getCause() 메소드를 이용해서 처음 발생한 예외가 무엇인지 확인할 수 있다.
- 중첩 예외 1
catch(SQLException e) { ... throw DuplicateUserIdException(e); }
- 중첩 예외 2
catch(SQLException e) { ... throw DuplicateUserIdException().initCause(e); }
- 예를 들어 사용자 등록하려고 시도할 때 중복이 발생한다면 JDBC API는 SQLException을 발생시키지만, 이대로 던지면 왜 SQLException이 발생했는지 모르기 때문에 적절하게 정보를 해석해서 DuplicateUserIdException 같은 예외로 바꿔서 던져주는 게 좋다.
- 두 번째 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다. 중첩 예외를 이용해 새로운 예외를 만들고 원인이 되는 예외를 내부에 담아서 던지는 방식은 같다. 하지만 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니다.
주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.
- 예외 포장
try { OrderHome orderHome = EJBHomeFactory.getInstance().getOrderHome(); Order order = orderHome.findByPrimaryKey(Integer id); } catch (NamingException ne) { throw new EJBException(ne); } catch (SQLException se) { throw new EJBException(se); } catch (RemoteException re) { throw new EJBException(re); }
EJBException은 RuntimeException 클래스를 상속한 런타임 예외다. 이렇게 런타임 예외로 만들어서 전달하면 EJB는 이를 시스템 익셉션으로 인식하고 트랜잭션을 자동으로 롤백해준다. 런타임 예외이기 때문에 EJB 컴포넌트를 사용하는 다른 EJB나 클라이언트에서 일일이 예외를 잡거나 다시 던지는 수고를 할 필요가 없다. 이런 예외는 잡아도 복구할 만한 방법이 없기 때문이다.
- 예외 포장
어차피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던져버리고, 예외처리 서비스 등을 이용해 자세한 로그를 남기고, 관리자에게는 메일 등으로 통보해주고, 사용자에게는 친절한 안내 메시지를 보여주는 식으로 처리하는 게 바람직하다.
예외처리 전략
add() 메소드의 예외처리
중복 문제의 예외라면 DuplicateUserIDException으로 전환해주고, 아니면 SQLException을 그대로 던진다.
public class DuplicateUserIdException extends RuntimeException {
public DuplicateUserIdException(Throwable cause) {
super(cause);
}
}
- 예외처리 전략을 적용한 add()
public void add(User user) throws DuplicateUserIdException { try { // JDBC를 이용해 user 정보를 DB에 추가하는 코드 또는 // 그런 기능을 가진 다른 SQLException을 던지는 메소드를 호출하는 코드 } catch (SQLException e) { //ErrorCode가 MySQL의 "Duplicate Entry(1062)"이면 예외 전환 if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) throw DuplicateUserIdException(e); // 예외 전환 else throw new RuntimeException(e); // 예외 포장 } }
SQLException을 처리하기 위해 불필요한 throws 선언을 할 필요는 없으면서, 필요한 경우 아이디 중복 상황을 처리하기 위해 DuplicatedUserIdException을 이용할 수 있다.
애플리케이션 예외
시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외
예를 들어 출금을 하기 전 현재 잔고를 확인하고, 허용하는 범위를 넘어서 출금을 요청하면 출금 작업을 중단시키고, 적절한 경고를 사용자에게 보내야 한다.
방법
- 정상적인 출금처리를 했을 경우와 잔고 부족이 발생했을 경우에 각각 다른 종류의 리텀값을 돌려준다.(0 또는 -1)
사전에 상수로 정의해둔 표준 코드를 사용하지 않는다면 자칫 개발자 사이의 의사소통 문제로 인해 제대로 동작하지 않을 위험이 있다. 또 if 블록이 범벅된 코드가 이어질 것이다.
- 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 띤 예외를 던진다. 예외상황을 처리하는 catch 블록을 메소드 호출 직후에 둘 필요는 없다. 정상적인 흐름을 따르지만 예외가 발생할 수 있는 코드를 try 블록 안에 깔끔하게 정리해두고 예외상황에 대한 처리는 catch 블록에 모아 둘 수 있기 때문에 코드를 이해하기도 편하다. 이 때 사용하는 예외는 의도적으로 체크 예외로 만든다.
try { BigDecimal balace = account.withdraw(amount); ... // 정상적인 처리 결과를 출력하도록 진행 } catch(InsufficientBalaceException e) { // 체크 예외 // InsufficientBalanceException에 담긴 인출 가능한 잔고금액 정보를 가져옴 BigDecimal availFunds = e.getAvailFunds(); ... //잔고 부족 안내 메시지를 준비하고 이를 출력하도록 진행 }
SQLException은 어떻게 됐나?
99%의 SQLException은 코드 레벨에서는 복구할 방법이 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한한 빨리 언체크/런타임 예외로 전환해줘야 한다.
스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 따라서 JdbcTemplate을 사용하는 UserDao 메소드에선 꼭 필요한 경우에만 런타임 예외인 DataAccessException을 잡아서 처리하면 되고 그 외의 경우에는 무시해도 된다.
JdbcTemplate의 update(), queryForInt(), query() 메소드 선언을 보면 모두 throws DataAccessException이라고 되어 있다. throws로 선언되어 있긴 하지만 DataAccessException이 런타임 예외이므로 update()를 사용하는 메소드에서 이를 잡거나 던질 의무는 없다.
예외 전환
예외를 다른 것으로 바꿔서 던지는 예외 전환의 목적은 두 가지다.
- 런타임 예외로 포장해서 굳이 필요하지 않은 catch/throws를 줄여주는 것
- 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주는 것
JDBC의 한계
표준화된 JDBC API가 DB 프로그램 개발 방법을 학습하는 부단은 확실히 줄여주지만 DB를 자유롭게 변경해서 사용할 수 있는 유연한 코드를 보장해주지는 못한다. 현실적으로 DB를 자유롭게 바꾸어 사용할 수 있는 DB 프로그램을 작성하는 데는 두 가지 걸림돌이 있다.
- 비표준 SQL
- SQL은 어느 정도 표준화된 언어디고 몇 가지 표준 규약이 있긴 하지만, 대부분의 DB는 표준을 따르지 않는 비표준 문법과 기능도 제공한다. 이런 비표준 특정 DB 전용 문법은 매우 폭넓게 사용되고 있다. 해당 DB의 특별한 기능을 사용하거나 최적화된 SQL을 만들 때 유용하기 때문이다.
해결: DAO를 DB별로 만들어 사용하거나 SQL을 외부에서 독립시켜서 바꿔 쓸 수 있게 한다.
- SQL은 어느 정도 표준화된 언어디고 몇 가지 표준 규약이 있긴 하지만, 대부분의 DB는 표준을 따르지 않는 비표준 문법과 기능도 제공한다. 이런 비표준 특정 DB 전용 문법은 매우 폭넓게 사용되고 있다. 해당 DB의 특별한 기능을 사용하거나 최적화된 SQL을 만들 때 유용하기 때문이다.
- 호환성 없는 SQLException의 DB 에러정보
- DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각이다. 그래서 JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. 예외가 발생한 원인은 SQLException 안에 담긴 에러 코드와 SQL 상태정보를 참조해야 하는데, 이 또한 DB별로 모두 다르다. 표준 상태코드가 있긴 하지만, DB의 JDBC 드라이버에서 상태 코드를 정확하게 만들어주지 않는다.
결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 가깝다.
- DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각이다. 그래서 JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. 예외가 발생한 원인은 SQLException 안에 담긴 에러 코드와 SQL 상태정보를 참조해야 하는데, 이 또한 DB별로 모두 다르다. 표준 상태코드가 있긴 하지만, DB의 JDBC 드라이버에서 상태 코드를 정확하게 만들어주지 않는다.
DB 에러 코드 매핑을 통한 전환
표준 상태 코드는 믿을 게 못된다. 차라리 DB 업체별로 만들어 유지해오고 있는 DB 전용 에러 코드가 더 정확하다.
해결 방법은 DB별 에러 코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해 주는 기능을 만드는 것이다. 스프링은 DB별 에러 코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑 정보 테이블을 만들어두고 이를 이용한다.
- 오라클 에러 코드 매핑 파일 ```
### 정리 * 예외를 잡아서 아무런 조취를 취하지 않거나 의미 없는 throws 선언을 남발하는 것은 위험하다. * 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해야 한다. * 좀 더 의미 있는 예외로 변경하거나, 불필요한 catch/throws를 피하기 위해 런타임 예외로 포장하는 두 가지 방법의 예외 전환이 있다. * 복구할 수 없는 예외는 가능한 한 빨리 런타임 예외로 전환하는 것이 바람직하다. * 애플리케이션의 로직을 담기 위한 예외는 체크 예외로 만든다. * JDBC의 SQLException은 대부분 복구할 수 없는 예외이므로 런타임 예외로 포장해야 한다. * SQLException의 에러 코드는 DB에 종속되기 때문에 DB에 독립적인 예외로 전환될 필요가 있다. * 스프링은 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다. * DAO를 데이터 액세스 기술에서 독립시키려면 인터페이스 도입과 런타임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다. 출처 : https://github.com/Masssidev/toby-vol1