티스토리 뷰

예외 처리

자바 개발자가 가장 신경 쓰기 귀찮아하는 것 중의 하나가 바로 예외처리다. 정상적인 결과와 흐름을 보여주는 코드를 만들기도 버거운데 예외상황까지 처리해야 한다는 사실이 부담스러울 수도 있다. 이 장에서는 예외를 처리하는 Best Practice를 살펴본다.

초난감 예외처리

먼저 개발자들의 코드에서 종종 발견되는 초난감 예외처리의 경우들을 살펴보자.

예외 블랙홀

try {
    // Do something
} catch (SQLException e) {
}

예외를 잡고는 아무것도 하지 않는 경우다. 예외 발생을 무시해버리고 정상적인 상황인 것처럼 다음 라인으로 넘어가겠다는 분명한 의도가 있는 게 아니라면 연습 중에도 절대 만들어서는 안 되는 코드다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문이다.

위와 같은 예외 처리 때문에 프로그램이 오작동을 하거나 시스템 오류가 나서 운영자가 알아차렸을 때는 이미 조치를 취하기엔 너무 늦었다. 더 큰 문제는 그 시스템 오류나 이상한 결과의 원인이 무엇인지 찾아내기가 매우 힘들다는 점이다.

단순 예외 출력

다음과 같이 예외를 단순히 콘솔에 출력하기만 하는 상황도 같은 문제다.

} catch (SQLException e) {
    System.out.println(e);
}
} catch (SQLException e) {
    e.printStackTrace();
}

예외는 처리되어야 한다. 화면에 메시지를 출력한 것은 예외를 처리한 게 아니다.

예외를 처리할 때 개발자가 반드시 지켜야 할 핵심 원칙은 한 가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.

무의미하고 무책임한 throws

catch 블록으로 예외를 잡아봐야 해결할 방법도 없고, 각종 API나 라이브러리가 던지는 긴 이름의 예외들을 매번 throws로 던지기도 귀찮아지면, 다음과 같이 모든 메소드에 throws Exception을 기계적으로 붙이는 개발자도 있다.

public void method1() throws Exception {
    method2();
    // ...
}

public void method2() throws Exception {
    method3();
    // ...
}

public void method3() throws Exception {
    // ...
}

자신이 사용하려고 하는 메소드에 throws Exception이 선언되어 있다고 생각해보자. 그런 메소드 선언에서는 의미 있는 정보를 얻을 수 없다. 정말 무엇인가 실행 중에 예외적인 상황이 발생할 수 있다는 것인지, 아니면 그냥 습관적으로 복사해서 붙여 놓은 것인지 알 수가 없다. 결국 이런 메소드를 사용하는 메소드에서도 역시 throws Exception을 따라서 붙이는 수밖에 없다. 결과적으로 적절한 처리를 통해 복구될 수 있는 예외상황도 제대로 다룰 수 있는 기회를 박탈당한다.

예외의 종류와 특징

자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

Error

첫째는 java.lang.Error 클래스의 서브클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안 된다. 따라서 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.

Exception과 체크 예외

java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.

Exception 클래스는 다시 체크 예외(Checked Exception)언체크 예외(Unchecked Exception) 로 구분된다. 전자는 Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 후자는 RuntimeException을 상속한 클래스들을 말한다.

일반적으로 예외라고 하면 Exception 클래스의 서브클래스 중에서 RuntimeException을 상속하지 않은 것만을 말하는 체크 예외라고 생각해도 된다. 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다.

RuntimeException과 언체크/런타임 예외

java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다. 또는 대표 클래스 이름을 따서 런타임 예외라고도 한다. 에러와 마찬가지로 이 런타임 예외는 catch문으로 잡거나 throws로 선언하지 않아도 된다.

런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다. 이런 예외는 코드에서 미리 조건을 체크하도록 주의 깊게 만든다면 피할 수 있다. 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외다.

예외처리 방법

먼저 일반적인 예외처리 방법을 살펴보고 나서 효과적인 예외처리 전략을 생각해보자.

예외 복구

첫 번째 예외처리 방법은 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예외로 인해 기본 작업 흐름이 불가능하면 다른 작업 흐름으로 자연스럽게 유도해주는 것이다. 예외처리 코드를 강제하는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.

예외처리 회피

두 번째 방법은 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다.

하지만 콜백과 템플릿처럼 긴밀하게 역할을 분담하고 있는 관계가 아니라면 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 책임회피일 수 있다. 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다. 템플릿/콜백처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 분명한 확신이 있어야 한다.

예외 전환

마지막으로 예외를 처리하는 방법은 예외 전환(Exception Translation)을 하는 것이다. 예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.

예외 전환은 보통 두 가지 목적으로 사용된다.

첫 번째는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다. 상황에 적합한 의미를 가진 예외로 변경하는 것이다. 보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외(Nested Exception)로 만드는 것이 좋다.

두 번째 전환방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장(wrap)하는 것이다. 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

예외처리 전략

이어서 일관된 예외처리 전략을 정리해보자.

런타임 예외의 보편화

일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램상의 오류에 사용한다고 했다. 문제는 체크 예외는 복구할 가능성이 조금이라도 있는, 말 그대로 예외적인 상황이기 때문에 자바는 이를 처리하는 catch 블록이나 throws 선언을 강제하고 있다는 점이다. 이렇게 예외처리를 강제하는 것은 예외가 발생할 가능성이 있는 API 메소드를 사용하는 개발자의 실수를 방지하기 위한 배려라고 볼 수도 있겠지만, 실제로는 예외를 다루고 싶지 않을 만큼 짜증나게 만드는 원인이 되기도 한다.

자바가 처음 만들어질 때 사용되던 독립형 어플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션의 작업이 중단되지 않게 해주고 상황을 복구해야 했다. 예를 들어 워드의 파일 열기 기능에서 사용자가 입력한 이름에 해당하는 파일을 찾을 수 없다고 애플리케이션이 종료돼버리게 할 수는 없다.

하지만 자바 엔터프라이즈 서버환경은 다르다. 수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급된다. 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단시키면 그만이다. 독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법이 없다.

차라리 애플리케이션 차원에서 예외상황을 미리 파악하고, 예외가 발생하지 않도록 차단하는 게 좋다. 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 체크 예외 대신 언체크 예외로 정의하는 것이 일반화되고 있다.

애플리케이션 예외

런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있다. 일단 잡고 보도록 강제하는 체크 예외의 비관적인 접근 방법과 대비된다.

반면에 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외도 있다. 이런 예외들을 일반적으로 애플리케이션 예외라고 한다. 은행계좌에서 돈을 출금하는 시점에서 잔고 부족 등의 예외 상황이 발생할 경우, 이를 특정한 리턴 값이나 if문으로 모든 상황을 분기처리해서 나눌 필요 없이, InsufficientBalanceException 등의 예외를 던지도록 만들 수 있다. 이때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 잔고 부족처럼 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.


JdbcTemplate의 예외

DataAccessException

예외를 다른 것으로 바꿔서 던지는 예외 전환의 목적은 두 가지라고 설명했다. 하나는 앞에서 적용해본 것처럼 런타임 예외로 포장해서 굳이 필요하지 않은 catch/throws를 줄여주는 것이고, 다른 하나는 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주는 것이다.

스프링의 JdbcTemplate이 던지는 DataAccessException은 일단 런타임 예외로 SQLException을 포장해주는 역할을 한다. 그래서 대부분 복구가 불가능한 예외인 SQLException에 대해 애플리케이션 레벨에서는 신경 쓰지 않도록 해주는 것이다. 또한 DataAccessException은 SQLException에 담긴 다루기 힘든 상세한 예외정보를 의미 있고 일관성 있는 예외로 전환해서 추상화해주려는 용도로 쓰이기도 한다.

JDBC의 한계

JDBC는 자바를 이용해 DB에 접근하는 방법을 추상화된 API 형태로 정의해놓고, 각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다. 내부 구현은 DB마다 다르겠지만 JDBC의 Connection, Statement, ResultSet 등의 표준 인터페이스를 통해 그 기능을 제공해주기 때문에 자바 개발자들은 표준화된 JDBC의 API에만 익숙해지면 DB의 종류에 상관없이 일관된 방법으로 프로그램을 개발할 수 있다. 인터페이스를 사용하는 객체지향 프로그래밍 방법의 장점을 잘 경험할 수 있는 것이 바로 JDBC이다.

하지만 JDBC를 이용하더라도 현실적으로 DB를 자유롭게 바꾸어 사용할 수 있는 프로그램을 작성하는 데는 두 가지 걸림돌이 있다. 첫 번째는 JDBC 코드에서 사용하는 SQL이 비표준 문장일 경우가 있다는 것이다. 특정 DB의 방언이 포함되는 SQL이 작성되는 경우, 해당 DAO 전체가 특정 DB에 종속적인 코드가 되고 만다. 두 번째는 SQLException이다. JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. 그래서 정확한 예외의 원인을 제공하기 위해 예외 상황의 DB 상태를 담은 SQL 상태정보를 부가적으로 제공하는데, 문제는 DB의 JDBC 드라이버에서 SQLException을 담을 상태코드를 정확하게 만들어주지 않는다는 점이다. 결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 가깝다.

DataAccessException 계층구조

DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다. 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.

대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없는 것이다. 그렇다고 모든 예외를 다 무시해야 하는 건 아니고, 중복 키 에러처럼 비즈니스 로직에서 의미 있게 처리할 수 있는 예외도 있다. 문제는 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다는 점이다. 중복 키 에러가 발생했을 때 JDBC로 만든 DAO에서는 SQLException이, JPA에서는 PersistenceException이, 하이버네이트에서는 HibernateException이 던져진다. 따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라서 예외 처리 방법이 달라져야 한다. 결국 클라이언트가 DAO의 기술에 의존적이 될 수밖에 없다.

그래서 스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다. DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있다. 스프링의 DataAccessException은 JDBC가 아닌 다른 기술들에서만 공통적으로 발생하는 예외도 포함해서, 데이터 액세스 기술에서 발생 가능한 대부분의 예외를 계층구조로 분류해놓았다.

예를 들어, JPA, 하이버네이트처럼 오브젝트/엔티티 단위로 정보를 업데이트하는 경우에는 낙관적인 락킹(optimistic locking)이 발생할 수 있다. 이 낙관적인 락킹은 같은 정보를 두 명 이상의 사용자가 동시에 조회하고 순차적으로 업데이트를 할 때, 뒤늦게 업데이트한 것이 먼저 업데이트한 것을 덮어쓰지 않도록 막아주는 데 쓸 수 있는 편리한 기능이다. 이런 예외들은 사용자에게 적절한 안내를 해주면서 다시 시도할 수 있도록 해주어야 하는데, 문제는 각 기술마다 다른 종류의 낙관적인 락킹 예외를 발생시키는 것이다. 그러나 스프링의 예외 전환 방법을 적용하면 기술에 상관없이 DataAccessException의 서브클래스인 ObjectOptimisticLockingFailureException으로 통일시킬 수 있다.

DataAccessException이 기술에 상관없이 어느 정도 추상화된 공통 예외로 변환하긴 하지만 근본적인 한계 때문에 완벽하다고 기대할 수는 없다. 따라서 사용에 주의를 기울여야 한다.


참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday