토비의 스프링 4장: 예외

메이도·2023년 3월 29일

3장에서 JDBC 탬플릿으로 변경하며 throws SQLException 부분이 사라졌다. 이 익셉션은 jdbc api 사용 중 던져지는 에러로 JDBC 템플릿에서 태치 됐어도 템플릿 밖으로 던져 에러 사실을 알려야 하는데 코드에서 사라졌다.

예외가 발생했을 때 catch문으로 잡고 catch문 안에 아무것도 작성하지 않거나, System.out.println이나 e.printStackTrace로 끝내는 경우 로그가 뭍혀버리면 중요한 에러를 발견하지 못할 수가 있다.

모든 예외는 적절하게 복구되거나 작업을 중단시키고 운영자나 개발자에게 통보돼야 한다.

예외는 절대 무시되면 안된다. 예외를 잡아 조치할 방법이 없으면 잡으면 안된다. 따라서 throws SQLException을 선언해 메소드 밖으로 던져 메소드를 호출한 코드에 예외 책임을 전가하는게 낫다.

throws Exception이라는 포괄적인 exception을 기계적으로 모든 함수에 넣어버리는 경우가 옛날에 있었는데, 이 에러를 통해 어떠한 정보를 얻을 수가 없다.

  • 메소드 실행 중 예외 발생?
  • 습관적으로 복사 붙여넣기?

예외의 종류

  • error: java.lang.Error 클래스의 서브클래스로 VM에서 발생시키는 시스템 상의 문제를 말한다. 이는 애플리케이션 코드에서 잡아도 대응 방법이 없다.(OutOfMemoryError, ThreadDath)
    java.lang.Exception과 그 서브 클래스는 개발자가 만든 애플리케이션 코드의 작업 중 발생하는 예외를 발한다.

  • 체크예외(일반적인 예외): Exception 클래스의 서브 클래스로 RuntimeException 클래스 상속 안한다. 체크 예외는 반드시 예외 처리 코드를 작성해야 한다(catch문, 밖으로 throws).

  • 언체크예외(런타임 예외): RuntimeException 상속하는 예외로 명시적인 예외 처리를 강제하지 않는다. 피할 수 있지만 개발자의 부주의로 만들어지는 에외(IllegalArgumentException, NullPointerException)로 예상치 못했던 예외가 아니어서 catch나 throws같은 처리가 필요하지 않다.

예외 복구

  1. 예외 상황을 파악학고 문제를 해결해서 정상 상태로 돌려놓는 것으로 예외 발생 시에도 안내 후 정상 동작 해야 함.-> 네트워크 오류로 db 연결이 안되면 다시 재시도 한다던지. 체크 예외는 복구 가능성이 있는 경우 사용한다.
  2. 직접 예외 처리를 하지 않고 메소드를 호출한 코드로 예외를 던지는 것. throws를 통해 던지거나, catch 후 로그 남기고 throw 하는 경우가 있다. 콜백 메소드의 경우 예외 처리가 역할이 아니기에 throws 로 예외를 회피한다.(책임이 명확한 경우 사용)

예외 전환

예외를 적절한 예외로 전환해서 던진다
1. 예외상황에 적절한 의미를 분명하게 반영할 수 있는 예외로 변경. 분명한 의미를 가진 예외를 보고 서비스 계층이 예외를 복구할 수도 있다.

public void add(User user) throws DuplicateUserIdException, SQLException{
	try{}
    catch(SQLException){
    	if(e.getErrorCode()==MysqlErrorNumbers.ER_DUP_ENTRY)
        	throw DuplicateUserIdException
        else
            throw e;
    }
}

전호나하는 예외는 원래 예외를 담아 중첩예외로 만드는 것도 좋다.

throw DuplicateUserIdException(e);

throw DuplicateUserIdException().initCause(e);
  1. 예외 포장. 중첩 예외를 사용하는 방식은 같지만 체크 예외를 언체크 예외로 변경하는 경우 사용. RemoteException/SQLException/NamingException과 같은 체크 예외들은 복구할 방법이 없기 때문에 EJBException으로 포장해서 던져도 좋다.
    복구가 불가능한 예외라면 빨리 런타임 예외로 포장해 던져 다른 계층 메소드 작성시 불필요한 throws가 선언되지 않도록 한다.
try{}
catch(NamingException ne){
	throw new EJBException(ne);
}

반면에 로직에서 예외 조건이 발견되거나 예외 상황 발생이 예상되는 경우에는 체크 예외를 그대로 사용해서 대응하고 복구하는 것이 적절하다.

런타임 예외 보편화

  1. 애플리케이션 차원에서 예외상황을 미리 파악하고, 예외 미리 차단
  2. 외부 환경이나 프로그램 오류로 예외 발생하는 경우 요청 작업을 빨리 취소하고 관리자에게 통보한다.
    복구할 가능성이 조금이라도 있으면 체크 예외로 둔 과거와는 달리 항상 복구가능하지 않다면 언체크로 만드는 경향이 있다.
public void add(final User user) throws DuplicateUserIdException {//내부 클래스에서 외부 로컬 변수 접근 위해 final 설정
        try{
            jdbc 작업
        }catch (SQLException e){
            if(e.getErrorCode()==MysqlErrorNumbers.ER_DUP_ENTRY)
                throw DuplicateUserIdException(e);
            else
                throw new RuntimeException(e);
        }
    }

위와 같은 add 메소드는 불필요한 throws 선언을 하지 않아도 되며 필요하면 DuplicateUserIdException를 사용할 수 있다.
다만 런타임 예외는 컴파일러가 예외처리를 강제하지 않아 꼼꼼히 예외상황을 고려해야 한다.

애플리케이션 예외

런타임 예외 중심의 전략은 복구 가능 예외가 없다고 가정한 후 예외가 생겨도 시스템 레벨에서 처리해줄 것이라 믿고, 필요한 경우 런타임 예외를 catch할 수 있다 믿기에 낙관적 예외 처리라고 한다.

애플리케이션 예외는 외부 예외가 원인이 되지 않고 로직에 의해 의도적으로 생성된 후 catch로 조취되어야 하는 예외를 말한다.-> 인출 시 잔고 부족의 경우 비즈니스적 의미를 띤 예외를 만드는 것으로 이럴 경우 예외 상황에 대한 로직을 구현하도록 강제해야 한다.

결국 JdbcTemplate 안의 update, queryForInt 등의 함수 선언을 살펴보면 DataAccessException이라는 런타임 에러로 포장돼있는걸 확인할 수 있다.

DB 종류에 상관없이 데이터 엑세스 코드 작성 가능?

JDBC는 자바로 DB에 접근할 수 있는 방법을 추상 api로 정의 후 db 업체가 JDBC 표준을 따라 만든 드라이버를 제공하게 만들었다. 표준 인터페이스를 통해 db 종류에 관계 없이 개발할 수 있다.

문제점
1. 비표준 SQL: 특정 로우의 시작 위치와 개서 지정, 조건문 포함 등의 비표준 SQL은 dao에 종속된다.-> DAO를 DB 별로 만들거나 SQL을 외부로 독립시켜 바꿀 수 있게.
2. db별로 에러코드도 다 다르다.-> sqlException은 db 상태 담은 sql 상태 정보를 부가적으로 제공한다.(getSqlState()가 있지만 이 상태코드는 정확하지 않다.) 스프링은 db별 에러 코드를 분류하고 예외 클래스에 에러 코드를 매핑해 테이블을 만들어 사용한다(p301)

Dao 인터페이스

인터페이스의 함수 선언에는 없는 예외를 구현 클래스 메소드의 throws에 넣을 수 없다. 따라서 throws SQLException이 되어야 하는데, 이렇게 되면 데이터 엑세스 기술(JAP/Hibernate/JDO)에 따라 예외가 달라져 다양한 에러를 throws하는 함수 선언을 만들 수밖에 없다.

DataAccessException은 데이터 엑세스 기술에서 발생할 수 있는 대부분의 예외를 계층별로 추상해놨다.

낙관적 락킹: 오브젝트/엔티티 단위로 정보를 업데이트 하는 경우 같은 정보를 두 명 이상의 사용자가 동시에 조회하고 순차적으로 업데이트 하는 경우 늦게 업데이트 한 것이 먼저 업데이트 한 것을 덮어쓰지 않도록 막아주는 기능이 있다. 이런 예외들은 사용자에게 안내 메시지를 보여주고 다시 시도할 수 있도록 도와줘야 한다. 데이터 엑세스 기술마다 다른 낙관적 락킹 예외를 보여주지면, DataAccessException을 통해 ObjectOptimisticLockingFailureException로 통일시킬 수 있다.

결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 엑세스 기술과 구현 방법에 독립적인 이상적인 DAO 만드는 것이 가능하다.

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

public class UserDaoJdbc implements UserDao {}
<bean id = "userDao" class = "org.example.user.dao.UserDaoJdbc">
        <property name="dataSource" ref = "dataSource"/>
    </bean>

빈의 이름은 구현한 인터페이스의 이름으로 설정하는 것이 맞다. 그래야 구현 인터페이스 이름이 바뀌어도 혼란이 없다.

public class UserDaoTest{
    @Autowired
    private UserDao dao;
}

위의 경우에 UserDaoJdbc로 고치지 않아도 Autowired가 스프링 컨텍스트 내의 빈 중 인스턴스 변수에 주입 가능한 타입의 빈을 찾아줘 UserDaoJdbc로 변경하지 않아도 된다.

다만 아무리 DataAccessException이 예외를 추상화된 공통 예외로 변환해줘도 완벽하게 같은 의미를 포괄하는 예외를 항상 설정하기는 어렵다.

@Test
    public void sqlExceptionTranslate(){
        dao.deleteAll();

        try{
            dao.add(user1);
            dao.add(user1);
        }catch(DuplicateKeyException ex){
            SQLException sqlEx = (SQLException) ex.getRootCause();//sqlexception을 적절한 DataAccessException으로 변경해 준다.(DuplicateKeyException)
            SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);//에러 코드 변환에 필요한 db 종류 알아내기
            assertThat(set.translate(null, null, sqlEx), is(DataAccessException.class));
            System.out.println("translate1: " + set.translate(null, null, sqlEx));
        }
    }

이를 통해 발생한 익셉션이 데이터베이스에 맞는 DataAccessException으로 변경되는 것을 확인할 수 있다.

0개의 댓글