ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 토비의 스프링[4] 예외처리
    Spring 2021. 1. 27. 06:05

    서론

    UserDao를 JdbcTemplate로 변경하면서 SQLException이 사라진 이유와 예외처리 및 전환 방법에 대해 알아보자.


    예외의 종류와 특징

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

    1. Error
      • 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다.
      • 주로 자바 VM에서 발생시키며, 애플리케이션에서 코드로 잡을 수 없으며, 별도로 처리하지 않아도 된다.
    2. Exception 과 체크 예외
      • java.lang.Exception 클래스와 그 서브클래스로 정의되며, 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업중 예외상황이 발생할 경우 사용된다.
      • 체크예외는 throw로 던지던가, catch 로 잡아 주어야 컴파일 에러가 발생하지 않는다.
    3. RunTimeException 과 언체크 예외/런타임 예외
      • java.lang.RunTimeException 클래스와 그 서브클래스로 정의되며, 예외처리를 강제하지 않는다.

    예외처리 방법

    1. 예외 복구
      • 예외 상황 발생 시 다른 작업으로 흐름을 변경하거나, 기존 작업을 복구하여 정상적으로 애플리케이션이 실행되도록 하는 방법이다. 예외처리 코드를 사용하는 경우 해당 체크 예외들은 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.
    2. 예외처리 회피
      • 예외를 자신이 처리하지 않고 넘기는 방식으로 자신을 호출한 쪽으로 예외를 던져버리는 것이다. throws문으로 선언해서 예외 발생 시 던지거나 catch 문을 이용하여 예외를 잡은 후 로그를 남기고 다시 던지는 방식이다.
    3. 예외 전환
      • 예외 전환의 경우 회피와 마찬가지로 메소드 밖으로 예외를 던지는 것이다. 하지만 발생한 예외를 그대로 던지는 회피와 다르게 적절한 예외로 전환해서 던진다는 특성을 가지고 있다.
      • 자신을 호출한 메소드에게 발생한 예외를 그대로 던질경우 해당 메소드는 어떠한 이유가 발생하여 이러한 예외가 던져졌는지 파악을 하기 어렵게 된다. 그렇기에 자신을 호출한 메소드에서 예외를 처리할 수 있도록 알아볼 수 있는 에외로 전환하여 던진다.
      • 보통 전환하는 예외에 원래 발생한 예외를 담아 중첩 예외로 만들어야 getCause() 메소드를 이용하여 최초 발생한 예외가 무엇인지 확인할 수 있다.

    예외처리 전략

    1. 런타임 예외의 보편화
      • 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치가 점점 떨어지고 있기에 자칫하면 Throws Exception으로 점철된 아무런 의미도 없는 메소드들을 낳을 뿐이다. 그래서 대응이 불가능한 경우 런타임 예외로 전환해서 던지는게 더 낫다.
    2. add() 메소드의 예외처리
      • add() 메소드는 DuplicationUserIdException과 SQLException두가지의 체크 예외를 던지게 되어 있다. 여기서 JDBC 코드에서 SQLException 의 세밀한 이유가 중복된 ID 라면 더 의미있는 예외인 DuplicationUserIdException으로 전환해주고 그렇지 아니라면 SQLException을 던지게 되어있다.
      • DuplicationUserIdException같이 의미 있는 예외는 add()를 호출한 메소드에서 다룰 수도 있다. 그렇기에 어디서든 처리할 수 있는 예외라면 런타임 예외로 만드며, 대신 add() 메소드에서는 명시적으로 DuplicationUserIdException을 던진다고 선언해야한다.
      • SQLException같은 경우 복구할 수 없는 예외이기에 잡아봤자 처리할 수 없으며 던져도 결국 애플리케이션 밖으로 던져지게 된다. 이럴경우 그냥 런타임 예외로 포장해서 다른 메소드가 신경쓰지 않게 해주는 편이 더 낫다.
    public void add(final User user) throws DuplicateUserIdException {  
        try {  
        // add error 고유키 중복 발생!  
        this.jdbcTemplate.update("insert into users(id, name, pwd) values(?,?,?)", user.getId(), user.getName(), user.getPwd()); // add method none throw SQLException  
        } catch (SQLException e) {  
          if(e.getErrorCode() == MysqlErrorNumbers.ER\_DUP\_ENTRY)  
          	throw new DuplicateUserIdException(e);  
          else  
          	throw new RunTimeException(e); // 예외 포장  
        }  
    }
    
    1. 애플리케이션 예외
      • 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외
      • 이 경우에는 2 가지의 설계 방법이 존재한다.
        1. 정상적인 처리와 예외 시 각각 다른 종류의 반환 값을 돌려준다.
          • 정확하면서 오히려 매번 반환 값을 if로 확인하는 불편한 코드가 될 수 있다.
        2. 정상적인 처리는 그대로 두고, 예외 시 비지니스적인 의미를 띤 예외를 던진다.
          • 예외상황에 대한 처리는 catch블록에 모아 둘 수 있기에 가독성이 좋으며, 번거로운 if문을 남발하지 않아도 된다.
          • 이렇게 발생한 예외는 예외사항에 대한 로직을 구현하도록 강제성을 주기 위하여 체크예외로 제작하는 것이 좋다.

    SQLException은 어떻게 됐나?

    JdbcTemplate템플릿/콜백의 경우 코드레벨에서 복구할 수 없는 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 그렇기에 필요한 경우 DataAccessException을 잡아 처리하면 되고 그렇지 않은 경우 무시해도 된다.

    예외 전환

    • 이유
      1. catch/throws 를 최소화 하기 위하여
      2. 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주기 위하여
      3. 스프링의 JdbcTemplate가 던져주는 DataAccessException은 SQLException을 포장해서 던져주는 런타임 예외이다. 그렇기에 애플리케이션에서 거의 복구가 불가능한 SQLException을 신경쓸 이유를 없애준다. 하지만 이 뿐만 아니라 SQLException에 담긴 힘든 예외에 대한 상세한 정보를 의미 있고 일관성 있는 예외로 전환해서 추상화해주려는 용도로도 쓰인다.

    JDBC의 한계

    JDBC는 자바 표준 JDK에서도 가장 많이 사용되는 기능 중 하나이며, 자바를 이용해 데이터베이스에 접근하는 방법을 추상화된 API형태로 정의해놓고, 각 데이터베이스 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다. 내부 구현은 조금씩 다르겠지만 Connection, Statement, ResultSet등의 표준 인터페이스를 통해 기능을 제공해주어 데이터베이스의 종류에 상관없이 일관된 방법으로 개발이 가능하다.
    하지만 데이터베이스 종류 상관없이 데이터 엑세스 코드를 작성하는 일은 쉽지 않다. JDBC API가 데이터베이스 프로그램 개발 방법을 학습하는 부담은 확실히 줄여주지만 데이터베이스를 자유롭게 변경해서 사용할 수 있는 유연한 코드를 보장해주지 못한다. 이러한 이유로는 2가지의 걸림돌이 존재하기 때문이다.

    1. 비표준 SQL
      • 일정 수준의 표준화된 언어이며, 규약이 있지만 대부분의 DB 는 표준을 따르지 않는 비표준 문법과 기능을 제공한다.
      • 그렇기에 표준, 비표준 SQL을 사용하는 DB 마다 새롭게 구현해야 한다.
    2. 호환성 없는 SQLException
      • DB를 사용하다 발생할 수 있는 모든 예외의 원인은 다양하지만 JDBC 는 다양한 예외를 모두 SQLException으로 나타내기 때문이다. 그렇기에 발생한 원인의 경우 SQLException의 에러코드를 확인해야 한다. 하지만 DB마다 재마다의 에러코드를 가지고 있기에 DB 가 변경될 경우 해당하는 에러코드로 변경해주어야 한다.

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

    DB 별 에러코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해주는 기능을 만드는 것이다. 하지만 DAO 메소드 및 JdbcTemplate에서 코드의 종류를 일일이 확인 하는 작업은 부담이 크기에 DB 별 에러코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러코드 매핑정보 테이블을 만들어두고 이를 이용하면 된다.

    JdbcTemplate을 이용한다면 발생하는 DB 관련 예외는 신경 쓰지 않아도 되지만 애플리케이션 레벨의 체크 예외를 발생시키고 싶은 경우 catch 블록에서 체크 예외를 던져주면 된다.

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

    DataAccessException은 JDBC 의 SQLException을 전환하는 용도로만 사용되는 것이 아닌 그 밖 JDO, JPA, Mybatis기술에서 발생하는 예외에도 적용된다.

     

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

    • 데이터 엑세스 로직을 담은 코드를 성격이 다른 코드에서 분리한다는 의미로 분리를 한다. 또한 분리된 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해서이기도 하다. 이처럼 DAO는 인터페이스를 사용해 구체적인 클래스 정보와 구현 방법을 감추고, DI를 통해 제공되도록 만드는 것이 바람직하다.
    • 하지만 DAO의 사용 기술과 구현 코드는 전략 패턴과 DI를 통해서 DAO를 사용하는 클라이언트에게 감출 수 있지만 예외는 감출 수 없다.
    • 데이터 접근 기술을 사용하는 DAO마다 발생하는 예외의 대한 처리방법이 달라져야 하기에 결국 클라이언트가 DAO의 기술에 의존적이 될 수 밖에 없다.
    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을 할 수는 있지만 너무 무책임한 선언이다.
    • 단지 인터페이스로 추상화하고, 일부 기술에서 발생하는 체크 예외를 언체크/런타임 예외로 전환하는것만으로는 불충분하다.

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

    • 스프링은 자바의 다양한 데이터 엑세스 기술을 사용할때 발생할 수 있는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다.
      • 어느 데이터 엑세스 기술이든 부정확하게 사용한 경우 InvalidDataAccessResourceUsageException 예외가 던져진다. 이는 거의 프로그램을 잘못 작성해서 발생하는 오류이다. 그리고 발생한 예외를 세분화하여 각각의 데이터 엑세스 기술마다의 Exception으로 구분된다.
      • 이런 성격의 예외를 InvalidDataAccessResourceUsageException 타입의 예외로 던져주므로 시스템 레벨의 예외처리 작업을 통해 개발자에게 빠르게 통보해주도록 만들 수 있다. 
    • JdbcTemplate과 같이 스프링의 데이터 엑세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다.
      • 결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 엑세스 기술과 구현방법에 독립적인 이상적인 DAO를 제작할 수 있다.

    DataAccessException 활용 시 주의사항

    • SQLException에 담긴 DB의 에러코드를 바로 해석하는 JDBC와 달리 JPA, Hibernate, JDO의 경우 DB의 에러코드와 달리 예외들은 세분화 되어있지않아 키 값이 중복되어 발생하는 DuplicationKeyException을 기대할 수 없다.
      • 그렇기에 DAO에서 사용하는 기술의 종류와 상관없이 동일한 예외를 기대하고 싶다면 사용자예외를 정의한 후 예외 전환을 해주어야할 필요가 있다.
    • 스프링은 SQLException을 DataAccessException으로 전환하는 다양한 방법을 제공한다. 가장 보편적이고 효과적인 방법은 DB에러 코드를 이용하는 것이다.
      • SQLException을 코드에서 직접 전환하고 싶다면 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다.
        • 이 Translator는 에러 코드 변환에 필요한 DB의 종류를 알아내기 위해 현재 연결된 DataSource를 필요로 한다.

    인터페이스 전환 및 학습테스트

    public interface UserDao {
        void add(User user);
        User get(String id);
        List<User> getAll();
        void deleteAll();
        int getCount();
        // 예외전환용 디폴트 메소드
        default void addThrownDuplicateUserIdException(User user) {
            try {
                add(user);
            } catch (DuplicateKeyException e) {
                throw new DuplicateUserIdException(e);
            }
        }
    }
    public class UserDaoJdbc implements UserDao {
        @Override
        public void add(final User user) {
        	this.jdbcTemplate.update("insert into users(id, name, pwd) values(?,?,?)", user.getId(), user.getName(), user.getPwd()); // add method none throw SQLException
        }
        @Override
        public User get(String id) {
            return this.jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id}, this.userRowMapper);
        }
    
        @Override
        public List<User> getAll() {
            return this.jdbcTemplate.query("select * from users order by id", this.userRowMapper);
        }
    
        @Override
        public void deleteAll() {
            this.jdbcTemplate.update("delete from users");
        }
    
        @Override
        public int getCount() {
            return this.jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
        }
    }
    @SpringBootTest
    @ContextConfiguration(classes = { DaoFactory.class })
    public class UserDaoTest {
        @Test
        @DisplayName("예외 확인예제")
        public void addDuplicateUserIdExceptionTest() {
            dao.deleteAll();
            assertThat(dao.getCount(), is(0));
            User user1 = new User("inwoo", "인우", "jiw");
            User user2 = new User("amuge", "인우", "jiw");
            User user3 = new User("inwoo", "인우", "jiw");
    
            dao.add(user1);
            Assertions.assertEquals(dao.getCount(), 1);
            dao.add(user2);
            Assertions.assertEquals(dao.getCount(), 2);
            // DuplicateUserIdException 예외 전환
            Assertions.assertThrows(DuplicateUserIdException.class, () -> {
                dao.addThrownDuplicateUserIdException(user3);
            });
            // add 예외
            Assertions.assertThrows(DuplicateKeyException.class, () -> {
                dao.add(user3);
            });
            Assertions.assertEquals(dao.getCount(), 2);
        }
        @Test
        @DisplayName("DataSource를 사용 하여 SQLException 전환 예제")
        public void sqlExceptionTranslator() {
            dao.deleteAll();
            User user1 = new User("inwoo", "인우", "jiw");
            User user3 = new User("inwoo", "인우", "jiw");
    
            try {
                dao.add(user1);
                dao.add(user3);
            } catch (DuplicateKeyException e) {
                SQLException sqlException = (SQLException) e.getRootCause();
                SQLExceptionTranslator set = new SQLErrorCodeSQLExceptionTranslator(this.dataSource);   // 코드를 이용해 SQLException의 전환
                // 에러 메시지를 만들때 사용하는 정보이므로 null로 넣어도 상관없다.
                DuplicateKeyException thrown = Assertions.assertThrows(DuplicateKeyException.class, () -> {
                   throw set.translate(null, null, sqlException);
                });
                Assertions.assertEquals(thrown.getClass(), DuplicateKeyException.class);
                Assertions.assertEquals(set.translate(null, null, sqlException).getClass(), DuplicateKeyException.class);
            }
        }
    }
    

    정리

    1. 예외를 잡아서 아무런 조취없이, 의미없이 던지는건 위험하다
    2. 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해야 한다.
    3. 좀 더 의미있는 예외로 변경하거나, 복구가 불가능한 예외 혹은 try/catch 코드를 줄이기 위해 런타임 예외로 포장하는 2가지 방법의 예외전환이 존재한다.
    4. 애플리케이션의 로직을 담기 위한 예외는 체크예외로 JDBC SQLException 같은 복구 불가능한 예외는 런타임 예외로 포장한다.
    5. SQLException 에러 코드는 DB에 종속되기에 DB에 독립적인 예외로 전환될 필요가 있다.
    6. 스프링 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다.
    7. DAO를 데이터 엑세스 기술에서 독립시키려면 인터페이스 도입과 런타임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다.

    'Spring' 카테고리의 다른 글

    토비의 스프링[5] 서비스 추상화  (0) 2021.02.25
    Spring Boot Test  (0) 2021.02.09
    토비의 스프링[3] 템플릿  (0) 2021.01.14
    토비의 스프링[2] 테스트  (0) 2020.12.23
    토비의 스프링[1] 오브젝트와 의존관계  (0) 2020.12.17

    댓글

Designed by Tistory.