이 글은 “토비의 스프링” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

토비의 스프링 - 예외 처리

1. 사라진 SQLException

JdbcTemplate에 있는 메소드들은 SQLException을 던지지 않는다.

1.1 초난감 예외처리

예외 블랙홀

try {
	// do something
} catch(SQLException e) {
	// 
}
  • 예외는 잡았지만 아무것도 하지 않고 있다. 예외 발생을 무시해버리고 정상적인 상황인것처럼 다음 라인으로 넘어가겠다는 분명한 의도가 있는게 아니라면 연습중에도 절대 만들어서는 안되는 코드이다.
  • 예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 그리고 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 정말 위험한 일이다. 원치 않는 예외가 발생하는 것 보다도 훨씬 더 나쁜일이다.
catch(SQLException e) {
	e.printStackTrace();
}
  • 위와 같은 코드도 잘못된 코드이다. 콘솔 로그를 누군가가 계속해서 모니터링하는게 아니라면 의미가 없다.
  • catch 블록을 이용해 화면에 메시지를 출력한 것은 예외를 처리한게 아니다.
  • 예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 한가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야한다.
  • 굳이 예외를 잡아서 뭔가 조치를 취할 방법이 없다면 잡지 말아야 한다. 메소드에 throws SQLException을 선언해서 메소드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가해버려라.

무의미하고 무책임한 throws

  • 모든 예외를 무조건 던져버리는 선언을 모든 메소드에 기계적으로 넣는 것이다. 예외를 흔적도 없이 먹어치우는 예외 블랙홀보다는 조금 낫긴 하지만 이런 무책임한 throws 선언도 심각한 문제점이 있다.
  • 메소드에 throws Exception이 선언되어 있다고 생각해보자. 그런 메소드 선언에서는 의미 있는 정보를 얻을 수 없다.

1.2 예외의 종류와 특징

Error

  • 첫째는 java.lang. Error 클래스의 서브클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안 된다. OutOfMemoryError나 ThreadDeath 같은 에러는 catch 블록으로 잡아봤자 아무런 대응 방법이 없기 때문이다.

Exception과 체크 예외

  • 체크 예외 : Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들
  • 언체크 예외 : RuntimeException을 상속한 클래스들

RuntimeException

  • java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다.
  • 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외다. 따라서 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것이다.

체크 예외의 불필요성을 주장하는 사람들이 늘어갔다. 체크 예외가 예외처리를 강제하는 것 때문에 예외 블랙홀이나 무책임한 throws 같은 코드가 남발됐다. 최근에 새로 등장하는 자바 표준 스펙의 API들은 예상 가능한 예외상황을 다루는 예외를 체크 예외로 만들지 않는 경향이 있기도 하다.

1.3 예외 처리 방법

예외 복구

  • 예외 상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다.
  • 재시도를 통해서 예외를 복구할 수도 있다.
  • 예외처리 코드를 강제히는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. API를 사용하는 개발자로 하여금 예외상황이 발생할 수 있음을 인식하도록 도와주고 이에 대한 적절한 처리를 시도해보도록 요구하는 것이다.

예외처리 회피

  • 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것
  • JdbcTemplate이 사용하는 콜백 오브젝트 메소드는 SQLException에 대한 예외를 회피하고 템플릿 레벨에서 처리하도록 던져준다.
  • 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다. 콜백/템플릿처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 분명한 확신이 있어야 한다.

예외 전환

  • 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.
  • 예외 전환은 보통 두 가지 목적으로 사용된다.
    • 첫째 : 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다.
      • SQLException -> DuplicateUserldException : 아이디가 같은 사용자가 존재해서 DB Insert에 실패한 경우
      • 보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외(nested exception)로 만드는 것이 좋다. 중첩 예외는 getCause( ) 메소드를 이용해서 처음 발생한 예외가 무엇인지 확인 할 수 있다.
    • 둘째 : 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것. 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니다. 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.
  • 일반적으로 체크 예외를 계속 throws를 사용해 넘기는 건 무의미하다. 메소드 선언은 지저분해지고 아무런 장점이 없다.
  • 어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.
catch (SQLException e) {
	throw DuplicateUserldException(e);
    or
    throw DuplicateUserldException().initCause(e);
}

1.4 예외 처리 전략

런타임 예외의 보편화

  • 애플릿이나 AWT, 스윙을 사용한 독립형 애플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션의 작업이 중단되지 않게 해주고 상황을 복구해야 했다.
  • 하지만 자바 엔터프라이즈 서버환경은 다르다. 수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급된다. 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단시키면 그만이다. 독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법이 없다.
  • 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고있다. 자칫하면 throws Exception으로 점철된 아무런 의미도 없는 메소드들을 낳을 뿐이다. 그래서 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는 게 낫다.
  • 자바 초기부터 있었던 JDK의 API와 달리 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 체크 예외 대신 언체크 예외로 정의하는 것이 일반화되고 있다. 예전에는 복구할 가능성이 조금이라도 있다면 체크 예외로 만든다고 생각했는데, 지금은 항상 복구할 수 있는 예외가 아니라면 일단 언체크예외로 만드는 경향이 있다.
  • 런타임 예외를 일반화해서 사용하는 방법은 여러모로 장점이 많다. 단, 런타임 예외로 만들었기 때문에 사용에 더 주의를 기울일 필요도 있다. 컴파일러가 예외처리를 강제하지 않으므로 신경 쓰지 않으면 예외상황을 충분히 고려하지 않을 수도 있기 때문이다.

애플리케이션 예외

  • 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외도 있다. 이런 예외들을 일반적으로 애플리케이션 예외라고 한다.
  • 예외적인 상황을 어떻게 나타낼 것인가?
    • 예외적인 상황에서 특별한 값 (-1 혹은 0)을 리턴 : 예외상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리하지 않으면 흔란이 생길수 있다. 정상적인 처리가 안됐을때 전딜하는값의 표준 같은것은 없다. 또한 결과 값을 확인하는 조건문이 자주 등장한다.
    • 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 띤 예외를 던지도록 만드는 것이다. 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 잔고 부족처럼 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.

1.5 SQLException은 어떻게 됐나?

  • SQLException은 과연 복구가 가능한 예외인가이다. 99%의 SQLException은 코드 레벨에서는 복구할 방법이 없다.
  • 시스템의 예외라면 당연히 애플리케이션 레벨에서 복구할 방법이 없다. 관리자나 개발자에게 빨리 예외가 발생했다는 사실이 알려지도록 전달하는 방법밖에는 없다.
  • 대부분의 SQLException은 복구가 불가능하다. 더군다나 DAO 밖에서 SQLException을 다룰 수 있는 가능성은 거의 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한 한 빨리 언체크/런타임 예외로 전환해줘야 한다.
  • 스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다.

2. 예외 전환

2.1 JDBC의 한계

  • JDBC는 자바를 이용해 DB에 접근하는 방법을 추상화된 API 형태로 정의해놓고, 각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다. 내부 구현은 DB마다 다르겠지만 JDBC의 Connection. Statement. ResultSet 등의 표준 인터페이스를 통해 그 기능을 제공해주기 때문에 자바 개발자들은 표준화된 JDBC의 API에만 익숙해지면 DB의 종류에 상관없이 일관된 방법으로 프로그램을 개발할 수 있다.
  • 표준화된 JDBC API가 DB 프로그램 개발 방법을 학습하는 부담은 확실히 줄여주지만 DB를 자유롭게 변경해서 사용할 수 있는 유연한 코드를 보장해주지는 못한다. 현실적으로 DB를 자유롭게 바꾸어 사용할 수 있는 DB 프로그램을 작성하는데는 두가지 걸림돌이 있다.

비표준 SQL

  • 대부분의 DB는 표준을 따르지 않는 비표준문법과 기능도 제공한다. 이런 비표준 특정 DB 전용 문법은 매우 폭넓게 사용되고 있다.
  • 이 문제의 해결책을 생각해보면, 호환 가능한 표준 SQL만 사용하는 방법과 DB별로 별도의 DAO를 만들거나 SQL을 외부에 독립시켜서 DB에 따라 변경해 사용하는 방법이 있다.

호환성 없는 SQLException의 DB 에러정보

  • DB마다 에러의 종류와 원인도 제각각이다.
  • JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. JDBC API는 이 SQLException 한 가지만 던지도록 설계되어 있다. 예외가 발생한 원인은 SQLException 안에 담긴 에러 코드와 SQL 상태정보를 참조해봐야 한다.
  • SQLException의 getErrorCode()로 가져올 수 있는 DB 에러 코드는 DB별로 모두 다르다. DB 벤더가 정의한 고유한 에러 코드를 사용하기 때문이다.
  • SQLException은 예외가 발생했을 때의 DB 상태를 담은 SQL 상태정보를 부가적으로 제공한다. getSQLState() 메소드로 예외상황 대한 상태정보를 가져올 수 있다.
  • SQLException이 이러한 상태 코드를 제공하는 이유는 DB에 독립적인 에러정보를 얻기 위해서다. 그런데 문제는 DB의 JDBC 드라이버에서 SQLException이 담을 상태 코드를 정확하게 만들어주지 않는다는 점이다.
  • 결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 기깝다.

2.2 DB 에러 코드 매핑을 통한 전환

  • 해결 방법은 DB별 에러 코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해주는 기능을 만드는 것이다.
  • 스프링은 DataAccessException이라는 SQLException을 대체할 수 있는 런타임 예외를 정의하고 있을 뿐 아니라 DataAccessException의 서브클래스로 세분화된 예외 클래스들을 정의하고 있다.
  • 스프링은 DB별 에러 코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑정보 테이블을 만들어두고 이를 이용한다.
  • JdbcTemplate은 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것이 아니라 DB의 에러 코드를 DataAccessException 계층구조의 클래스 중 하나로 매핑해준다.
  • JdbcTemplate에서 던지는 예외는 모두 DataAccessException의 서브클래스 타입이다. 드라이버나 DB 메타정보를 참고해서 DB 종류를 확인하고 DB별로 미리 준비된 매핑정보를 참고해서 적절한 예외 클래스를 선택하기 때문에 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있는 것이다.
  • JDK 1.6에 포함된 JDBC 4.0부터는 기존에 JDBC의 단일 예외 클래스였던 SQLException을 스프링의 DataAccessException과 비슷한 방식으로 좀 더 세분화해서 정의하고 있다.

2.3 DAO 인터페이스와 DataAccessException 계층구조

  • DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다. 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.
  • 스프링이 왜 이렇게 DataAccessException 계층구조를 이용해 기술에 독립적인 예외를 정의하고 사용하게 하는지 생각해보자.

DAO 인터페이스와 구현의 분리

  • UserDao의 add() 메소드는 어떤 데이터 액세스 기술을 사용하느냐에 따라서 throw 하는 예외가 달라진다. 데이터 액세스 기술에 따라 발생하는 예외가 전부 다르기 때문이다.
public void add(User user) throws SQLException;	// JDBC
public void add(User user) throws PersistentException; // JPA
public void add(User user) throws HibernateException;  // Hibernate
public void add(User user) throws JdoException; // JDO
  • 결국 인터페이스로 메소드의 구현은 추상화했지만 구현 기술마다 던지는 예외가 다르기 때문에 메소드의 선언이 달라진다는 문제가 발생한다. DAO 인터페이스를 기술에 완전히 독립적으로 만들려면 예외가 일치하지 않는 문제도 해결해야 한다.
  • 가장 단순한 해결 방법은 모든 예외를 다 받아주는 throws Exception으로 선언하는것이다. 하지만 무책임한 선언이다.
  • 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다. 따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라서 예외 처리 방법이 달라져야한다.
  • 인터페이스로 추상화하고 일부 기술에서 발생하는 체크 예외를 런타임 예외로 전환하는 것만으론 불충분하다.

데이터 액세스 예외 추상화와 DataAccessException 계층구조

  • 스프링의 JdbcTemplate은 SQLException의 에러 코드를 DB 별로 매핑해서 그에 해당하는 의미 있는 DataAccessException의 서브클래스 중 하나로 전환해서 던져준다.
  • DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있다.
  • DataAccessException 계층구조에는 템플릿 메소드나 DAO 메소드에서 직접 활용할 수 있는 예외도 정의되어 있다.

DataAccessException 활용 시 주의사항

  • DataAccessException이 기술에 상관없이 어느 정도 추상화된 공통 예외로 변환해주긴 하지만 근본적인 한계 때문에 완벽하다고 기대할 수는 없다. 따라서 사용에 주의를 기울여야 한다.
  • 스프링은 SQLException을 DataAccessException으로 전환하는 다양한 방법을 제공한다. 가장 보편적이고 효과적인 방법은 DB 에러 코드를 이용하는 것이다. SQLException을 코드에서 직접 전환하고 싶다면 SQLExceptionTranslator 인터페이스를 구현한 클래스 중에서 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다.
  • SQLErrorCodeSQLExceptionTranslator는 에러 코드 변환에 필요한 DB의 종류를 알아내기 위해 현재 연결된 DataSource를 필요로 한다.

3. 정리

  • 예외를 잡아서 아무런 조취를 취하지 않거나 의미 없는 throws 선언을 남발하는 것은 위험 하다.
  • 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해야 한다.
  • 좀 더 의미 있는 예외로 변경하거나 불필요한 catch/throws를 피하기 위해 런타임 예외로 포장하는 두 가지 방법의 예외 전환이 있다.
  • 복구할 수 없는 예외는 가능한 한 빨리 런타임 예외로 전환하는 것이 바람직하다.
  • 애플리케이션의 로직을 담기 위한 예외는 체크 예외로 만든다.
  • JDBC의 SQLException은 대부분 복구할 수 없는 예외이므로 런타임 예외로 포장해야 한다.
  • SQLException의 에러 코드는 DB에 종속되기 때문에 DB에 독립적인 예외로 전환될 필요가 있다.
  • 스프링은 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다.
  • DAO를 데이터 액세스 기술에서 독립시키려면 인터페이스 도입과 런타임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다.