[토비의 스프링 3.1] 4장. 예외

    Source : yes24

    1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
    2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
    3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
    4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

    난감한 예외처리

    모든 예외는 적절하게 처리되던지 프로그램을 중단하여야 한다.

    Code : So what?

    try {
        //...
    }
    catch(Exception e){
        // do nothing
    }

    위 코드는 Java 초심자들이 흔히 하는 실수다. 예외가 catch되지만 아무런 동작을 해주지 않는데, 이 경우에는 문제가 있어도 그냥 넘어가기 때문에 복잡한 프로그램에서 이 무책임한 예외처리가 불러오는 문제는 디버깅하기가 힘들다.

    Code : So what? (2)

    try {
        //...
    }
    catch(Exception e){
        e.printStackTrace();
    }

    에러 메세제를 출력해주지만 바로 위의 코드와 별반 다를바가 없다. 작은 예제에선 개발자가 로그를 일일이 확인하며 오류를 알아챌 수 있지만, 매우 복잡하고 거대한 코드에서 쏟아지는 엄청난 양의 로그에 묻혀 버리게 된다.

    예외를 어떻게 다룰 수 있는 방법이 없다면 무책임하게 예외를 무시하고 잡아먹는 위 코드 대신, 메소드 밖으로 예외를 던지는게 현명하다.

    public void method() throws Exception{} 
    // 예외가 발생하면 해당 메서드를 호출한 메서드로 예외를 던진다.

    그렇다고 무책임하게 throws Exception을 기계적으로 붙이는 것도 큰 문제가 된다.

    1. 메서드 시그니쳐만 보고 어떤 예외가 발생하는지 알 수가 없다.
    2. 적절하게 처리될 수 있는 예외도 밖으로 던지게 되면 좋은 코드라고 할 수 없다.

    예외의 종류와 특징

    Error

    • java.lang.error를 상속한 하위 클래스들이다.
    • 코드 대신 시스템에 문제가 있을 때 발생하는 예외이다.
    • 복구 불가능한 시스템 레벨의 문제이므로, catch로 잡아서 할 수 있는 것이 없다.
    • OutOfMemorryError, ThreadDeath 등이 있다.
    • 시스템 레벨의 작업을 하는게 아니라면 신경쓰지 않아도 되는 예외. 무슨 말인지 모르겠으면 일단은 신경쓰지 않도록 한다.

    Exception 체크 예외

    • Exception을 상속하면서 RuntimeException을 상속하지 않은 예외를 말한다.
    • 이 예외들은 코드에서 필수적으로 try-catch-... 블록으로 다루어져야 한다. 컴파일러가 이를 체크한다.
    • 쉽게 말하면 코드를 아무리 잘 작성하더라도 발생할 수 있는 예외들이다.
    • 로우레벨에서 네트워크, 메모리 등에 원인이 있는 IOException, SQLException 등이 있다.
    • 예외처리가 귀찮아 프로그래머로 하여금 이를 허술하게 우회하도록 한다는 비판도 있지만, 개발자에게 반드시 발생할 수 있는 상황에 대해 인식하도록 돕고 그 해결책을 요구하는 강제적 장치이기도 하다.

    RuntimeException과 언체크/런타임 예외

    • Exception을 상속하면서 RuntimeException을 상속한 예외를 말한다.
    • 예외처리가 강제되지 않는다.
    • 프로그램을 허술하게 작성하였을 때 발생하는 예외다. 제약조건 등을 잘 체크하도록 작성했다면 발생하지 않는 에러들이다.
    • NullPointerException, IllegalArgumentException 등이 있다.

    예외는 어떻게 처리할까?

    예외 복구

    • 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는다.
    • 만약 어떤 파일을 읽으려고 시도했으나 파일이 없거나 하는 문제가 있어서 IOException이 발생한 경우, 사용자에게 다른 파일을 이용하도록 안내하는 것이 그 예시
    • DB 서버에 접근하는 데 문제가 있는 경우, 지정된 횟수만큼 접속을 재시도하는 것도 복구하는 방법 중 하나지만, 그럼에도 불구하고 결과적으로 연결에 실패할 경우에는 예외를 복구하는 것을 포기하는 것을 고려할 수도 있다. DB가 꼭 필요한 어플리케이션에 DB 연결이 실패한다면 무엇을 할 수 있겠는가?
    • 사용자에게는 원하는 기능이 의도치 않게 동작하는 예외적 상황이지만, 어플리케이션의 입장에선 정상적인 흐름을 해치지 않도록 한다.

    예외처리 회피

    • 예외를 직접 처리하지 않고 자신을 호출한 메서드에 예외를 던져버리는 방법
    • 메서드 시그니쳐에 throws {예외 클래스 이름}을 붙임으로써 가능
    • 어떤 오브젝트가 어떤 예외를 다루어야 하는지 명확한 설계를 기반으로 확실한 의도로 수행하여야 한다. 무책임 무지성으로 throws Exception 하지 말라는 것. 결국은 상위 클래스/메서드에서 처리될 거란/할 거란 확신을 가지고 던져야 한다.

    예외 전환

    예외 회피와 유사하지만, 다른 예외로 적절하게 전환해서 던진다.

    1. 내부에서 발생한 예외가 의미를 잘 표현하지 못 하는 경우.
      • 아이디가 중복된 경우 SQLException이 발생하지만, 이게 접속이 불가능한 상황인지 다른 상황인지 알 방법이 없음
      • DuplicateUserIdException을 외부로 던지면, 처리하는 객체 입장에선 '아 아이디가 잘못된 상황이구나!' 명확하게 이해할 수 있다.
    2. 예외 처리를 컴파일단에서 강제하는 체크 예외를 언체크 예외로 바꾸는 경우에 사용한다.

    예외처리의 전략

    예외를 처리하고 전환하고 회피하는 것들은 기술적으로 어려운 일이 아니지만, 깔끔한 코드를 위해 일관적인 전략을 정리한다.

    런타임 예외의 보편화

    체크 예외는 사용자에게 발생할 수 있는 문제를 언어적 장치를 이용하여 알린다는 장점이 있지만,

    1. API에 체크 예외가 덕지덕지 붙어있으면 사용자는 예외처리 하느라 짜증나 죽을 것이다.
    2. 최근 자바 엔터프라이즈 서버 환경에선 굳이 작업을 중단하지 않도록 처리할 이유가 없다. 각각의 요청이 독립적인 작업이기 때문에, 해당 트랜잭션에 문제가 있다면 해당 작업만 그만두면 될 일이다.
    3. 로우레벨에서 발생한 예외가 웹 컨트롤러까지 전달한다 해도, 사용자와 직접 커뮤니케이션하면서 예외 상황을 복구할 수 있는 상황은 자바 엔터프라이즈 어플리케이션에서 쉽게 있을 수 없는 일이다. 또한 컨트롤러에까지 throws SQLException이 붙어있는 코드를 읽는 개발자 입장에선 이게 뭐지... 싶을 것이다.
    4. 대부분의 체크 예외는 대부분 복구가 불가능한 상황이기에 차라리 런타임 예외로 전환해서 던진다.

    그래서 예외 전환을 통해 런타임예외로 바꾸어 던진다. 자바 초기에는 조금이라도 복구할 가능성이 있다면 체크 예외를 던졌지만, 어차피 런타임으로 바꿔 던질 걸 API 개발자들이 알기에 최근에는 항상 복구할 수 있는 예외가 아니라면 언체크로 던지는 경향이 있다.

    결국 복구 불가능한 상황에 불필요한 try catch 블록 사용을 줄이고, 로우레벨의 예외를 의미가 있는 예외로 바꿔 던지는 것이다.

    애플리케이션 예외

    어플리케이션 로직에서 의도적으로 발생시키고 예외 처리를 하도록 요구하는 설계가 있다. 예를 들어 은행 서버를 개발한다고 했을 때, 현재 잔고를 넘어서는 출금 요청은 어떻게 대응해야 하는가?

    return 값으로 해당 작업의 결과에 대한 결과를 알려줄 수 있다. 정상 출금이면 0, 아니면 -1을 반환하는 식으로 구현할 수도 있겠지만 이 return 코드에 대한 설계가 이루어 져야 하는 점, 분기문으로 반환 코드에 대한 처리가 과하게 많아진다는 점이 문제가 있을 수 있다.

    대신 정상적으로 출금이 되는 상황 등 흐름은 그대로 두고, 잔고 부족인 경우 InsufficientBalacneException을 던지도록 하는 것이다. 이런 상황은 비즈니스 로직에서 절대로 발생하면 안되기에 체크 예외로 예외처리를 강제하도록 한다.

    스프링의 JdbcTemplate

    스프링의 JdbcTemplate가 던지는 DataAccessException은 SQLException을 포장하는 런타임 예외다. 복구가 불가능한 체크 예외를 신경쓰지 않도록 하고, 상세한 예외정보를 일관성 있는 예외로 전환해서 추상화하는 책임도 겸한다.

    JDBC의 한계

    JDBC는 모든 DB를 동일한 API 인터페이스를 통해 접근하도록 하여 DB의 종류에 상관없이 표준화된 JDBC API로개발을 할 수 있도록 한다. 그러나 모든 DB에 적용 가능한 코드를 작성하는 것은 다른 문제다.

    비표준 SQL

    SQL은 표준이 있는 언어지만, DB에 따라 특별한 기능을 제공하는 비표준 SQL 문법이 존재한다. 이 비표준SQL을 이용하는 코드는 결국 DB에 종속된다. 당연히 DB를 교체하면 문제가 생기기 마련이다. 표준 SQL만 사용하는 방법과, DB별로 DAO를 만들어 사용하는 방법이 있다.

    SQLException 에러 정보의 비표준의 난립

    똑같은 상황이더라도 DB마다 문제의 원인은 다를 수 있다. 그러나 JDBC는 이 모든 예외를 SQLException으로 퉁쳐서 던지는 것이 문제다. getErrorCode()로 DB 에러 코드를 가져올 수 있지만 이마저도 중구난방이다.

    그래서 SQLException은 예외가 발생한 상황의 DB 상태를 담아 보여주는 getSQLState() 메서드를 제공한다. JDBC 버젼에 따라 다르지만, Open Group의 XOPEN SQL 스펙에 정의된 상태 코드를 따르도록 되어있다. 그러나 각 DB의 JDBC 드라이버에서 이 상태 코드를 제대로 담지 않는다. 표준이 있어도 제대로 따르지 않는 셈이다.

    SQLException 만으로 DB독립적인 코드 작성하는 것은 불가능에 가깝다.

    DB 에러 코드 매핑을 통환 예외 전환

    SQLException의 상태 코드는 신뢰할 수 없는 지경에 이르렀다. 대신 DB별로 통일되어 있지는 않지만 DB 전용 에러 코드를 이용하고자 한다. DB에서 직접 제공해주기 때문에 일관성이 유지된다는 장점이 있다.

    예를 들어, 키 값이 중복되는 경우 MySQL은 1062, 오라클은 1, DB2는 -803 에러 코드를 반환한다. 스프링은 이 문제를 해결하기 위해 JdbcTemplate에 스프링이 정의한 예외 클래스와, 각 DB마다 고유한 에러 코드를 매핑해놓는다. 각 코드는 DataAccessException의 서브 클래스로 매핑하여, 개발자로 하여금 같은 종류의 에러인 동일한 예외를 받도록 한다. DB 종류에 상관없이!

    DB뿐 아니라 JPA, JDO 등 데이터 엑세스 표준을 지원하는 기술에서 일관된 예외를 발생하도록 만드는 것이 DataAccessException이다.

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

    DAO를 따로 만들어서 사용하는 이유는,

    1. 로직을 담은 코드를 성격이 다른 코드에서 분리해 놓기 위함
    2. 전략 패턴을 이용하여 다른 DAO로 바꾸기 쉽도록 하기 위함
    3. DAO 세부 구현에 상관없이 인터페이스를 통해 쉽게 사용하도록 하기 위함

    이다.

    throws 선언

    DAO에 사용된 DB와 코드는 전략패턴과 DI를 통해 감추는 법을 앞에서 이미 다루었지만, 인터페이스의 메서드 선언에 보이는 예외가 문제가 될 수 있다.

    기술별로 다른 예외를 던지는 같은 메서드를 여러번 선언해야할까? 다행히 JDO, Hibernate, JPA 등의 기술은 런타임 예외를 사용한다. 체크 예외인 SQLException을 던지는 JDBC API의 경우만 문제가 되는데, DAO의 구현에서 런타임 예외로 포장해 던진다면 인터페이스엔 throws 선언을 뺄 수가 있다.

    DAO의 기술에 관계없이 예외 throws 선언을 통일하게 되었다.

    모든 예외는 무시할 수 있을까?

    대부분의 데이터 엑세스 예외는 복구가 불가능하거나 할 필요가 없지만, 키값 중복 등 비즈니스 로직에선 중요하게 여겨지는 예외들이 있고 다루어져야 한다. 어플리케이션 레벨에선 중요하지 않지만 시스템 레벨에선 중요하게 여겨지는 것들도 있다. 데이터 엑세스 하는 기술이 달라지면 같은 상황에서도 다른 종류의 예외를 던지는 것도 문제다. 결국 클라이언트는 DAO의 코드에 종속적인 예외처리 코드를 작성할 수 밖에 없다.

    DataAccessException에는 가능한 모든 예외들이 잘 계층화되어 있어, 데이터 엑세스 기술과 구현에 독립적인 DAO를 작성할 수 있다. 일관성 있는 예외 처리가 가능하기 때문이다. 어느정도까지는 일관성이 있지만, 만능은 아니다. DB간의 에러 코드를 성실히 번역하는 것과 달리, JPA,JDO등 다른 데이터 엑세스 기술의 경우 에러 코드는 세분화되어 있지 않기 때문이다. 학습 테스트를 작성하며 실제 전환되는 예외의 종류를 확인하여야 한다.

    SQLException sqlEx = (SQLException) ex.getRootCause();
    SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);
    // SQLException을 Data Access Exception으로 변환하는 코드.

    댓글