• [토비의 스프링 vol.1]4장 예외

    2021. 6. 11.

    by. 웰시코더

    반응형

    -토비의 스프링 3.1 vol.1의 4장을 정리 및 실습한다.


    4.1 사라진 SQLException

     

    3장 템플릿/콜백에서 JdbcContext를 JdbcTemplate로 바꾼 후 예외를 던지는 코드가 사라졌다.

    //jdbcTemplate 적용 전
    public void deleteAll() throws SQLException { 
        this.jdbcContext.executeSql("delete from users"); 
    } 
    //jdbcTemplate 적용 후
    public void deleteAll() { 
        this.idbcTemplate.update("delete from users");
    }

    위의 throws SQLException이 어디로 갔을까? 이번 장에서는 그 이유를 살펴본다. 그 전에 난감한 예외처리의 대표적인 예시들을 확인해본다.

     

    4.1.1 초난감 예외처리

    예외 블랙홀

     try/catch만 씌워주고 아무 것도 안 한다. 최악의 코드라고 한다. 

    try{
    	//예외처리할 코드
    }catch(SQLException e){
    }

    위의 코드에서 예외가 발생하면 그냥 넘어가겠지만 최종적으로는 시스템 오류가 분명 발생할 것이다. 그 밖에 다음과 같이 로그만 찍어주는 예외처리도 의미 없다.

    try{
    	//예외처리할 코드
    }catch(SQLException e){
    	e.printStackTrace();
    }

     

    무의미하고 무책임한 throws

     다음과 같은 코드가 있다고 하자.

    public void method1() throws Exception{
    	method2();
    }
    
    public void method2() throws Exception{
    	method3();
    }
    
    public void method3() throws Exception{
    	method4();
    }

    발생가능한 특정 예외를 선언하는 것이 아니라 그냥 Exception을 던지는 코드이다. 또한 catch로 잡아주는 것이 아니라 호출한 메소드로 계속 넘긴다. 적절하게 예외복구될 기회를 박탈당하는 코드이다.

     

    4.1.2 예외 종류 및 특징

    자바에서 throws를 통해 발생시킬 수 있는 예외는 3가지가 있다.

    1. Error : java.lang.Error클래스의 서브클래스들이다. 시스템적인 문제 상황(OutOfMemoryError 등)에서 발생하기 때문에 VM에서 주로 발생시킨다. 그렇기 때문에 애플리케이션 단에서 따로 처리할 수도 없고 해서도 안 된다.
    2. Exception과 체크 예외(Checked Exception) : java.lang.Exception 클래스 및 서브클래스로 정의되는 예외들은 애플리케이션에서 발생하는 예외에서 사용된다.예외는 체크예외,언체크예외로 나뉘는데 Exception의 하위클래스들 중 RuntimeException을 상속하지 않은 예외들을 체크예외라고 하고 RuntimeException을 상속한 예외들을 언체크 예외라고한다. 체크 예외는 예외처리 코드가 필수이다. 안 그러면 컴파일 에러가 발생한다.(IOException, SQLException 등)
    3. RuntimeException과 언체크/런타임 예외 : java.lang.RuntimeException 클래스를 상속한 예외들이다. 예외 강제가 없으며 프로그램 오류가 있을 때 발생하도록 되어 있다. NullPointException, IllegalArgumentException 등이 있다. 즉, 개발자 부주의로 인해 발생하는 예외이다.

     

    4.1.3 예외처리 방법

    예외 복구

     예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 요청 파일을 읽으려고 헀는데 문제가 발생하여 IOException이 발생할 경우 상황을 사용자에게 알려주고 다른 파일을 이용하도록 안내할 수 있다. 작업 흐름을 다른 작업 흐름으로 유도해주는 것이다. 

     또한 네트워크 문제로 SQLException이 발생하면 재시도 코드를 통해 예외상황을 복구 시도할 수 있다. 다음과 같은 코드가 그런 예이다.

     

    int maxRetry=MAX_RETRY;
    
    while(maxRetry-- > 0) {
       try{
           //예외 처리할 코드
       }catch(SomeException e){
           //로그 출력. 정해진 시간만큼 대기
       }finally {
           //리소스 반납 및 정리작업
       }
    }
    
    throw new RetryFailedException(); //재시도 횟수 초과시 직접 예외 발생

     

    예외처리 회피

     예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던지는 것이다. throws 선언으로 예외발생시 알아서 던지거나 catch로 잡아서 로그를 찍어주고 rethrow하는 것이다. jdbcTemplate나 jdbcContext에서 콜백오브젝트가 SQLException을 템플릿에게 던지는데, 이런 경우 두 관계가 긴밀하게 역할분담을 하고 있기 때문이다. SQLException은 콜백의 책임이 아니라고 보는 것이다. 

     이런 경우를 제외하고 무조건 throws로 던져서 결국 컨트롤러까지 가서 서버에 전달하면 안 된다. 예외 복구처럼 목적이 명확해야 한다.

     

    예외전환

     예외를 메소드 밖으로 던질 때 적절한 예외로 전환해서 던지는 것이다. 사용하는 이유는 크게 두 가지이다.

     첫번째내부에서 발생한 예외를 그대로 던질 때 서비스단 등에서 적절하게 의미 해석이 어려울 때, 의미를 분명하게 해주는 예외로 바꿔 던진다. 사용자 정보 등록 시에 중복된 아이디 값 때문에 SQLException이 난 경우 의미가 명확한 DuplicateKeyException으로 전환할 수 있다. 다음과 같다.

    public void add(User user) throws DuplicateUserIdException, SQLException {
        try{
            //JDBC관련 코드
            //SQLException을 던지는 메소드 호출 코드
        }catch(SQLException e){
            if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) throw DuplicateUserIdException();
        	else throw e;
        }
    }

      다음처럼 중첩 예외로 만들어 처음 발생한 예외가 무엇인지 확인하는 게 좋다.

    //중첩예외 방법1
    try{
    	//생략
    }catch(SQLException e){
    	//생략
        throw DuplicateUserIdException(e);
    }
    
    //중첩예외 방법2
    try{
    	//생략
    }catch(SQLException e){
    	//생략
        throw DuplicateUserIdException().initCause(e);
    }
    

     

     두번째로 예외를 처리하기 쉽고 단순하게 만들기 위해 포장(wrap)하여 체크 예외를 런타임 예외로 바꾸기 위한 경우다. 복구 가능한 예외가 아닌 경우 런타임 예외로 만들어 전달하면 트랜잭션을 자동 롤백할 수 있다.(RuntimeException을 상속한 EJBException 등) 일반적으로는 체크 예외를 계속 throws로 던지는 것은 무의미하다. 빨리 런타임 예외로 포장하는 게 더 좋다.

    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);
    }

     책에서는 위와 같이 EJB로 예외 포장을 설명하는데 일단 EJB를 잘 모른다. 그래도 대충 말하려는 목적은 NamingException, SQLException, RemoteException 등의 체크 예외가 발생하면 그냥 던지는 것은 무의미하니 런타임예외(EJBException)로 포장하여 롤백처리라도 하든지 고객 안내라도 하든지 하라는 것 같다.

     

    4.1.4 예외처리 전략

    런타임 예외의 보편화 - add() 메소드 예외처리

     위에서 add() 메소드를 예외전환하는 예시를 살펴봤다. throws로 SQLException 체크예외를 던지는 것은 사실 의미가 없으므로 언체크예외로 예외포장 해주는 것이 더 좋다. DuplicateUserIdException도 add()를 호출한 쪽에서 처리할 수 있게 체크예외가 아닌 언체크예외로 던지는 것이 좋다. 또한 명시적으로 throws DuplicateUserIdException을 명시하여 add메소드를 사용하는 개발자에게 의미 있는 정보를 전달한다. 수정한 버전은 다음과 같다.

     

    UserDao.java 일부

    public void add(User user) throws DuplicateUserIdException{
        try {
            this.jdbcContext.executeSql("insert into users(id, name, password) values(?,?,?)",user.getId(), user.getName(), user.getPassword());
        }catch(SQLException e) {
            if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) {
                throw new DuplicateUserIdException(e);	//예외 전환
            }else {
                throw new RuntimeException(e);	//예외 포장(체크예외->언체크예외)
            } 
        }
    }
    	
    class DuplicateUserIdException extends RuntimeException{
        DuplicateUserIdException(Throwable cause) {	//중첩예외를 위한 생성자
            super(cause);
        }
    }

    런타임 예외 중심 전략은 일단 복구할 수 있는 예외는 없다고 가정하며 예외가 생겨도 어차피 런타임 예외로 만들어 시스템 레벨에서 알아서 처리해주거나 꼭 필요한 경우는 런타임 예외라도 잡아서 복구 및 대응해줄 수 있으니 문제 없다는 태도의 전략이다.  

     

    애플리케이션 예외

     런타임 예외와는 다르게 시스템 또는 외부 예외상황이 원인이 아니라 애플리케이션 로직에 의해 의도적으로 발생시키고 반드시 catch하여 처리하도록 하는 예외도 있다. 이를 애플리케이션 예외라고 한다. 흔한 예로 은행계좌 출금 메소드가 있다. 출금 요청 시에 잔고 확인 후 허용 범위를 넘으면 작업을 중단시키고 사용자에게 경고를 보내야 한다. 이런 경우 처리 방법은 두 가지가 있다.

    1. try/catch를 사용하는 것이 아니라 분기문(if)을 사용하여 다른 리턴 값을 돌려주는 것이다. 정상 출금이면 요청금액을 리턴하고 잔고 부족의 경우 0이나 -1을 리턴하도록 한다. 이런 경우 문제는 예외상황에 따라 리턴 값을 명확하게 코드화하지 않으면 혼란이 온다는 것이다. 잔고 부족의 경우 0,-1이 아니라 -99를 돌려주는 개발자도 있기 때문에 일관성 문제가 생길 수 있다. 또한 if 블록 범벅으로 가독성이 떨어진다.
    2. 두 번째 방법은 잔고 부족이 뜨는 경우만 의미 있는 예외를 던지는 것이다. 이 때 예외는 의도적으로 체크 예외로 만들어야 개발자가 잊지 않고 잔고 부족처럼 자주 발생하는 예외상황에 대한 로직을 구현하도록 강제할 수 있다.

    두 번째 예시인 애플리케이션 예외를 코드 예시로 만들면 다음과 같다.

    try{
        BigDecimal balance = account.withdraw(amount);
        //정상흐름 코드
    }catch(InsufficientBalanceException e){
        BigDecimal availFunds = e.getAvailFunds();   //인출 가능한 잔고금액 정보를 위의 예외 오브젝트에서 가져옴
        //잔고 부족 안내 메세지
    }

     

    4.1.5 SQLException은 어떻게 되었는가 

     JdbcTemplate에 throws SQLException은 사라졌다. 위의 설명에서 복구 불가능한 SQLException과 같은 예외는 기계적인 throws 선언하도록 방치하지 않도록 언체크/런타임 예외로 전환해줘야 한다고 헀다. JdbcTemplate는 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿 및 콜백 안에서 발생하는 모든 SQLException은 런타임 예외인 DataAccessException으로 포장하여 던져준다. 런타임 예외는 강제가 아니기 때문에 UserDao의 DAO메소드들에서 SQLException이 사라진 것이다.

     

    4.2 예외 전환

     위에서 설명했듯이 예외를 다른 것으로 바꿔 던지는 예외 전환 목적은 두 가지다.

    1. 런타임 예외로 포장해서 불필요한 catch/throws를 없애려는 목적
    2. 로우레벨의 예외를 좀 더 의미있고 추상화된 예외로 바꿔 던져주려는 목적

    4.2.1 JDBC의 한계

     JDBC는 자바를 이용해 DB 접근법을 추상화된 API 형태로 정의한다. 각 DB벤더(오라클, MySQL 등)에서는 JDBC 표준을 따라 만들어진 드라이버를 제공한다. JDBC가 없었다면 각 DB벤더가 자체적인 API를 각각 제공했을 것이고 DB를 바꿔야하는 상황이 오면 모든 DAO 등을 다 바꿔야하는 문제가 생길 것이다.

     그러나 DB종류에 상관없이 사용할 수 있는 데이터 엑세스 코드 작성은 쉽지 않다. 다음과 같은 문제점이 있다.

     

    비표준 SQL

     각 벤더마다 비표준 문법이 있다. 오라클의 start with connect by 문법이 대표적이다.

     

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

      예외 발생시의 에러 코드가 벤더마다 다르다. e.getErrorCode()로 가져와지는 값이 오라클, MySQL마다 다르기 때문에 DB가 바뀐다면 위에서 작성했던 다음과 같은 예외코드는 사용하지 못할 것이다.

    if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY){
    	//생략
    }

     그래서 SQLException은 예외 발생시 DB 상태를 담은 SQL상태정보를 부가적으로 제공한다. getSQLState()로 가져올 수 있다. 이 상태코드는 표준화된 관례를 따른다. 통신장애를 예로 들면 08S01, 테이블 존재 안 하는 경우는 42S02 등이다. DB에 독립적인 에러정보를 얻을 수 있는 것이다. 

     그러나 JDBC 드라이버에서 SQLException을 담을 상태코드를 정확하게 만들어주지 않기 때문에 의존하여 사용하는 것은 위험하다.

     

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

     위의 문제를 해결하려면 DB별 에러 코드를 참고해서 발생한 예외 원인이 무엇인지 해석하는 기능을 만드는 것이다. 키 값 중복이 되어 발생한 경우 MySQL은 1062, 오라클이면 1이라는 에러코드를 받는다. 이런 에러 코드를 확인하여 의미가 분명히 드러나는 DuplicateKeyException 예외로 전환할 수 있다.

     위에서 썼던 add()는 throws 선언을 없애도 되고 중복키 발생 문제는 DataAccessException의 서브클래스인 DuplicateKeyException으로 throws하면 편리하다.

     

    UserDao.java 일부

    public void add(User user) throws DuplicateKeyException{
        this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)", 
            user.getId(), user.getName(), user.getPassword());
    }

     중복키 에러 발생시 애플리케이션 예외를 발생시키고 싶을 수 있다. 

    public void add() throws DuplicateUserIdException{
        try{
            //jdbctemplate을 이용해 user를 add하는 코드
        }catch(DuplicateKeyException e){
            //로그 등 작업
            throws new DuplicateUserIdException(e);   //원인예외도 던져주기
        }
    
    }

     이렇게 하면 런타임 예외인 DuplicateKeyException을 예외처리 강제할 수 있으며, 애플리케이션 예외를 발생시킬 수 있다. 런타임예외라서 강제처리가 안 되어 불안한 경우라든지 사용할 수 있다.

     

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

     DataAccessException은 JDBC의 SQLException 전환 용도 이외에도 다른 자바 데이터 엑세스 기술에서 발생하는 예외에도 적용된다. JDO,JPA,iBatis 등이 있다. DataAccessException은 데이터 엑세스 기술에 독립적인 추상화를 제공하는 것이다. 

     

    DAO 인터페이스와 구현 분리

     DAO를 따로 만들어서 사용하는 이유는 데이터 엑세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서이다. 또한 분리됨 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해서이다. DAO는 인터페이스를 사용해 구체적인 클래스정보와 구현방법을 감추고 DI를 통해 제공되도록 만들어야 한다.

     인터페이스의 메소드들은 throws 선언을 해줘야 한다. 구현 클래스의 add()가 JDBC를 사용하면 SQLException을 던질 것인데 인터페이스 메소드 선언에 없는 예외를 구현 클래스 메소드의 throws에 넣을 수 없다. 그래서 인터페이스에도 throws로 예외를 던져줘야한다.

     그러나 SQLException은 JDBC 기술에서 던져지는 예외이므로 다른 엑세스 기술로 바꾼다면 DAO를 사용할 수 없다.

    JPA는 throws PersistentException을 던지고 Hibernate는 HibernateException 등 각각 다른 Exception을 던진다. 

     이런 경우 throws Exception으로 모든 예외를 다 받을 수 있지만 무책임한 방법이다. 다행히도 JPA 등 최신 기술들은 SQLException같은 체크예외가 아니라 런타임예외를 사용한다. 따라서 throws 선언을 안 해도 된다. 남은 SQLException은 런타임예외로 포장해서 던지면 된다. 결과적으로 인터페이스에서는 throws를 없애도 된다.

     그러나 중복키 예외처럼 비지니스 로직에서 의미 있게 처리해야 하는 예외도 있으니 데이터 엑세스 예외를 의미 있게 분류하고 처리할 필요가 있다. 위에 언급한 것처럼 각 엑세스 기술마다 던지는 예외가 다르기 때문에 DAO를 사용하는 클라이언트에서 예외처리방법이 달라져야 한다. 클라이언트가 DAO에 의존적일 수밖에 없다.

     

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

     스프링은 자바의 다양한 데이터 엑세스 기술 사용시 발생하는 예외들을 추상화하여 DataAccessException 계층구조에 정리해뒀다. DataAccessException은 자바의 주요 데이터 엑세스 기술에서 발생할 수 있는 대부분 예외를 추상화하고 있다. 

     

    4.2.4 기술에 독립적인 UserDao 만들기

    인터페이스 적용

     

     UserDao를 인터페이스로 만들고 기존 UserDao는 UserDaoJdbc로 rename하여 UserDao를 implement한다.

     

    UserDao.java

    public interface UserDao {
        void add(User user);
        User get(String id);
        List<User> getAll();
        void deleteAll();
        int getCount();
    }
    

     

    UserDaoJdbc.java 일부

    public class UserDaoJdbc implements UserDao {
    	private RowMapper<User> userMapper = new RowMapper<User>() {
    		public User mapRow(ResultSet rs, int rowNum) throws SQLException {
    			User user = new User();
    			user.setId(rs.getString("id"));
    			user.setName(rs.getString("name"));
    			user.setPassword(rs.getString("password"));
    			return user;
    		}
    	};
    	
    	private JdbcTemplate jdbcTemplate;
    	private JdbcContext jdbcContext;
    	
    	public void setDataSource(DataSource dataSource) {
    		this.jdbcTemplate = new JdbcTemplate(dataSource);
    		this.jdbcContext  = new JdbcContext();
    		jdbcContext.setDataSource(dataSource);
    	}
    	
    	public void add(User user){
            this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)", 
                user.getId(), user.getName(), user.getPassword());
    	}
        //생략..    
    }

    그리고 applicationContext.xml 설정파일의 bean 정보를 UserDao에서 UserDaoJdbc로 변경한다.

     

    applicationContext.xml 일부

    <bean id="userDao" class="springbook.user.dao.UserDaoJdbc">
        <property name="dataSource" ref="dataSource"/>
    </bean>

    class의 UserDao를 UserDaoJdbc로 변경했다.

     

    테스트 보완

     UserDaoTest에서 add() 중복 호출을 통해 어떤 예외가 발생하는지 확인해본다. 이때 스프링의 DataAccessException나 서브클래스 예외가 던져져야 한다.

     

    UserDaoTest.java 일부

    @Test(expected=DataAccessException.class)
    public void duplicateKey() {
        dao.deleteAll();
        dao.add(user1);
        dao.add(user1);
    }

     

     여기서 나는 이상하게 ClassNotFoundException -> NoClassDefFoundException이 난다..DuplicateKeyException을 던지긴 하는데 익셉션 클래스를 찾을 수 없다고 나온다. 참조하는 exception jar가 없거나 버전이 안 맞는 문제인 것 같아서 일단 라이브러리를 뒤져봤는데 org.springframework.dao에 있는 것은 맞아 보인다. 근데 DuplicateKeyException이 없는 것 같다.

    ->org.springframework.transaction-3.1.0.RELEASE.jar를 추가해줬더니 되었다..여기에 DuplicateKeyException이 있었다.

     

     어쨋뜬 정확히 DataAccessException이 던져진 것인지, 그 하위 Exception이 던져진 것인지 확인하기 위해 expected 조건을 빼고 테스트한다. 오류가 발생하면서 어떤 exception이 발생했는지 나온다. DuplicateKeyException이 발생했다. expected=DuplicateKeyException.class로 선언해줘도 통과가 된다.

     

    DataAccessException 활용 주의사항

     DuplicationKeyException은 JDBC를 이용하는 경우에만 발생한다. 다른 데이터 엑세스 기술(JPA 등) 사용 시 동일 예외가 발생하지가 않는다. JDBC는 SQLException에 담긴 DB 에러코드를 바로 해석하지만, JPA 등은 각 기술이 재정의한 예외를 가져와 스프링이 최종적으로 DataAccessException으로 변환하는데 DB의 에러코드와 달리 이런 예외들은 세분화 되어 있지 않기 때문이다.

     DataAccessException은 기술 상관없이 어느정도는 추상화된 공통예외로 변환해주지만 한게가 있다. 하이버네이트 등은 중첩예외로 SQLException이 전달되기 때문에 이를 다시 스프링의 JDBC 예외전환 클래스 도움을 받아 처리 가능하다.

     SQLException DB코드를 직접 전환하는 방법을 사용한다. 에러코드 변환에 필요한 DB종류를 알아내야 하기 때문에 DataSource 변수추가 후 빈을 받는다.

     

    UserDaoTest.java 일부

    @Test
    public void sqlExceptionTranslate() {
        dao.deleteAll();
    		
        try {
            dao.add(user1);
            dao.add(user1);
        }catch(DuplicateKeyException ex) {
            SQLException sqlEx = (SQLException)ex.getRootCause();	//중첩예외 뽑기
            
            SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource); //스프링 예외 전환 API
    			
            assertThat(set.translate(null, null, sqlEx), is(DuplicateKeyException.class));
        }
    }

     위의 코드를 해석하면 우선 DuplicateKeyException이 발생할 때 catch를 해준다. DuplicateKeyException은 중첩된 예외로 중첩예외를 찾기 위해 getRootCause()를 사용하여 SQLException 형변환 처리 후 sqlEx 변수에 담아준다. 이후 스프링 예외 전환 API를 사용하여 DuplicateKeyException이 만들어지는지 본다. 주입받은 dataSource를 이용해 SQLErrorCodeSQLExceptionTranslator의 오브젝트를 만든다. 그리고 SQLException을 파라미터로 넣어 translate()를 호출하면 SQLExceptiond을 DataAccessException 타입 예외로 변환해준다. 변환된 예외가 DataAccessException 타입의 예외가 맞는지 assertThat으로 확인한다. is() 대신 equals()를 넣으면 주어진 클래스 인스턴스인지 검사해준다. 다른 DB로 변경해도 성공한다. 

     

     

    참고자료 : 토비의 스프링 3.1 Vol.1

    반응형

    댓글